提交 69ca960b 作者: 方治民

feat: 添加 mapbox 地图组件、农场详情示例(功能不完整)

上级 335172ee
shamefully-hoist=true
strict-peer-dependencies=false strict-peer-dependencies=false
shell-emulator=true
registry=https://registry.npmmirror.com registry=https://registry.npmmirror.com
disturl=https://registry.npmmirror.com/-/binary/node/
...@@ -81,11 +81,12 @@ ...@@ -81,11 +81,12 @@
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001", "@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
"@dcloudio/uni-ui": "^1.5.11", "@dcloudio/uni-ui": "^1.5.11",
"@faker-js/faker": "^9.9.0", "@faker-js/faker": "^9.9.0",
"@turf/turf": "^7.3.0",
"@vant/area-data": "^2.1.0", "@vant/area-data": "^2.1.0",
"@vue/runtime-core": "3.4.21", "@vue/runtime-core": "3.4.21",
"@vueuse/core": "^10.11.1", "@vueuse/core": "^10.11.1",
"@vueuse/shared": "^11.3.0", "@vueuse/shared": "^11.3.0",
"axios": "^1.13.1", "axios": "^1.13.2",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"dayjs-plugin-lunar": "^1.4.1", "dayjs-plugin-lunar": "^1.4.1",
...@@ -94,7 +95,7 @@ ...@@ -94,7 +95,7 @@
"pinia": "2.0.36", "pinia": "2.0.36",
"qs": "6.9.7", "qs": "6.9.7",
"stompjs": "^2.3.3", "stompjs": "^2.3.3",
"tyme4ts": "^1.3.8", "tyme4ts": "^1.3.9",
"urijs": "^1.19.11", "urijs": "^1.19.11",
"vue": "3.4.21", "vue": "3.4.21",
"vue-demi": "^0.14.10", "vue-demi": "^0.14.10",
...@@ -113,12 +114,13 @@ ...@@ -113,12 +114,13 @@
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001", "@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/uni-uts-v1": "3.0.0-4060620250520001", "@dcloudio/uni-uts-v1": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001", "@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"@iconify/json": "^2.2.403", "@iconify/json": "^2.2.410",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^22.19.0", "@types/mapbox-gl": "^2.7.21",
"@types/node": "^22.19.1",
"@types/qs": "^6.14.0", "@types/qs": "^6.14.0",
"@types/stompjs": "^2.3.9", "@types/stompjs": "^2.3.10",
"@types/urijs": "^1.19.26", "@types/urijs": "^1.19.26",
"@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0", "@typescript-eslint/parser": "^7.18.0",
...@@ -137,7 +139,8 @@ ...@@ -137,7 +139,8 @@
"jest": "27.0.4", "jest": "27.0.4",
"jest-environment-node": "27.5.1", "jest-environment-node": "27.5.1",
"less": "^4.4.2", "less": "^4.4.2",
"lint-staged": "^16.2.6", "lint-staged": "^16.2.7",
"mapbox-gl": "2.15.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"pont-engine": "^1.6.3", "pont-engine": "^1.6.3",
...@@ -145,10 +148,10 @@ ...@@ -145,10 +148,10 @@
"postcss-html": "^1.8.0", "postcss-html": "^1.8.0",
"postcss-less": "^6.0.0", "postcss-less": "^6.0.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"rimraf": "^6.1.0", "rimraf": "^6.1.2",
"sass": "^1.93.3", "sass": "^1.94.2",
"sort-package-json": "^3.4.0", "sort-package-json": "^3.4.0",
"stylelint": "^16.25.0", "stylelint": "^16.26.0",
"stylelint-config-html": "^1.1.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-recommended": "^14.0.1", "stylelint-config-recommended": "^14.0.1",
"stylelint-config-standard": "^36.0.1", "stylelint-config-standard": "^36.0.1",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
import type mapboxgl from 'mapbox-gl'
import qs from 'qs'
import { isEmpty, isNull, isObject, isString, isUndefined, merge, omitBy } from 'lodash-es'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, HandlerUtil } from './index'
import type { GeoJSONSourceDataUrlParams, HandlerUtilType, MapboxConfig, MapboxInstance } from './index'
import { API_URL, API_URL_PREFIX } from '/@/utils/net'
import { isProdMode } from '@/utils/env'
import { useUserStoreWithOut } from '@/store/modules/user'
// 组件名称
export const name = 'Mapbox'
/**
* 注册 Mapbox 实例
* @param config Mapbox 配置项
*/
export function useMapbox<T extends MapboxInstance, P extends MapboxConfig>(
config: P,
): [(instance: T) => void, MapboxInstance] {
const instanceRef = ref<T>()
function register(instance: T) {
if (isProdMode()) {
if (instance === unref(instanceRef)) {
return
}
onUnmounted(() => {
instanceRef.value = null
})
}
instanceRef.value = instance
config && Object.keys(config)?.length && instance?.setConfig(config)
}
function getInstance() {
const instance = unref(instanceRef)
if (!instance) {
console.warn('Mapbox instance is undefined!')
}
return instance as T
}
return [
register,
{
setRegion: (region: string, flyTo?: boolean) => getInstance()?.setRegion(region, flyTo),
setConfig: (config: Partial<P>) => getInstance()?.setConfig(config),
isReady: computed(() => instanceRef?.value?.isReady) as unknown as ComputedRef<boolean>,
on: (type: string, layerId: string, listener: (...args: any[]) => void) =>
getInstance()?.on(type, layerId, listener),
addSource: (id: string, source: mapboxgl.AnySourceData) => getInstance()?.addSource(id, source),
removeSource: (id: string) => getInstance()?.removeSource(id),
setGeoJSONSourceForRequest: (
id: string,
config: string | GeoJSONSourceDataUrlParams,
handler?: (
data: Recordable,
util: HandlerUtilType,
) => GeoJSON.Feature<GeoJSON.Geometry> | GeoJSON.FeatureCollection<GeoJSON.Geometry>,
filters?: string[],
) =>
getInstance()?.setGeoJSONSourceForRequest(id, buildGeoJSONSourceDataUrl(config), handler, [
...(filters || ['jsonObject', 'liveVos']),
]),
setGeoJSONSourceData: (id: string, data: mapboxgl.GeoJSONSource['setData'], filter?: string) =>
getInstance()?.setGeoJSONSourceData(id, data, filter),
setVectorTileSourceTiles: (id: string, tiles: string[]) =>
getInstance()?.setVectorTileSourceTiles(id, tiles),
updateRasterImage: (id: string, url: string) => getInstance()?.updateRasterImage(id, url),
addLayer: (layer: mapboxgl.Layer, beforeId?: string, popup?: boolean) =>
getInstance()?.addLayer(layer, beforeId, popup),
removeLayer: (id: string) => getInstance()?.removeLayer(id),
setPaintProperty: (layerId: string, name: string, value: any) =>
getInstance()?.setPaintProperty(layerId, name, value),
setLayoutProperty: (layerId: string, name: string, value: any) =>
getInstance()?.setLayoutProperty(layerId, name, value),
setFilter: (layerId: string, filter: any[]) => getInstance()?.setFilter(layerId, filter),
flyTo: (options: mapboxgl.FlyToOptions) => getInstance()?.flyTo(options),
addMarker: (id: string, lnglat: [number, number], popup?: string, popupDefaultOpen?: boolean) =>
getInstance()?.addMarker(id, lnglat, popup, popupDefaultOpen),
removeMarker: (id: string) => getInstance()?.removeMarker(id),
removePopup: () => getInstance()?.removePopup(),
loadImage: (url: string, callback: (error: Error | null, image: HTMLImageElement) => void) =>
getInstance()?.loadImage(url, callback),
addImage: (id: string, image: HTMLImageElement) => getInstance()?.addImage(id, image),
},
]
}
/**
* 添加默认的 GeoJSON Source - 空白数据源
* @param map 地图实例
* @param id source id
* @param features features
*/
export function addDefaultGeoJSONSource(map: MapboxInstance, id: string, features?: GeoJSON.Feature[]) {
map.addSource(id, {
type: 'geojson',
data: HandlerUtil.createFeatureCollection(features),
})
}
/**
* 添加默认的色斑 Fill Layer
* @param map 地图实例
* @param id layer id
* @param layer layer config
* @param beforeId before layer id
*/
export function addDefaultSplotLayer(
map: MapboxInstance,
id: string,
layer?: Partial<mapboxgl.Layer>,
beforeId: string = 'fill-placeholder',
popop: boolean = true,
) {
map.addLayer(
merge(
{
id,
type: 'fill',
source: id,
paint: {
'fill-color': {
type: 'identity',
property: 'color',
},
'fill-opacity': 1,
},
},
layer,
),
beforeId,
popop,
)
}
/**
* 添加默认的点位 Symbol Layer - 默认支持 Symbol 的 Click + Popup 事件
* @param map 地图实例
* @param id layer id
* @param layer layer config
* @param beforeId before layer id
* @param popop 是否支持 Popup
*/
export function addDefaultSymbolLayer(
map: MapboxInstance,
id: string,
layer?: Partial<mapboxgl.Layer>,
beforeId: string = 'symbol-placeholder',
popop: boolean = true,
) {
map.addLayer(
merge(
{
id,
type: 'symbol',
source: id,
layout: {
'text-field': '{value}',
// 'text-font': ['PingFang SC', 'Helvetica', 'Helvetica Neue', 'Microsoft YaHei', 'sans-serif'],
'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'],
'text-size': 14,
'text-offset': [0, 1.2],
'icon-image': 'point.normal',
'icon-anchor': 'top',
'icon-size': 0.4,
},
paint: {
'text-color': '#3766fd',
'text-halo-color': '#fff',
'text-halo-width': 1,
},
},
layer,
),
beforeId,
popop,
)
}
export function flyTo(
map: MapboxInstance,
center: [number, number] = DEFAULT_MAP_CENTER,
zoom: number = DEFAULT_MAP_ZOOM,
) {
map.flyTo({
center,
zoom,
speed: 0.2,
essential: true,
})
}
/**
* 构建 GeoJSON Source Data Url - 默认添加 token + 过滤空参数
* @param config 接口配置
* @returns source url
*/
export function buildGeoJSONSourceDataUrl(config: GeoJSONSourceDataUrlParams | string): string {
const userStore = useUserStoreWithOut()
const baseURL = `${API_URL}${API_URL_PREFIX}`
const defaultParams = {
Authorization: userStore.getToken,
}
const url = typeof config === 'string' ? config : config.url
const params = typeof config === 'string' ? defaultParams : { ...defaultParams, ...config.params }
const query = qs.stringify(
omitBy(
params,
(value) =>
isNull(value) ||
isUndefined(value) ||
(isString(value) && isEmpty(value)) ||
(isObject(value) && isEmpty(value)),
),
{ addQueryPrefix: true, encode: true, encodeValuesOnly: true },
)
return `${baseURL}${url}${query}`
}
/**
* 获取当前位置
* @returns UniApp.GetLocationSuccess
*/
export function getLocation() {
return new Promise<UniApp.GetLocationSuccess>((resolve, reject) => {
// 获取用户定位
uni.getLocation({
success: (res) => {
resolve(res)
// TODO: Test
// resolve({
// longitude: 112.989113,
// latitude: 28.039308,
// })
},
fail: (err) => {
Message.toast('获取位置失败')
reject(err)
},
})
})
}
// @unocss-ignore
import type { Ref } from 'vue'
import type mapboxgl from 'mapbox-gl'
import { toRaw } from 'vue'
import { appendScript, appendStylesheet } from '@/utils/dom'
// Mapbox accessToken
export const accessToken = 'pk.eyJ1IjoiaWZ6bSIsImEiOiJjamswc3M1a2gwYWEwM3Zxa3ZsMjh3djkwIn0.h93-XtdRdtoC1KbVv_ZIow'
// 天地图密钥
export const tk = 'aa0ccd36f2dbb86dbb16cbf63f0034a6'
// 自定义图层 host 注意:renderjs里面不能引用带有 import.env 的任何文件,否则会编译报错
// const host = 'https://hntq.yiring.com/gis'
const host = 'https://foxgis.app.yiring.com'
// 构建天地图图层地址
export function buildTdtTileUrl(id: string, tk: string) {
return `${host}/api/tdt/cache/${id}/{z}/{y}/{x}/${tk}?v=2022`
}
/**
* 地图默认中心点
*/
export const DEFAULT_MAP_CENTER: [number, number] = [113.01798803033034, 28.098790979279613]
/**
* 地图默认缩放级别
*/
export const DEFAULT_MAP_ZOOM = 11.5
/**
* 地图样式
*/
export const defaultStyle: mapboxgl.Style = {
version: 8,
zoom: DEFAULT_MAP_ZOOM,
center: [...DEFAULT_MAP_CENTER],
sprite: `${host}/api/sprites/drainage/sprite`,
glyphs: `${host}/api/fonts/{fontstack}/{range}.pbf`,
sources: {
'wms-img_w-source': {
type: 'raster',
tiles: [buildTdtTileUrl('img', tk)],
tileSize: 256,
maxzoom: 18,
},
'wms-cia_w-source': {
type: 'raster',
tiles: [buildTdtTileUrl('cia', tk)],
tileSize: 256,
maxzoom: 18,
},
},
layers: [
// 占位图层
{
id: 'background',
type: 'background',
layout: { visibility: 'visible' },
paint: { 'background-color': '#FFFFFF' },
},
// 默认图层 - 影像底图
{
id: 'wms-img_w-layer',
type: 'raster',
source: 'wms-img_w-source',
layout: { visibility: 'visible' },
},
// 默认图层 - 标注底图
{
id: 'wms-cia_w-layer',
type: 'raster',
source: 'wms-cia_w-source',
layout: { visibility: 'visible' },
},
// 天空图层
{
id: 'sky',
type: 'sky',
paint: {
'sky-type': 'gradient',
'sky-gradient': [
'interpolate',
['linear'],
['sky-radial-progress'],
0.8,
'rgba(135, 206, 235, 1.0)',
1,
'rgba(0,0,0,0.1)',
],
'sky-gradient-center': [0, 0],
'sky-gradient-radius': 90,
'sky-opacity': ['interpolate', ['exponential', 0.1], ['zoom'], 5, 0, 22, 1],
},
},
// 色斑渲染占位图层
{
id: 'fill-placeholder',
type: 'background',
layout: { visibility: 'none' },
},
// 遮罩占位图层
{
id: 'mask-placeholder',
type: 'background',
layout: { visibility: 'none' },
},
// 数值渲染占位图层
{
id: 'symbol-placeholder',
type: 'background',
layout: { visibility: 'none' },
},
],
}
export function buildTileJsonURL(tileset: string) {
return `${host}/api/tilesets/${tileset}/tilejson`
}
export function buildJsonAssetURL(tileset: string) {
return `${host}/api/assets/json/${tileset}.json`
}
interface LayerOption {
label: string
value: string
image: string
active?: boolean
}
/**
* 图层控件
* 目前仅支持天地图图层
*/
class LayerControl implements mapboxgl.IControl {
map: mapboxgl.Map | undefined
container: HTMLElement | undefined
tk: string
layers?: LayerOption[]
constructor(options: Recordable) {
this.tk = options.tk
this.layers = options.layers
}
onAdd(map: mapboxgl.Map) {
this.map = map
this.container = document.createElement('div')
this.container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group mapbox-control'
const layersWrapHtml: string[] = []
if (this.layers) {
layersWrapHtml.push('<div class="mapboxgl-ctrl-layer-wrap">')
this.layers.forEach((layer) => {
layersWrapHtml.push(`
<div class="mapboxgl-ctrl-layer ${layer.active ? 'active' : ''}" data-layer-ids="${layer.value}">
<img src="${layer.image}" alt="${layer.label}" />
<span>${layer.label}</span>
</div>
`)
for (const id of layer.value.split(',')) {
map.addLayer(
{
id: `t_${id}`,
type: 'raster',
source: {
type: 'raster',
tiles: [buildTdtTileUrl(id, this.tk)],
tileSize: 256,
maxzoom: 18,
},
layout: { visibility: layer.active ? 'visible' : 'none' },
},
'sky',
)
}
})
layersWrapHtml.push('</div>')
}
this.container.innerHTML = `
<button type="button" class="mapboxgl-ctrl-layer-button" title="地图样式选择">
<span class="mapboxgl-ctrl-icon" aria-hidden="true" title="Layer"></span>
${layersWrapHtml.join('')}
</button>
`
this.container.addEventListener('click', (e) => {
const target = e.target as HTMLElement
const parent = target.parentElement as HTMLElement
if (parent.classList.contains('mapboxgl-ctrl-layer')) {
const ids = parent.getAttribute('data-layer-ids') as string
const list = parent.parentElement?.children as HTMLCollection
for (let i = 0; i < list.length; i++) {
const item = list[i] as HTMLElement
if (item.classList.contains('mapboxgl-ctrl-layer')) {
if (item.getAttribute('data-layer-ids') === ids) {
item.classList.toggle('active')
} else {
item.classList.remove('active')
}
}
}
this.layers?.forEach((layer) => {
for (const id of layer.value.split(',')) {
map.setLayoutProperty(`t_${id}`, 'visibility', 'none')
}
})
if (parent.classList.contains('active')) {
for (const id of ids.split(',')) {
map.setLayoutProperty(`t_${id}`, 'visibility', 'visible')
}
}
return
}
this.container?.querySelector('button')?.classList.toggle('-active')
})
return this.container
}
onRemove() {
this.container?.parentNode?.removeChild(this.container)
this.map = undefined
}
}
/**
* 经纬度坐标拾取控件
*/
class GeolocateInfoControl implements mapboxgl.IControl {
map: mapboxgl.Map | undefined
container: HTMLElement | undefined
toText(lngLat: mapboxgl.LngLat) {
return `经度:${lngLat.lng.toFixed(6)}, 纬度:${lngLat.lat.toFixed(6)}`
}
onAdd(map: mapboxgl.Map) {
this.map = map
this.container = document.createElement('div')
this.container.className = 'mapboxgl-ctrl mapboxgl-ctrl-lnglat'
this.container.textContent = this.toText(map.getCenter())
map.on('mousemove', (e) => {
const element = this.container as HTMLElement
element.textContent = this.toText(e.lngLat)
})
return this.container
}
onRemove() {
this.container?.parentNode?.removeChild(this.container)
this.map = undefined
}
}
/**
* 地图复位控件
*/
class ResetControl implements mapboxgl.IControl {
map: mapboxgl.Map | undefined
container: HTMLElement | undefined
options: mapboxgl.FlyToOptions
constructor(options: mapboxgl.FlyToOptions) {
this.options = options
}
onAdd(map: mapboxgl.Map) {
this.map = map
this.container = document.createElement('div')
this.container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group mapbox-control'
this.container.innerHTML = `
<button type="button" class="mapboxgl-ctrl-reset-button mapboxgl-ctrl-reset" title="地图复位">
<span class="mapboxgl-ctrl-icon" aria-hidden="true" title="Reset"></span>
</button>
`
this.container.addEventListener('click', () => {
// alert('map: ' + JSON.stringify(this.map.getCenter()))
this.map?.flyTo(this.options)
})
return this.container
}
onRemove() {
this.container?.parentNode?.removeChild(this.container)
this.map = undefined
}
}
/**
* 测量控件
*/
class MeasureControl implements mapboxgl.IControl {
map: mapboxgl.Map | undefined
container: HTMLElement | undefined
onAdd(map: mapboxgl.Map) {
this.map = map
this.container = document.createElement('div')
this.container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group mapbox-control'
this.container.innerHTML = `
<button type="button" class="mapboxgl-ctrl-reset-button" title="面积计算">
<span class="iconify" data-icon="carbon:area-custom" data-width="24" data-height="24"></span>
</button>
`
this.container.addEventListener('click', () => {
this.container?.querySelector('button')?.classList.toggle('-active')
console.log('TODO: 测量面积功能暂未实现, 敬请期待...')
})
return this.container
}
onRemove() {
this.container?.parentNode?.removeChild(this.container)
this.map = undefined
}
}
/**
* 风场控件
*/
export class WindyControl implements mapboxgl.IControl {
map: mapboxgl.Map | undefined
container: HTMLElement | undefined
onOpen: () => Promise<void>
onClose: Function
constructor(options: Recordable) {
this.onOpen = options.onOpen
this.onClose = options.onClose
}
onAdd(map: mapboxgl.Map) {
this.map = map
this.container = document.createElement('div')
this.container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group mapbox-control'
this.container.innerHTML = `
<button type="button" class="mapboxgl-ctrl-reset-button" title="风场">
<span class="iconify" data-icon="ph:wind-light" data-width="24" data-height="24"></span>
</button>
`
this.container.addEventListener('click', () => {
const button = this.container?.querySelector('button')
if (!button?.classList.contains('-active')) {
this.onOpen().then(() => {
button?.classList.add('-active')
})
} else {
this.onClose()
button?.classList.remove('-active')
}
})
return this.container
}
onRemove() {
this.container?.parentNode?.removeChild(this.container)
this.map = undefined
}
}
export interface MapOptions {
control?: MapControlOptions
style?: mapboxgl.Style | Recordable
}
export interface MapControlOptions {
// 导航控件
navigation?: {
zoom?: boolean
compass?: boolean
}
// 图层控制
layer?:
| boolean
| {
tk: string
layers: LayerOption[]
}
// 重置控件
reset?: boolean | mapboxgl.FlyToOptions
// 全屏控件
fullscreen?: boolean
// 比例尺
scale?: boolean
// 经纬度拾取
info?: boolean
// 定位控件
geolocate?: { initial?: boolean; zoom?: number; fn?: any }
// 面积测算控件
measure?: boolean
// 距离测算控件
ruler?: boolean
// 风场控件
windy?: boolean
// 绘制控件
draw?: boolean
}
/**
* 加载地图控件
* @param map 地图实例
* @param options 控件配置
*/
export function loadMapControl(mapboxgl: any, map: mapboxgl.Map, options?: MapControlOptions) {
if (!options) {
return
}
// 导航控件
if (options.navigation) {
map.addControl(
new mapboxgl.NavigationControl({
showCompass: options.navigation.compass,
showZoom: options.navigation.zoom,
}),
)
}
// 全屏控件
if (options.fullscreen) {
map.addControl(new mapboxgl.FullscreenControl())
}
// 地图复位控件
if (options.reset) {
if (typeof options.reset === 'boolean') {
map.addControl(
new ResetControl({
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
center: map.getCenter(),
}),
)
} else {
map.addControl(new ResetControl(options.reset))
}
}
// 图层选择控件
if (options.layer) {
if (typeof options.layer === 'boolean') {
map.addControl(
new LayerControl({
tk,
layers: [
{
label: '影像',
value: 'img,cia',
image: `${host}/api/assets/images/img_w.png`,
},
{
label: '矢量',
value: 'vec,cva',
image: `${host}/api/assets/images/vec_w.png`,
active: true,
},
{
label: '地形',
value: 'ter,cta',
image: `${host}/api/assets/images/ter_w.png`,
},
],
}),
)
} else {
map.addControl(new LayerControl(options.layer))
}
}
// 面积测量控件
if (options.measure) {
map.addControl(new MeasureControl())
}
// 经纬度信息控件
if (options.info) {
map.addControl(new GeolocateInfoControl(), 'bottom-left')
}
// 比例尺控件
if (options.scale) {
map.addControl(new mapboxgl.ScaleControl(), 'bottom-left')
}
// 定位
if (options.geolocate) {
const geolocate = new mapboxgl.GeolocateControl({
fitBoundsOptions: {
maxZoom: options.geolocate.zoom || 15,
duration: 0,
},
positionOptions: {
enableHighAccuracy: true,
timeout: 30000,
},
trackUserLocation: false,
showUserHeading: true,
})
// #ifdef APP-PLUS
geolocate.geolocation = options.geolocate
// #endif
map.addControl(geolocate, 'bottom-right')
// 初始化定位
if (options.geolocate.initial) {
// 由于定位控件初始化需要时间,所以延迟 0.5 秒后再触发定位
setTimeout(() => {
geolocate.trigger()
}, 500)
}
}
}
export function toPointGeoJSON(
data?: ({ lon: number; lat: number } & Recordable)[],
): GeoJSON.FeatureCollection<GeoJSON.Geometry, GeoJSON.GeoJsonProperties> {
return {
type: 'FeatureCollection',
features: !data
? []
: data.map((item) => {
return {
type: 'Feature',
properties: {
...item,
},
geometry: {
type: 'Point',
coordinates: [item.lon, item.lat],
},
}
}),
}
}
export function createPopupHtml(entry: Recordable, mapping: Recordable) {
const htmls: string[] = ['<div class="mapboxgl-custom-popup">']
for (const key in entry) {
const lable = mapping[key]
if (!lable) {
continue
}
const value = entry[key]
htmls.push(`
<div class="popup-row">
<span class="popup-title">${lable}:</span>
${value}
</div>
`)
}
htmls.push('</div>')
return htmls.join('')
}
/**
* 加载地图依赖库
*/
export function loadMapboxLibs() {
const id = 'mapbox-gl'
const version = '2.15.0'
const resource = `static/js/mapbox-gl-js/${version}/${id}`
return Promise.all([
appendScript(id, `${resource}.js`),
appendStylesheet(id, `${resource}.css`),
appendStylesheet(id, `${resource}.extra.css`),
])
}
export const getMapInstance = (ref: Ref<any>): mapboxgl.Map => toRaw(ref.value?.map) as mapboxgl.Map
export function getGeoJSONSource(map: mapboxgl.Map, sourceName: string): mapboxgl.GeoJSONSource {
return map.getSource(sourceName) as mapboxgl.GeoJSONSource
}
export function getImageSource(map: mapboxgl.Map, sourceName: string): mapboxgl.ImageSource {
return map.getSource(sourceName) as mapboxgl.ImageSource
}
export const EmptyImage =
'data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=='
export function sendRequest(url: string, method = 'GET', data = {}) {
return new Promise((resolve, reject) => {
const uri = new URL(url)
const xhr = new XMLHttpRequest()
xhr.open(method, uri)
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded')
xhr.onload = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText)
if (result.status === 200) {
resolve(result)
} else {
reject(new Error(xhr.responseText))
}
} else {
reject(new Error(JSON.stringify({ status: xhr.status, message: xhr.statusText })))
}
}
}
xhr.onerror = function () {
reject(new Error(JSON.stringify({ status: 502, message: '网络错误' })))
}
xhr.send(JSON.stringify(data))
// console.debug('[SendRequest]', uri, method, data)
})
}
/**
* Mapbox 组件配置参数类型定义
*/
export interface MapboxConfig {
/**
* 地图行政区域编码,最小支持到区县级别
* @example 430000
* @default ''
* FIEXD: 目前仅支持湖南省,其他省份暂不支持
*/
region?: string
/**
* 自动飞行到区域中心
* @default true
*/
autoFlyToRegionCenter?: boolean
/**
* Mapbox 构造函数参数
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/
*/
options?: Partial<mapboxgl.MapboxOptions>
/**
* Mapbox 地图样式
* @link https://docs.mapbox.com/mapbox-gl-js/style-spec/
*/
style?: Partial<mapboxgl.Style>
/**
* 地图资源/数据源来源或归属描述信息
*/
attribution?: {
/**
* 文本
* @example 湖南省气象台
* @default ''
*/
text: string
/**
* 位置, 默认为 'bottom-right'
* @example 'bottom-left', 'bottom-right'
* @default 'bottom-right'
*/
align?: 'bottom-left' | 'bottom-right'
}
/**
* 扩展配置
*/
extra?: {
control?: MapControlOptions
}
// =============== 自定义事件 =================
/**
* 地图加载完成事件
* @param data 事件数据, 由视图层在 Map 加载完成后传入的可序列化 JSON 数据
*/
onLoaded?: (data: Recordable) => void
/**
* 监听请求数据源的请求回执
* @param id 数据源 ID
* @param url 请求地址
* @param data 请求回来的数据(默认情况下将过滤掉一些大字段或数据集,例如:jsonObject、liveVos)
*/
onSourceRequestHandle?: (data: { id: string; url: string; data: Recordable }) => void
/**
* 监听地图事件
* 可以用来处理地图点击事件、自定义事件等
* @param e 事件对象
*/
onMapEvent?: (e: { type: 'click' | 'custom' | string; name: string; data: Recordable }) => void
}
/**
* Mapbox 组件实例类型定义
*/
export interface MapboxInstance {
/**
* 设置地图行政区域编码,例如: 430000, 设置后地图的遮罩层将仅包含该区域的边界
* FIXED: 暂时仅支持湖南省
* @param region 地图显示行政区域编码
* @param flyTo 是否飞行到区域中心
*/
setRegion: (region: string, flyTo?: boolean) => void
/**
* 设置地图组件配置
* @param config 地图配置
*/
setConfig: (config: Partial<MapboxConfig>) => void
/**
* 地图组件是否准备好了
*/
isReady: ComputedRef<boolean>
/**
* 监听地图事件
* @param type 事件类型
* @param layerId 图层 ID
* @param listener 事件监听器
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#on
*/
on: (type: string, layerId: string, listener: (...args: any[]) => void) => void
/**
* 飞行到指定位置
* @param options 飞行参数
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#flyto
*/
flyTo: (options: mapboxgl.FlyToOptions) => void
/**
* 添加数据源
* @param id 数据源 ID
* @param source 数据源
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#addsource
*/
addSource: (id: string, source: mapboxgl.AnySourceData) => void
/**
* 移除数据源
* @param id 数据源 ID
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#removesource
*/
removeSource: (id: string) => void
/**
* 设置 GeoJSON 数据源,如果数据源不存在则创建
* @param id 数据源 ID
* @param url 数据源 URL,通常是一个后端的数据查询接口(色斑图查询接口、站点数据查询接口)
* @param map 数据映射函数,用于处理后端返回的数据,将其转换为 GeoJSON 数据
* @param filters 过滤对象,用于过滤响应 onSourceRequestHandle 事件时的数据,默认过滤掉 jsonObject、liveVos 字段
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#addsource
*/
setGeoJSONSourceForRequest: (
id: string,
url: string | GeoJSONSourceDataUrlParams,
map?: (
data: Recordable,
util: HandlerUtilType,
) => GeoJSON.Feature<GeoJSON.Geometry> | GeoJSON.FeatureCollection<GeoJSON.Geometry> | any,
filters?: string[],
) => void
/**
* 设置 GeoJSONSource 数据源 data
* @param id 数据源 ID
* @param data 数据
* @param filter 过滤器, 可选, 过滤 JSON 数据, 使用 json path 语法
* @link https://docs.mapbox.com/mapbox-gl-js/api/sources/#geojsonsource#setdata
*/
setGeoJSONSourceData: (id: string, data: mapboxgl.GeoJSONSource['setData'] | Recordable, filter?: string) => void
/**
* 设置 VectorTileSource 数据源 tiles
* @param id 数据源 ID
* @param tiles 瓦片源
* @link https://docs.mapbox.com/mapbox-gl-js/api/sources/#vectortilesource#settiles
*/
setVectorTileSourceTiles: (id: string, tiles: string[]) => void
/**
* 更新 ImageSource 数据源 image url
* @param id 数据源 ID
* @param url 图片地址
* @link https://docs.mapbox.com/mapbox-gl-js/api/sources/#imagesource#updateimage
*/
updateRasterImage: (id: string, url: string) => void
/**
* 添加图层
* @param layer 图层
* @param beforeId 在指定图层之前插入
* @param popup 是否添加点击弹窗提示
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#addlayer
*/
addLayer: (layer: mapboxgl.Layer, beforeId?: string, popup?: boolean) => void
/**
* 移除图层
* @param id 图层 ID
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#removelayer
*/
removeLayer: (id: string) => void
/**
* 设置图层过滤器
* @param layerId 图层 ID
* @param filter 过滤器
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#setfilter
*/
setFilter: (layerId: string, filter: any[]) => void
/**
* 设置图层样式
* @param layerId 图层 ID
* @param name 样式名称
* @param value 样式值
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#setpaintproperty
*/
setPaintProperty: (layerId: string, name: string, value: any) => void
/**
* 设置图层样式
* @param layerId 图层 ID
* @param name 样式名称
* @param value 样式值
* @link https://docs.mapbox.com/mapbox-gl-js/api/map/#map#setlayoutproperty
*/
setLayoutProperty: (layerId: string, name: string, value: any) => void
/**
* 添加 Marker
* @param id Marker ID
* @param lngLat 经纬度
* @param popup 弹窗内容
* @param popupDefaultOpen 是否默认打开弹窗
* @link https://docs.mapbox.com/mapbox-gl-js/api/markers/#marker
*/
addMarker(id: string, lngLat: number[], popup?: string, popupDefaultOpen?: boolean): void
/**
* 移除 Marker
* @param id Marker ID
*/
removeMarker(id: string): void
/**
* 移除所有Popup
* @default
*/
removePopup(): void
/**
* 加载图片资源
* @default
*/
loadImage(url: string, callback: (error?: Error, result?: HTMLImageElement | ImageBitmap) => void): void
addImage(
name: string,
image:
| HTMLImageElement
| ArrayBufferView
| { width: number; height: number; data: Uint8Array | Uint8ClampedArray }
| ImageData
| ImageBitmap,
options?: { pixelRatio?: number; sdf?: boolean },
): this
}
export interface GeoJSONSourceDataUrlParams {
url: string
params: Recordable
}
/**
* 站点数据类型定义
*/
export interface Station extends Recordable {
/**
* key 用于标识站点是什么类型的数据(例如:降水量、气温...)
*/
key: string
value: string | number
suffix?: string
stationCode?: string
stationName?: string
city: string
cnty: string
lon: string
lat: string
[key: string]: any
}
/**
* 判断是否为非空对象
* @param obj 对象
* @returns 是否为非空对象
*/
function isPlainObject(obj: any): boolean {
if (typeof obj !== 'object' || obj === null || obj === undefined) {
return false
}
const proto = Object.getPrototypeOf(obj)
if (proto === null) {
return true
}
const constructor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor
return (
typeof constructor === 'function' &&
constructor instanceof constructor &&
Function.prototype.toString.call(constructor) === Function.prototype.toString.call(Object)
)
}
interface PointFeatureMap {
/**
* 时序按钮标记
*/
timing?: boolean
[key: string]: any
}
/**
* 数据处理工具
*/
export const HandlerUtil = {
isPlainObject,
createDefaulPretPointFeature(
station: Station | Recordable,
map = {} as PointFeatureMap,
isStation = true,
): GeoJSON.Feature<GeoJSON.Geometry> {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [Number(station.cenLon), Number(station.cenLat)],
},
properties: {
...station,
key: station.key,
suffix: station.suffix,
value: station.extremeValue,
popup: HandlerUtil.createPrePopupHtml(
station,
{
'{key}': `${station.extremeValue || '-'}{suffix}`,
...map,
},
isStation,
),
},
}
},
createDefaultPointFeature(
station: Station | Recordable,
map = {} as PointFeatureMap,
isStation = true,
additional: Recordable = {},
): GeoJSON.Feature<GeoJSON.Geometry> {
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [Number(station.lon), Number(station.lat)],
},
properties: {
...station,
key: station.key,
suffix: station.suffix,
value: additional?.valueName ? station[additional.valueName] : station.value,
popup: HandlerUtil.createDefaultPopupHtml(
station,
{
'{key}': `${(additional?.valueName ? station[additional.valueName] : station.value) || '-'
}{suffix}`,
...map,
},
isStation,
),
},
}
},
createFeatureCollection(
features: GeoJSON.Feature<GeoJSON.Geometry>[] = [],
): GeoJSON.FeatureCollection<GeoJSON.Geometry> {
return {
type: 'FeatureCollection',
features: features || [],
}
},
createPrePopupHtml(station: Station | Recordable, map: Recordable, isStation = true): string {
const htmls = ['<div class="custom-popup">']
if (isPlainObject(station) && isStation) {
htmls.push(
`<div class="popup-row"><span class="popup-title">地点:</span>${station.city || ''} ${station.cnty || ''
} ${station.town || ''}</div>`,
)
htmls.push(`<div class="popup-row"><span class="popup-title">发生时间:</span>${station.occurTime}</div>`)
}
if (isPlainObject(map)) {
// 增加内容换行布局
const wrap = map?.wrap
if (wrap) {
delete map.wrap
}
for (const key in map) {
const len = String(key === '{key}' ? station.key : key).length
htmls.push(
`<div class="popup-row"><span class="popup-title" style="min-width: ${len + 1}em">${key}: </span>${wrap ? '</br>' : ''
} ${map[key] || '-'}</div>`,
)
}
}
htmls.push('</div>')
return htmls.join('')
},
createDefaultPopupHtml(station: Station | Recordable, map: Recordable, isStation = true): string {
const htmls = ['<div class="custom-popup">']
if (isPlainObject(station) && isStation) {
htmls.push(`<div class="popup-row"><span class="popup-title">站号:</span>${station.stationCode}</div>`)
htmls.push(
`<div class="popup-row"><span class="popup-title">站名:</span>${station.city || ''} ${station.cnty || ''
} ${station.stationName || ''}</div>`,
)
}
if (isPlainObject(map)) {
// 时序按钮标记
const timing = map.timing
// 附件
const attachment = map.attachment
if (timing) {
delete map.timing
}
if (attachment) {
delete map.attachment
}
// 增加内容换行布局
const wrap = map?.wrap
if (wrap) {
delete map.wrap
}
for (const key in map) {
const len = String(key === '{key}' ? station.key : key).length
if (key !== '{key}') {
htmls.push(
`<div class="popup-row"><span class="popup-title" style="min-width: ${len + 1}em">${key} </span>${wrap ? '</br>' : ''
} ${map[key] || '-'}</div>`,
)
}
}
if (timing) {
htmls.push(`<div class="popup-row timing">{{timing}}</div>`)
}
if (attachment) {
const imgs = (station?.imagePath || []).map((v) => ({ url: v, type: 'img' }))
const videos = (station?.videoPath || []).map((v) => ({
url: v,
type: 'video',
}))
let str = ''
imgs.concat(videos).forEach((v) => {
if (v.type === 'img') {
str += `<img onclick="onPopupContentEventHandle(src)" style="width:30px;margin-right:5px;" src="${v.url}" />`
}
if (v.type === 'video') {
// str += `<video onclick="onPopupContentEventHandle(src)" style="width:30px;margin-right:5px;" src="${v.url}" />`
str += `<div class="popup-row timing">{{download}}</div>`
}
})
htmls.push(`<div class="popup-row"> <span class="popup-title">附件:</span>${str || '~'}</div>`)
}
}
htmls.push('</div>')
return htmls.join('')
},
createRegimenPopupHtml(station: any): string {
// console.log('station', station)
const htmls = ['<div class="custom-popup">']
if (station?.no) {
htmls.push(`<div class="popup-row"><span class="popup-title">编号:</span>${station.no}</div>`)
htmls.push(`<div class="popup-row"><span class="popup-title">类型:</span>${station.typeName}</div>`)
htmls.push(`<div class="popup-row"><span class="popup-title">监测值:</span>${station.value}</div>`)
}
// console.log('htmls', htmls)
htmls.push('</div>')
return htmls.join('')
},
PointGeoJSON(data: Recordable<any>[]) {
// console.log('data', data)
return data.map((item: any) => {
// console.log('item', item)
const coordinates = item.geometry?.geometry?.coordinates?.length
? toRaw(item.geometry?.geometry?.coordinates)
: []
// const coordinates = [112.98983, 28.11426]
// console.log('coordinates', coordinates)
const iconFn = item.type ? { icon: item.type } : {}
// const popup = item?.popup ?? HandlerUtil.createRegimenPopupHtml(item)
return {
type: 'Feature',
// geometry: item.geometry?.geometry,
geometry: {
type: 'Point',
coordinates,
},
properties: {
...item,
...iconFn,
// popup,
},
}
})
},
}
export type HandlerUtilType = typeof HandlerUtil
<script>
import { nanoid } from 'nanoid'
import { getLocation } from './hook'
import { useUserStoreWithOut } from '@/store/modules/user'
import { getRegionConfig } from '@/components/Map/Mapbox/regions'
// FIXED: 重要说明
// renderjs 组件暂不支持 setup 组件写法,对 ts 支持也不友好,这里的写法需要参考 vue2 的写法
// 总共分为两块 script
// 1. 逻辑层 Script
// 2. 视图层 Script
// 通信方式
// 1. 通过在逻辑层 data 中设置的属性向视图层传递数据
// 2. 通过在逻辑层 methods 定义方法,在视图层调用传递参数(注意:只能传递可进行 JSON 序列化的数据)
function noop() {}
export default {
emits: ['register'],
data() {
return {
id: nanoid(),
timeId: null,
onMapEvent: noop,
onLoadedEvent: noop,
onSourceRequestHandleEvent: noop,
onSourceRequestHandleErrorEvent: noop,
options: {},
// FIXED: 定义为 undefined,避免在 Hook 中通过计算属性取值是出现两次变化的问题
loaded: undefined,
// 以下是一些 mapbox 的方法所需参数,方便传递到 renderjs 中
events: {},
eventOptions: undefined,
addSourceOptions: undefined,
addLayerOptions: undefined,
removeOptions: undefined,
setGeoJSONSourceForRequestOptions: undefined,
setGeoJSONSourceDataOptions: undefined,
setVectorTileSourceTilesOptions: undefined,
updateRasterImageOptions: undefined,
setFilterOptions: undefined,
setPaintPropertyOptions: undefined,
setLayoutPropertyOptions: undefined,
flyToOptions: undefined,
regionOptions: undefined,
addMarkerOptions: undefined,
// change options 锁,结合 event,配合数组实现先进先出队列控制
changeLock: false,
changeOptionsQueue: [],
}
},
computed: {
isReady() {
return this.loaded
},
},
created() {
Message.loading()
},
mounted() {
this.$emit('register', this)
this.timeId = setTimeout(() => {
if (!this.loaded) {
Message.hideLoading()
Message.toast('地图加载超时')
}
}, 30 * 1000)
},
unmounted() {
this.timeId && clearTimeout(this.timeId)
},
methods: {
onMapLoad(data) {
this.loaded = true
Message.hideLoading()
// console.debug('✨ Map Loaded', data)
// 触发 onLoaded 事件
this.onLoadedEvent?.(data)
},
onSourceRequestHandle(data) {
// console.debug('✅ Request Success Handle', data)
// 触发 onSourceRequestHandle 事件
this.onSourceRequestHandleEvent?.(data)
},
async onSourceRequestErrorHandle(data) {
console.debug('❌ Request Error Handle', data)
// 触发 onSourceRequestErrorHandle 事件
this.onSourceRequestErrorHandleEvent?.(data)
// 检查请求错误的原因
if (data) {
try {
const { status, message } = JSON.parse(data)
if (status === 401) {
// 跳转到登录页
const userStore = useUserStoreWithOut()
await userStore.logout()
Message.toast('登录信息过期,请重新登录!')
} else {
Message.toast(message)
}
} catch (e) {
console.error(e)
Message.toast(data)
}
}
},
// 监听地图事件(on,custom...)
onEventHandle(event) {
console.debug('📢 Map Event', event)
if (this.events[`on-${event.type}-${event.layer}`]) {
this.events[`on-${event.type}-${event.layer}`]?.(event)
} else {
// 触发 onMapEvent 事件
this.onMapEvent?.(event)
}
},
// 尝试触发事件, 通过 changeLock 控制
tryTriggerChange() {
if (!this.changeLock && this.changeOptionsQueue.length) {
this.changeLock = true
const item = this.changeOptionsQueue.shift()
this[`${item.fn}Options`] = item
}
},
// 监听地图配置从逻辑层到视图层的响应式变化事件
onMapOptionsChangeEvent(event) {
// console.debug('🔊 Map Options Change Event', event)
// 必须重置Options (在app真机环境下存在兼容问题)
this[`${event.type}Options`] = undefined
this.changeLock = false
this.tryTriggerChange()
},
/**
* 解析区域编码获得区域配置
* @param {string} code 区域编码
*/
analysisRegion(code) {
const region = code
const regionConfig = getRegionConfig(region)
return {
region,
regionConfig,
}
},
// 设置地图区域编码,用于覆盖遮罩层
setRegion(value, flyTo = true) {
const { region, regionConfig } = this.analysisRegion(value)
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'region',
region,
regionConfig,
flyTo,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
// 设置地图配置
setConfig(config) {
const { region, regionConfig } = this.analysisRegion(config?.region)
this.options = {
container: this.id,
style: config?.style,
options: config?.options,
attribution: config?.attribution,
extra: config?.extra,
region,
regionConfig,
autoFlyToRegionCenter: config?.autoFlyToRegionCenter,
}
this.onMapEvent = config?.onMapEvent || noop
this.onLoadedEvent = config?.onLoaded || noop
this.onSourceRequestHandleEvent = config?.onSourceRequestHandle || noop
this.onSourceRequestErrorHandleEvent = config?.onSourceRequestErrorHandle || noop
},
// 重新实现 mapbox 的一些方法,方便传递到 renderjs 中
customEvent(event, type, layer, ...args) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'event',
event,
type,
layer,
...args,
})
this.$nextTick(() => {
// 尝试触发 event 事件
this.tryTriggerChange()
})
return `${event}-${type}-${layer}`
},
// 1. on/once/off 事件监听处理
on(type, layer, listener, popupTemplate) {
const key = this.customEvent('on', type, layer, popupTemplate)
this.events[key] = listener
},
onEvent(event) {
const key = `on-${event.type}-${event.layer}`
this.events[key]?.(event)
},
once(type, layer, listener) {
const key = this.customEvent('once', type, layer)
this.events[key] = listener
},
onceEvent(event) {
const key = `once-${event.type}-${event.layer}`
this.events[key]?.(event)
delete this.events[key]
},
off(type, layer) {
const key = this.customEvent('off', type, layer)
delete this.events[key]
},
// 2. addSource/removeSource 方法
addSource(id, source) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'addSource',
id,
source,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
removeSource(id) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'remove',
id,
type: 'source',
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
setGeoJSONSourceForRequest(id, url, handler, filters) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'setGeoJSONSourceForRequest',
id,
url,
handler: handler.toString(),
filters,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
setGeoJSONSourceData(id, data, filter) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'setGeoJSONSourceData',
id,
data,
filter,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
setVectorTileSourceTiles(id, tiles) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'setVectorTileSourceTiles',
id,
tiles,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
updateRasterImage(id, url) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'updateRasterImage',
id,
url,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
// 3. addLayer/removeLayer 方法
addLayer(layer, beforeId, popup) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'addLayer',
layer,
beforeId,
popup,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
removeLayer(id) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'remove',
id,
type: 'layer',
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
// 4. setFilter 方法
setFilter(layerId, filter) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'setFilter',
layerId,
filter,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
// 5. setPaintProperty 方法
setPaintProperty(layerId, paintProperty, value) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'setPaintProperty',
layerId,
paintProperty,
value,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
// 6. setLayoutProperty 方法
setLayoutProperty(layerId, layoutProperty, value) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'setLayoutProperty',
layerId,
layoutProperty,
value,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
// 7. flyTo 方法
flyTo(options) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'flyTo',
...options,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
addMarker(id, lngLat, popup, popupDefaultOpen) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'addMarker',
id,
lngLat,
popup,
popupDefaultOpen,
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
removeMarker(id) {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'remove',
id,
type: 'marker',
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
removePopup() {
// 加入到 change 事件队列中
this.changeOptionsQueue.push({
fn: 'remove',
type: 'popup',
})
// 尝试触发 change 事件
this.tryTriggerChange()
},
// 获取当前位置
getCurrentPosition() {
getLocation().then((res) => {
// 更新定位
this.position = {
coords: res,
_: Date.now(),
}
})
},
},
}
</script>
<!-- renderjs 视图层模块 -->
<script module="mapbox" lang="renderjs" src="./mapbox.module.js"></script>
<template>
<!-- #ifdef APP-PLUS || H5 -->
<view
class="map wrap"
:id="id"
:options="options"
:change:options="mapbox.changeOptions"
:eventOptions="eventOptions"
:change:eventOptions="mapbox.changeEventOptions"
:addSourceOptions="addSourceOptions"
:change:addSourceOptions="mapbox.changeAddSourceOptions"
:setGeoJSONSourceForRequestOptions="setGeoJSONSourceForRequestOptions"
:change:setGeoJSONSourceForRequestOptions="mapbox.changeSetGeoJSONSourceForRequestOptions"
:setGeoJSONSourceDataOptions="setGeoJSONSourceDataOptions"
:change:setGeoJSONSourceDataOptions="mapbox.changeSetGeoJSONSourceDataOptions"
:setVectorTileSourceTilesOptions="setVectorTileSourceTilesOptions"
:change:setVectorTileSourceTilesOptions="mapbox.changeSetVectorTileSourceTilesOptions"
:updateRasterImageOptions="updateRasterImageOptions"
:change:updateRasterImageOptions="mapbox.changeUpdateRasterImageOptions"
:addLayerOptions="addLayerOptions"
:change:addLayerOptions="mapbox.changeAddLayerOptions"
:setFilterOptions="setFilterOptions"
:change:setFilterOptions="mapbox.changeSetFilterOptions"
:setPaintPropertyOptions="setPaintPropertyOptions"
:change:setPaintPropertyOptions="mapbox.changeSetPaintPropertyOptions"
:setLayoutPropertyOptions="setLayoutPropertyOptions"
:change:setLayoutPropertyOptions="mapbox.changeSetLayoutPropertyOptions"
:removeOptions="removeOptions"
:change:removeOptions="mapbox.changeRemoveOptions"
:flyToOptions="flyToOptions"
:change:flyToOptions="mapbox.changeFlyToOptions"
:regionOptions="regionOptions"
:change:regionOptions="mapbox.changeRegionOptions"
:addMarkerOptions="addMarkerOptions"
:change:addMarkerOptions="mapbox.changeAddMarkerOptions"
/>
<!-- #endif -->
<!-- #ifndef APP-PLUS || H5 -->
<view class="empty wrap">非 APP、H5 环境不支持</view>
<!-- #endif -->
</template>
<style lang="less" scoped>
.wrap {
display: flex;
width: 100%;
height: 100%;
/* #ifdef APP */
height: 100vh;
/* #endif */
}
.empty {
justify-content: center;
align-items: center;
}
</style>
import qs from 'qs'
import { merge, omit } from 'lodash-es'
import {
HandlerUtil,
buildTileJsonURL,
defaultStyle,
loadMapControl,
loadMapboxLibs,
sendRequest,
} from '/@/components/Map/Mapbox'
// renderjs 官方文档
// https://uniapp.dcloud.io/tutorial/renderjs.html
// renderjs 的一些细节问题
// https://juejin.cn/post/7049185827582115870
function replaceVariables(str, data) {
if (!HandlerUtil.isPlainObject(data)) {
return str
}
// 使用正则表达式匹配 {a.xxx} 格式的变量占位字符串
const regex = /\{inject\.(\w+)\}/g
// 使用 replace 方法进行替换
const result = str.replace(regex, (match, key) => {
// 检查变量是否存在于提供的数据对象中
if (data[key]) {
return data[key]
}
// 如果变量不存在,则返回原始占位字符串
return match
})
return result
}
async function request(url, handler) {
const { origin, pathname, search } = new URL(url)
const query = qs.parse(search, { ignoreQueryPrefix: true })
const params = qs.stringify({ ...query, inject: undefined }, { addQueryPrefix: true, encode: true })
const data = await sendRequest(`${origin}${pathname}${params}`)
const { body } = data
if (handler) {
// eslint-disable-next-line no-eval
const executableFunction = window.eval(`(${replaceVariables(handler, query?.inject)})`)
return { data: executableFunction(body, HandlerUtil), raw: body }
}
return { data: body, raw: body }
}
// 全局暴露 popup 事件处理方法
window.onPopupContentEventHandle = function (e) {
if (popupFeature && $vm) {
// 获取当前 popup 点位的图层和属性
const layer = popupFeature.layer.id
const properties = popupFeature.properties
delete properties.popup
// 调用逻辑层方法
$vm.$ownerInstance.callMethod('onEventHandle', {
type: 'custom',
name: e ? 'src' : 'timing',
data: {
type: 'click',
layer,
properties,
url: e,
},
})
}
}
const defaultEmptyGeoJSON = {
type: 'FeatureCollection',
features: [],
}
let $vm = null
let popup = null
let popupFeature = null
export default {
data() {
return {
config: {},
listeners: {},
markers: {},
}
},
mounted() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
$vm = this
},
methods: {
loadLibs: loadMapboxLibs,
initMap(options) {
// 移除地图
if (this.map && this.map.remove) {
this.map.remove()
}
// [107.570282, 19.474339],
// [115.629717, 34.466859],
// 加载地图
const mapboxgl = window.mapboxgl
const map = new mapboxgl.Map({
// accessToken,
container: options.container,
style: merge(defaultStyle, options?.style),
...merge(
{
minZoom: 5,
maxZoom: 18,
maxBounds: [
[106.94, 20.89],
[117.03, 34],
],
},
options?.options,
),
// 禁用默认的 attribution 控件
attributionControl: false,
// 禁用旋转
dragRotate: false,
touchRotate: false,
// 禁用倾斜
pitchWithRotate: false,
// 不同源的冲突碰撞检测
crossSourceCollisions: false,
// 启用本地字体
// localFontFamily: true,
// 默认使用 equirectangular(等经纬度) 投影
projection: options?.options?.projection || 'equirectangular',
})
// 绑定作用域
this.map = map
// 监听地图错误事件
map.on('error', (e) => {
console.error('Mapbox map error', e)
})
// 监听地图事件
map.once('styledata', function () {
// 禁用旋转
map.dragRotate.disable()
map.touchPitch.disable()
map.touchZoomRotate.disableRotation()
})
// 加载地图控件
map.on('load', () => {
// console.log(map.getCenter())
// #ifdef APP-PLUS
if (options?.extra?.control?.geolocate) {
window.navigator.permissions = undefined
options.extra.control.geolocate = {
...options.extra.control.geolocate,
geolocation: {
getCurrentPosition: (onPositionSuccess, _onPositionError, options) => {
this.onPositionSuccess = onPositionSuccess
this.$ownerInstance.callMethod('getCurrentPosition', options)
},
},
}
}
// #endif
// 加载地图图层
this.initMapLayers(this.config)
.then(() => {
// 添加地图数据来源描述
if (options.attribution) {
map.addControl(
new mapboxgl.AttributionControl({
compact: true,
customAttribution: options.attribution?.text,
}),
options.attribution?.align || 'bottom-right',
)
}
// 加载地图控件
loadMapControl(mapboxgl, map, options?.extra?.control)
// 调用逻辑层方法
this.loaded = true
console.debug('Mapbox map loaded', map)
this.$ownerInstance.callMethod('onMapLoad', {
center: map.getCenter(),
})
})
.catch(console.error)
})
},
initMapLayers({ region, regionConfig, autoFlyToRegionCenter, options }) {
// TODO
// 1. 检查遮罩数据源是否存在,不存在则添加,存在则先移除再添加
// 2. 检查遮罩图层是否存在,不存在则添加,存在则先移除再添加
// 3. 对一些数据图层进行过滤,只显示当前区域的数据
// 4. 重置地图中心点和缩放级别
if (region && regionConfig) {
const { properties = {}, cityCode } = regionConfig
const id = properties?.code
if (!id) {
return Promise.resolve()
}
// 检查遮罩图层是否存在,存在则先移除
if (this.map.getLayer('line')) {
this.map.removeLayer(`line`)
this.map.removeLayer(`mask`)
this.map.removeLayer(`shadow`)
}
if (this.map.getSource(id)) {
this.map.removeSource(id)
this.map.removeSource(`${id}.mask`)
}
// 添加边界数据源
this.map.addSource(id, {
type: 'vector',
url: buildTileJsonURL(`${id}.v2024`),
})
// 添加遮罩数据源
if (!options?.noMusk) {
this.map.addSource(`${id}.mask`, {
type: 'vector',
url: buildTileJsonURL(`${id}.v2024.mask`),
})
// 添加遮罩图层
this.map.addLayer(
{
id: `mask`,
type: 'fill',
source: `${id}.mask`,
'source-layer': `${id}.v2024.mask`,
paint: {
'fill-color': '#F4F5F7',
},
},
'mask-placeholder',
)
}
// 添加阴影图层
this.map.addLayer(
{
id: `shadow`,
type: 'line',
source: id,
'source-layer': `${id}.v2024`,
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-width': 4,
'line-blur': 92.7,
'line-offset': -4,
'line-color': '#444',
},
},
'mask-placeholder',
)
// 添加边界图层
this.map.addLayer(
{
id: `line`,
source: id,
'source-layer': `${id}.v2024`,
type: 'line',
paint: {
'line-width': 2,
'line-color': '#444',
},
},
'mask-placeholder',
)
// 解析区域编码
const splits = region.match(/.{1,2}/g)
if (splits && splits.length > 0) {
// 解构省市区编码
const [_, city, area] = splits
// 视角飞行到最佳位置
if (autoFlyToRegionCenter !== false) {
this.map.flyTo({
center: [properties.lat, properties.lon],
zoom: properties.zoom || (area ? 7.8 : city ? 6.5 : 5.5),
essential: true,
// speed: 0.2,
duration: 0,
})
}
// 重置图层过滤
this.map.setFilter(`430000.city-label`, null)
this.map.setFilter(`430000.area-label`, null)
this.map.setFilter(`430000.town-label`, null)
// 图层数据过滤
if (area) {
this.map.setLayoutProperty('430000.city-label', 'visibility', 'none')
this.map.setLayoutProperty('430000.area-label', 'visibility', 'visible')
this.map.setFilter(`430000.area-label`, [
'all',
['==', ['get', 'city'], city],
['==', ['get', 'area'], area],
])
this.map.setFilter(`430000.town-label`, [
'all',
['==', ['get', 'city'], city],
['==', ['get', 'area'], area],
])
} else if (city) {
this.map.setLayoutProperty('430000.city-label', 'visibility', 'visible')
this.map.setLayoutProperty('430000.area-label', 'visibility', 'visible')
this.map.setFilter(`430000.city-label`, ['==', ['get', 'city'], city])
this.map.setFilter(`430000.area-label`, ['==', ['get', 'city'], city])
this.map.setFilter(`430000.town-label`, ['==', ['get', 'city'], city])
} else {
this.map.setLayoutProperty('430000.city-label', 'visibility', 'visible')
this.map.setLayoutProperty('430000.area-label', 'visibility', 'visible')
}
if (cityCode) {
this.map.setFilter(`430000.city-line-select`, ['all', ['==', ['get', 'code'], `${cityCode}00`]])
this.map.setLayoutProperty('430000.city-line-select', 'visibility', 'visible')
}
setTimeout(() => {
this.map.setLayoutProperty('430000.city-line', 'visibility', 'visible')
this.map.setLayoutProperty('430000.area-line', 'visibility', 'visible')
this.map.setLayoutProperty('430000.town-line', 'visibility', 'visible')
}, 500)
}
}
return Promise.resolve()
},
// 检测触发变更时候的有效性
checkOnChangeValidity(options) {
if (options && Object.keys(options).length > 0 && this.map) {
// 移除弹窗
this.clearPopup()
options.fn && this.emitChangeEvent(options.fn)
return true
}
return false
},
emitChangeEvent(type) {
// 通知逻辑层,本次变更事件已经送达
this.$ownerInstance.callMethod('onMapOptionsChangeEvent', { type })
},
changeOptions(options) {
if (!options.container) {
return
}
this.config = options
if (typeof window.mapboxgl === 'object') {
this.initMap(options)
} else {
this.loadLibs().then(() => {
this.initMap(options)
})
}
},
changeRegionOptions(options) {
if (this.checkOnChangeValidity(options)) {
this.initMapLayers(options).then(() => {
this.config.region = options.region
this.config.regionConfig = options.regionConfig
this.config.autoFlyToRegionCenter = options.flyTo
})
}
},
changeFlyToOptions(options) {
if (this.checkOnChangeValidity(options)) {
this.map.flyTo(options)
}
},
changeAddSourceOptions(options) {
if (this.checkOnChangeValidity(options)) {
if (this.map.getSource(options.id)) {
this.map.getSource(options.id).setData(options.source.data)
} else {
this.map.addSource(options.id, options.source)
}
}
},
changeSetGeoJSONSourceForRequestOptions(options) {
if (this.checkOnChangeValidity(options)) {
if (this.map.getSource(options.id)) {
// 移除弹窗
this.clearPopup()
// 清空数据
this.map.getSource(options.id).setData(defaultEmptyGeoJSON)
} else {
// 添加空的 source
this.map.addSource(options.id, {
type: 'geojson',
data: defaultEmptyGeoJSON,
})
}
// 请求数据
request(options.url, options.handler)
.then(({ data, raw }) => {
this.map.getSource(options.id).setData(data)
this.$ownerInstance.callMethod('onSourceRequestHandle', {
id: options.id,
url: options.url,
data: omit(raw, options.filters || []),
})
})
.catch((e) => {
console.error(e)
this.$ownerInstance.callMethod('onSourceRequestErrorHandle', e.message)
})
}
},
changeSetGeoJSONSourceDataOptions(options) {
if (this.checkOnChangeValidity(options)) {
this.map.getSource(options.id).setData(options.data)
}
},
changeSetVectorTileSourceTilesOptions(options) {
if (this.checkOnChangeValidity(options)) {
this.map.getSource(options.id).setTiles(options.tiles)
}
},
changeUpdateRasterImageOptions(options) {
if (this.checkOnChangeValidity(options)) {
this.map.getSource(options.id).updateImage({
url: options.url,
})
}
},
changeAddLayerOptions(options) {
if (this.checkOnChangeValidity(options)) {
if (this.map.getLayer(options.layer.id)) {
this.map.removeLayer(options.layer.id)
}
this.map.addLayer(options.layer, options.beforeId)
if (options.popup && ['symbol', 'fill', 'line'].includes(options.layer.type)) {
this.map.on('click', options.layer.id, (e) => {
const feature = e.features[0]
if (feature && feature.properties?.popup) {
popupFeature = feature
popup = new window.mapboxgl.Popup()
.setLngLat(options.layer.type === 'symbol' ? feature.geometry.coordinates : e.lngLat)
.setHTML(
feature.properties.popup
.replace('{key}', feature.properties.key)
.replace('{value}', feature.properties.value)
.replace('{suffix}', feature.properties.suffix || '')
.replace(
'{{timing}}',
`<button onclick="onPopupContentEventHandle()"><i class="icon"></i>查看时序</button>`,
)
.replace(
'{{download}}',
`<button onclick="onPopupContentEventHandle()"><i class="icon"></i>下载视频</button>`,
)
.replaceAll(/\{\{(\w+)\}}/g, (_, i) => feature.properties[i]),
)
.addTo(this.map)
}
})
}
}
},
changeRemoveOptions(options) {
if (this.checkOnChangeValidity(options)) {
if (options.type === 'layer') {
if (this.map.getLayer(options.id)) {
this.map.removeLayer(options.id)
}
} else if (options.type === 'source') {
if (this.map.getSource(options.id)) {
this.map.removeSource(options.id)
}
} else if (options.type === 'marker') {
this.markers[options.id]?.remove()
} else if (options.type === 'popup') {
this.clearPopup()
}
}
},
changeSetFilterOptions(options) {
if (this.checkOnChangeValidity(options)) {
this.map.setFilter(options.layerId, options.filter)
}
},
changeSetLayoutPropertyOptions(options) {
if (this.checkOnChangeValidity(options)) {
this.map.setLayoutProperty(options.layerId, options.layoutProperty, options.value)
}
},
changeSetPaintPropertyOptions(options) {
if (this.checkOnChangeValidity(options)) {
this.map.setPaintProperty(options.layerId, options.paintProperty, options.value)
}
},
changeEventOptions(options) {
if (this.checkOnChangeValidity(options)) {
const key = `on_${options.type}_${options.layer}`
if (options.event === 'on') {
this.listeners[key] = (e) => {
console.debug('🔔 Map event on', options.type, e)
this.$ownerInstance.callMethod('onEventHandle', {
type: options.type,
layer: options.layer,
event: {
lngLat: e.lngLat,
features: e.features,
},
})
}
if (!options.layer || options.layer === 'all') {
this.map.on(options.type, this.listeners[key])
} else {
this.map.on(options.type, options.layer, this.listeners[key])
}
} else if (options.event === 'off') {
console.debug('🔦 Map event off', options.type)
this.map.off(options.type, this.listeners[key])
}
}
},
changeAddMarkerOptions(options) {
if (this.checkOnChangeValidity(options)) {
const { id, lngLat, popup, popupDefaultOpen, ...rest } = options
if (this.markers[id]) {
this.markers[id].remove()
}
const marker = new window.mapboxgl.Marker({ scale: 0.8, ...rest }).setLngLat(lngLat).addTo(this.map)
if (popup) {
const popupComponent = new window.mapboxgl.Popup({ options: { closeButton: true } }).setHTML(popup)
marker.setPopup(popupComponent)
if (popupDefaultOpen) {
marker.togglePopup()
}
}
this.markers[id] = marker
}
},
changePosition(position) {
if (!this.loaded) {
return
}
this.onPositionSuccess?.(position)
},
clearPopup() {
// 移除弹窗
if (popup) {
popup.remove()
popup = null
popupFeature = null
}
},
},
}
export function getRegionConfig(region: string): GeoJSON.Feature<GeoJSON.Point> {
return HUNAN_REGIONS[String(region || '43').padEnd(6, '0')]
}
export const HUNAN_REGIONS = {
'430000': {
id: 430000,
type: 'Feature',
properties: { name: '湖南省', province: '43', code: '430000', lat: 111.6, lon: 26.77 },
geometry: { type: 'Point', coordinates: [112.947314, 28.18616] },
},
'430100': {
type: 'Feature',
properties: {
code: '430100',
name: '长沙市',
province: '43',
city: '01',
lat: 113.063841,
lon: 27.733286,
},
geometry: { type: 'Point', coordinates: [112.94731408395079, 28.186160323597434] },
id: 430100,
},
'430102': {
type: 'Feature',
properties: {
code: '430102',
name: '芙蓉区',
province: '43',
city: '01',
area: '02',
lat: 113.037078,
lon: 28.173557,
},
geometry: { type: 'Point', coordinates: [113.05437645657373, 28.20653329485] },
id: 430102,
},
'430103': {
type: 'Feature',
properties: {
code: '430103',
name: '天心区',
province: '43',
city: '01',
area: '03',
lat: 112.980946,
lon: 28.032686,
zoom: 10.2,
},
geometry: { type: 'Point', coordinates: [113.001393866701, 28.05535994531411] },
id: 430103,
},
'430104': {
type: 'Feature',
properties: {
code: '430104',
name: '岳麓区',
province: '43',
city: '01',
area: '04',
lat: 112.794972,
lon: 28.050464,
zoom: 9.13,
},
geometry: { type: 'Point', coordinates: [112.82078004151099, 28.127273962284654] },
id: 430104,
},
'430105': {
type: 'Feature',
properties: {
code: '430105',
name: '开福区',
province: '43',
city: '01',
area: '05',
lat: 112.99086,
lon: 28.261982,
zoom: 10.2,
},
geometry: { type: 'Point', coordinates: [113.01352150727135, 28.321854790235914] },
id: 430105,
},
'430111': {
type: 'Feature',
properties: {
code: '430111',
name: '雨花区',
province: '43',
city: '01',
area: '11',
lat: 113.077807,
lon: 27.993345,
zoom: 9.7,
},
geometry: { type: 'Point', coordinates: [113.08509584496585, 28.02659000045035] },
id: 430111,
},
'430112': {
type: 'Feature',
properties: {
code: '430112',
name: '望城区',
province: '43',
city: '01',
area: '12',
lat: 112.825566,
lon: 28.262214,
zoom: 9.13,
},
geometry: { type: 'Point', coordinates: [112.81639480061067, 28.352447870633075] },
id: 430112,
},
'430121': {
type: 'Feature',
properties: {
code: '430121',
name: '长沙县',
province: '43',
city: '01',
area: '21',
lat: 113.200784,
lon: 28.241158,
zoom: 8.7,
},
geometry: { type: 'Point', coordinates: [113.24173828096262, 28.36048710860851] },
id: 430121,
},
'430181': {
type: 'Feature',
properties: {
code: '430181',
name: '浏阳市',
province: '43',
city: '01',
area: '81',
lat: 113.714812,
lon: 27.999539,
zoom: 7.8,
},
geometry: { type: 'Point', coordinates: [113.5459169226901, 28.191562533925126] },
id: 430181,
},
'430182': {
type: 'Feature',
properties: {
code: '430182',
name: '宁乡市',
province: '43',
city: '01',
area: '82',
lat: 112.3414,
lon: 27.973983,
zoom: 8.0,
},
geometry: { type: 'Point', coordinates: [112.40649207520309, 28.140232914573463] },
id: 430182,
},
'430200': {
type: 'Feature',
properties: {
code: '430200',
name: '株洲市',
province: '43',
city: '02',
lat: 113.497479,
lon: 26.68148,
},
geometry: { type: 'Point', coordinates: [113.45863700259983, 27.17324281159717] },
id: 430200,
},
'430202': {
type: 'Feature',
properties: {
code: '430202',
name: '荷塘区',
province: '43',
city: '02',
area: '02',
lat: 113.23018156274084,
lon: 27.925302464047284,
},
geometry: { type: 'Point', coordinates: [113.23018156274084, 27.925302464047284] },
id: 430202,
},
'430203': {
type: 'Feature',
properties: {
code: '430203',
name: '芦淞区',
province: '43',
city: '02',
area: '03',
lat: 113.24698009554513,
lon: 27.79908514489288,
},
geometry: { type: 'Point', coordinates: [113.24698009554513, 27.79908514489288] },
id: 430203,
},
'430204': {
type: 'Feature',
properties: {
code: '430204',
name: '石峰区',
province: '43',
city: '02',
area: '04',
lat: 113.15515241404151,
lon: 27.95431125444443,
},
geometry: { type: 'Point', coordinates: [113.15515241404151, 27.95431125444443] },
id: 430204,
},
'430211': {
type: 'Feature',
properties: {
code: '430211',
name: '天元区',
province: '43',
city: '02',
area: '11',
lat: 113.05348277072393,
lon: 27.656610002756196,
},
geometry: { type: 'Point', coordinates: [113.05348277072393, 27.656610002756196] },
id: 430211,
},
'430212': {
type: 'Feature',
properties: {
code: '430212',
name: '渌口区',
province: '43',
city: '02',
area: '12',
lat: 113.14148219276399,
lon: 27.53589000981881,
},
geometry: { type: 'Point', coordinates: [113.14148219276399, 27.53589000981881] },
id: 430212,
},
'430223': {
type: 'Feature',
properties: {
code: '430223',
name: '攸县',
province: '43',
city: '02',
area: '23',
lat: 113.41776287174375,
lon: 27.12484017663232,
},
geometry: { type: 'Point', coordinates: [113.41776287174375, 27.12484017663232] },
id: 430223,
},
'430224': {
type: 'Feature',
properties: {
code: '430224',
name: '茶陵县',
province: '43',
city: '02',
area: '24',
lat: 113.5853739913709,
lon: 26.775446031888727,
},
geometry: { type: 'Point', coordinates: [113.5853739913709, 26.775446031888727] },
id: 430224,
},
'430225': {
type: 'Feature',
properties: {
code: '430225',
name: '炎陵县',
province: '43',
city: '02',
area: '25',
lat: 113.81390906426675,
lon: 26.37128675809254,
},
geometry: { type: 'Point', coordinates: [113.81390906426675, 26.37128675809254] },
id: 430225,
},
'430281': {
type: 'Feature',
properties: {
code: '430281',
name: '醴陵市',
province: '43',
city: '02',
area: '81',
lat: 113.45580938383763,
lon: 27.631846337532107,
},
geometry: { type: 'Point', coordinates: [113.45580938383763, 27.631846337532107] },
id: 430281,
},
'430300': {
type: 'Feature',
properties: {
code: '430300',
name: '湘潭市',
province: '43',
city: '03',
lat: 112.544741,
lon: 27.522009,
zoom: 7.5,
},
geometry: { type: 'Point', coordinates: [112.56078254464227, 27.783882570619102] },
id: 430300,
},
'430302': {
type: 'Feature',
properties: {
code: '430302',
name: '雨湖区',
province: '43',
city: '03',
area: '02',
lat: 112.83482964810315,
lon: 27.976488551057646,
},
geometry: { type: 'Point', coordinates: [112.83482964810315, 27.976488551057646] },
id: 430302,
},
'430304': {
type: 'Feature',
properties: {
code: '430304',
name: '岳塘区',
province: '43',
city: '03',
area: '04',
lat: 113.02604947538589,
lon: 27.932358033588123,
},
geometry: { type: 'Point', coordinates: [113.02604947538589, 27.932358033588123] },
id: 430304,
},
'430321': {
type: 'Feature',
properties: {
code: '430321',
name: '湘潭县',
province: '43',
city: '03',
area: '21',
lat: 112.71284604417524,
lon: 27.634472286773402,
},
geometry: { type: 'Point', coordinates: [112.71284604417524, 27.634472286773402] },
id: 430321,
},
'430381': {
type: 'Feature',
properties: {
code: '430381',
name: '湘乡市',
province: '43',
city: '03',
area: '81',
lat: 112.35026227262158,
lon: 27.79863766702213,
},
geometry: { type: 'Point', coordinates: [112.35026227262158, 27.79863766702213] },
id: 430381,
},
'430382': {
type: 'Feature',
properties: {
code: '430382',
name: '韶山市',
province: '43',
city: '03',
area: '82',
lat: 112.50567036797644,
lon: 27.910599242437524,
},
geometry: { type: 'Point', coordinates: [112.50567036797644, 27.910599242437524] },
id: 430382,
},
'430400': {
type: 'Feature',
properties: {
code: '430400',
name: '衡阳市',
province: '43',
city: '04',
lat: 112.420741,
lon: 26.404885,
zoom: 6.9,
},
geometry: { type: 'Point', coordinates: [112.47610888762907, 26.7375778746171] },
id: 430400,
},
'430405': {
type: 'Feature',
properties: {
code: '430405',
name: '珠晖区',
province: '43',
city: '04',
area: '05',
lat: 112.68696610206781,
lon: 26.87391584344994,
},
geometry: { type: 'Point', coordinates: [112.68696610206781, 26.87391584344994] },
id: 430405,
},
'430406': {
type: 'Feature',
properties: {
code: '430406',
name: '雁峰区',
province: '43',
city: '04',
area: '06',
lat: 112.5752243405455,
lon: 26.827935559661043,
},
geometry: { type: 'Point', coordinates: [112.5752243405455, 26.827935559661043] },
id: 430406,
},
'430407': {
type: 'Feature',
properties: {
code: '430407',
name: '石鼓区',
province: '43',
city: '04',
area: '07',
lat: 112.57497402340871,
lon: 26.9836871143345,
},
geometry: { type: 'Point', coordinates: [112.57497402340871, 26.9836871143345] },
id: 430407,
},
'430408': {
type: 'Feature',
properties: {
code: '430408',
name: '蒸湘区',
province: '43',
city: '04',
area: '08',
lat: 112.52837378535621,
lon: 26.87900098454944,
},
geometry: { type: 'Point', coordinates: [112.52837378535621, 26.87900098454944] },
id: 430408,
},
'430412': {
type: 'Feature',
properties: {
code: '430412',
name: '南岳区',
province: '43',
city: '04',
area: '12',
lat: 112.69158084046752,
lon: 27.256543172779402,
},
geometry: { type: 'Point', coordinates: [112.69158084046752, 27.256543172779402] },
id: 430412,
},
'430421': {
type: 'Feature',
properties: {
code: '430421',
name: '衡阳县',
province: '43',
city: '04',
area: '21',
lat: 112.35186976414064,
lon: 27.05687870339835,
},
geometry: { type: 'Point', coordinates: [112.35186976414064, 27.05687870339835] },
id: 430421,
},
'430422': {
type: 'Feature',
properties: {
code: '430422',
name: '衡南县',
province: '43',
city: '04',
area: '22',
lat: 112.61105260143303,
lon: 26.77491260483389,
},
geometry: { type: 'Point', coordinates: [112.61105260143303, 26.77491260483389] },
id: 430422,
},
'430423': {
type: 'Feature',
properties: {
code: '430423',
name: '衡山县',
province: '43',
city: '04',
area: '23',
lat: 112.67825470803591,
lon: 27.266136674254117,
},
geometry: { type: 'Point', coordinates: [112.67825470803591, 27.266136674254117] },
id: 430423,
},
'430424': {
type: 'Feature',
properties: {
code: '430424',
name: '衡东县',
province: '43',
city: '04',
area: '24',
lat: 113.06032278203054,
lon: 27.010648299875783,
},
geometry: { type: 'Point', coordinates: [113.06032278203054, 27.010648299875783] },
id: 430424,
},
'430426': {
type: 'Feature',
properties: {
code: '430426',
name: '祁东县',
province: '43',
city: '04',
area: '26',
lat: 111.97169918717914,
lon: 26.78031135277974,
},
geometry: { type: 'Point', coordinates: [111.97169918717914, 26.78031135277974] },
id: 430426,
},
'430481': {
type: 'Feature',
properties: {
code: '430481',
name: '耒阳市',
province: '43',
city: '04',
area: '81',
lat: 112.93351733304152,
lon: 26.426348798470926,
},
geometry: { type: 'Point', coordinates: [112.93351733304152, 26.426348798470926] },
id: 430481,
},
'430482': {
type: 'Feature',
properties: {
code: '430482',
name: '常宁市',
province: '43',
city: '04',
area: '82',
lat: 112.38259954265503,
lon: 26.306887486948213,
},
geometry: { type: 'Point', coordinates: [112.38259954265503, 26.306887486948213] },
id: 430482,
},
'430500': {
type: 'Feature',
properties: {
code: '430500',
name: '邵阳市',
province: '43',
city: '05',
lat: 110.93368,
lon: 26.292349,
},
geometry: { type: 'Point', coordinates: [111.0657451002648, 26.993649529252057] },
id: 430500,
},
'430502': {
type: 'Feature',
properties: {
code: '430502',
name: '双清区',
province: '43',
city: '05',
area: '02',
lat: 111.55530340500314,
lon: 27.263082354823595,
},
geometry: { type: 'Point', coordinates: [111.55530340500314, 27.263082354823595] },
id: 430502,
},
'430503': {
type: 'Feature',
properties: {
code: '430503',
name: '大祥区',
province: '43',
city: '05',
area: '03',
lat: 111.49326956803311,
lon: 27.142929775243413,
},
geometry: { type: 'Point', coordinates: [111.49326956803311, 27.142929775243413] },
id: 430503,
},
'430511': {
type: 'Feature',
properties: {
code: '430511',
name: '北塔区',
province: '43',
city: '05',
area: '11',
lat: 111.40437052038251,
lon: 27.25561599748525,
},
geometry: { type: 'Point', coordinates: [111.40437052038251, 27.25561599748525] },
id: 430511,
},
'430522': {
type: 'Feature',
properties: {
code: '430522',
name: '新邵县',
province: '43',
city: '05',
area: '22',
lat: 111.49148850060558,
lon: 27.414895145108115,
},
geometry: { type: 'Point', coordinates: [111.49148850060558, 27.414895145108115] },
id: 430522,
},
'430523': {
type: 'Feature',
properties: {
code: '430523',
name: '邵阳县',
province: '43',
city: '05',
area: '23',
lat: 111.33058189944263,
lon: 27.00724427161972,
},
geometry: { type: 'Point', coordinates: [111.33058189944263, 27.00724427161972] },
id: 430523,
},
'430524': {
type: 'Feature',
properties: {
code: '430524',
name: '隆回县',
province: '43',
city: '05',
area: '24',
lat: 110.94057733444437,
lon: 27.32853240223401,
},
geometry: { type: 'Point', coordinates: [110.94057733444437, 27.32853240223401] },
id: 430524,
},
'430525': {
type: 'Feature',
properties: {
code: '430525',
name: '洞口县',
province: '43',
city: '05',
area: '25',
lat: 110.64148920827904,
lon: 27.072170176893916,
},
geometry: { type: 'Point', coordinates: [110.64148920827904, 27.072170176893916] },
id: 430525,
},
'430527': {
type: 'Feature',
properties: {
code: '430527',
name: '绥宁县',
province: '43',
city: '05',
area: '27',
lat: 110.18151929130823,
lon: 26.726961809835785,
},
geometry: { type: 'Point', coordinates: [110.18151929130823, 26.726961809835785] },
id: 430527,
},
'430528': {
type: 'Feature',
properties: {
code: '430528',
name: '新宁县',
province: '43',
city: '05',
area: '28',
lat: 110.94829152313683,
lon: 26.572558228033692,
},
geometry: { type: 'Point', coordinates: [110.94829152313683, 26.572558228033692] },
id: 430528,
},
'430529': {
type: 'Feature',
properties: {
code: '430529',
name: '城步苗族自治县',
province: '43',
city: '05',
area: '29',
lat: 110.35420494792562,
lon: 26.40467468131861,
},
geometry: { type: 'Point', coordinates: [110.35420494792562, 26.40467468131861] },
id: 430529,
},
'430581': {
type: 'Feature',
properties: {
code: '430581',
name: '武冈市',
province: '43',
city: '05',
area: '81',
lat: 110.76263623048962,
lon: 26.82798674191223,
},
geometry: { type: 'Point', coordinates: [110.76263623048962, 26.82798674191223] },
id: 430581,
},
'430582': {
type: 'Feature',
properties: {
code: '430582',
name: '邵东市',
province: '43',
city: '05',
area: '82',
lat: 111.81084801729804,
lon: 27.200427377116863,
},
geometry: { type: 'Point', coordinates: [111.81084801729804, 27.200427377116863] },
id: 430582,
},
'430600': {
type: 'Feature',
properties: {
code: '430600',
name: '岳阳市',
province: '43',
city: '06',
lat: 113.233427,
lon: 28.701148,
zoom: 6.8,
},
geometry: { type: 'Point', coordinates: [113.33482979091914, 28.930510638979946] },
id: 430600,
},
'430602': {
type: 'Feature',
properties: {
code: '430602',
name: '岳阳楼区',
province: '43',
city: '06',
area: '02',
lat: 113.2273788800502,
lon: 29.321471399927535,
},
geometry: { type: 'Point', coordinates: [113.2273788800502, 29.321471399927535] },
id: 430602,
},
'430603': {
type: 'Feature',
properties: {
code: '430603',
name: '云溪区',
province: '43',
city: '06',
area: '03',
lat: 113.34170292255776,
lon: 29.50552302886432,
},
geometry: { type: 'Point', coordinates: [113.34170292255776, 29.50552302886432] },
id: 430603,
},
'430611': {
type: 'Feature',
properties: {
code: '430611',
name: '君山区',
province: '43',
city: '06',
area: '11',
lat: 112.8509773815636,
lon: 29.472513376783258,
},
geometry: { type: 'Point', coordinates: [112.8509773815636, 29.472513376783258] },
id: 430611,
},
'430621': {
type: 'Feature',
properties: {
code: '430621',
name: '岳阳县',
province: '43',
city: '06',
area: '21',
lat: 113.29367495155445,
lon: 29.151911801431325,
},
geometry: { type: 'Point', coordinates: [113.29367495155445, 29.151911801431325] },
id: 430621,
},
'430623': {
type: 'Feature',
properties: {
code: '430623',
name: '华容县',
province: '43',
city: '06',
area: '23',
lat: 112.67454884643132,
lon: 29.516436023696293,
},
geometry: { type: 'Point', coordinates: [112.67454884643132, 29.516436023696293] },
id: 430623,
},
'430624': {
type: 'Feature',
properties: {
code: '430624',
name: '湘阴县',
province: '43',
city: '06',
area: '24',
lat: 112.86927918923033,
lon: 28.664172870059232,
},
geometry: { type: 'Point', coordinates: [112.86927918923033, 28.664172870059232] },
id: 430624,
},
'430626': {
type: 'Feature',
properties: {
code: '430626',
name: '平江县',
province: '43',
city: '06',
area: '26',
lat: 113.63907369373845,
lon: 28.782792570952022,
},
geometry: { type: 'Point', coordinates: [113.63907369373845, 28.782792570952022] },
id: 430626,
},
'430681': {
type: 'Feature',
properties: {
code: '430681',
name: '汨罗市',
province: '43',
city: '06',
area: '81',
lat: 113.1437868146439,
lon: 28.777241116633878,
},
geometry: { type: 'Point', coordinates: [113.1437868146439, 28.777241116633878] },
id: 430681,
},
'430682': {
type: 'Feature',
properties: {
code: '430682',
name: '临湘市',
province: '43',
city: '06',
area: '82',
lat: 113.44829550308913,
lon: 29.44118515533238,
},
geometry: { type: 'Point', coordinates: [113.44829550308913, 29.44118515533238] },
id: 430682,
},
'430700': {
type: 'Feature',
properties: {
code: '430700',
name: '常德市',
province: '43',
city: '07',
lat: 111.430724,
lon: 28.733161,
},
geometry: { type: 'Point', coordinates: [111.40741912540925, 29.236025739338878] },
id: 430700,
},
'430702': {
type: 'Feature',
properties: {
code: '430702',
name: '武陵区',
province: '43',
city: '07',
area: '02',
lat: 111.71260475773451,
lon: 29.064937752547728,
},
geometry: { type: 'Point', coordinates: [111.71260475773451, 29.064937752547728] },
id: 430702,
},
'430703': {
type: 'Feature',
properties: {
code: '430703',
name: '鼎城区',
province: '43',
city: '07',
area: '03',
lat: 111.6958329812305,
lon: 28.99611247762044,
},
geometry: { type: 'Point', coordinates: [111.6958329812305, 28.99611247762044] },
id: 430703,
},
'430721': {
type: 'Feature',
properties: {
code: '430721',
name: '安乡县',
province: '43',
city: '07',
area: '21',
lat: 112.13450230282321,
lon: 29.46732283208645,
},
geometry: { type: 'Point', coordinates: [112.13450230282321, 29.46732283208645] },
id: 430721,
},
'430722': {
type: 'Feature',
properties: {
code: '430722',
name: '汉寿县',
province: '43',
city: '07',
area: '22',
lat: 111.9790776242697,
lon: 28.775909032519653,
},
geometry: { type: 'Point', coordinates: [111.9790776242697, 28.775909032519653] },
id: 430722,
},
'430723': {
type: 'Feature',
properties: {
code: '430723',
name: '澧县',
province: '43',
city: '07',
area: '23',
lat: 111.6554838097881,
lon: 29.741385737517206,
},
geometry: { type: 'Point', coordinates: [111.6554838097881, 29.741385737517206] },
id: 430723,
},
'430724': {
type: 'Feature',
properties: {
code: '430724',
name: '临澧县',
province: '43',
city: '07',
area: '24',
lat: 111.62601912674303,
lon: 29.475168006320974,
},
geometry: { type: 'Point', coordinates: [111.62601912674303, 29.475168006320974] },
id: 430724,
},
'430725': {
type: 'Feature',
properties: {
code: '430725',
name: '桃源县',
province: '43',
city: '07',
area: '25',
lat: 111.27185567498358,
lon: 28.908872776554933,
},
geometry: { type: 'Point', coordinates: [111.27185567498358, 28.908872776554933] },
id: 430725,
},
'430726': {
type: 'Feature',
properties: {
code: '430726',
name: '石门县',
province: '43',
city: '07',
area: '26',
lat: 111.16677132796482,
lon: 29.705480660977415,
},
geometry: { type: 'Point', coordinates: [111.16677132796482, 29.705480660977415] },
id: 430726,
},
'430781': {
type: 'Feature',
properties: {
code: '430781',
name: '津市市',
province: '43',
city: '07',
area: '81',
lat: 111.85517969780484,
lon: 29.460203935328597,
},
geometry: { type: 'Point', coordinates: [111.85517969780484, 29.460203935328597] },
id: 430781,
},
'430800': {
type: 'Feature',
properties: {
code: '430800',
name: '张家界市',
province: '43',
city: '08',
lat: 110.519328,
lon: 29.005154,
zoom: 6.9,
},
geometry: { type: 'Point', coordinates: [110.42905377199926, 29.405159070851166] },
id: 430800,
},
'430802': {
type: 'Feature',
properties: {
code: '430802',
name: '永定区',
province: '43',
city: '08',
area: '02',
lat: 110.5133655479532,
lon: 29.100252934170626,
},
geometry: { type: 'Point', coordinates: [110.5133655479532, 29.100252934170626] },
id: 430802,
},
'430811': {
type: 'Feature',
properties: {
code: '430811',
name: '武陵源区',
province: '43',
city: '08',
area: '11',
lat: 110.48367879156638,
lon: 29.331114195473724,
},
geometry: { type: 'Point', coordinates: [110.48367879156638, 29.331114195473724] },
id: 430811,
},
'430821': {
type: 'Feature',
properties: {
code: '430821',
name: '慈利县',
province: '43',
city: '08',
area: '21',
lat: 110.97888525412722,
lon: 29.31267701923531,
},
geometry: { type: 'Point', coordinates: [110.97888525412722, 29.31267701923531] },
id: 430821,
},
'430822': {
type: 'Feature',
properties: {
code: '430822',
name: '桑植县',
province: '43',
city: '08',
area: '22',
lat: 110.10455348730405,
lon: 29.6081855797352,
},
geometry: { type: 'Point', coordinates: [110.10455348730405, 29.6081855797352] },
id: 430822,
},
'430900': {
type: 'Feature',
properties: {
code: '430900',
name: '益阳市',
province: '43',
city: '09',
lat: 111.80421,
lon: 28.08103,
},
geometry: { type: 'Point', coordinates: [111.75759950292462, 28.389017179871054] },
id: 430900,
},
'430902': {
type: 'Feature',
properties: {
code: '430902',
name: '资阳区',
province: '43',
city: '09',
area: '02',
lat: 112.26774837804294,
lon: 28.672304695727536,
},
geometry: { type: 'Point', coordinates: [112.26774837804294, 28.672304695727536] },
id: 430902,
},
'430903': {
type: 'Feature',
properties: {
code: '430903',
name: '赫山区',
province: '43',
city: '09',
area: '03',
lat: 112.42827979393694,
lon: 28.413667599414474,
},
geometry: { type: 'Point', coordinates: [112.42827979393694, 28.413667599414474] },
id: 430903,
},
'430921': {
type: 'Feature',
properties: {
code: '430921',
name: '南县',
province: '43',
city: '09',
area: '21',
lat: 112.44130331631172,
lon: 29.251784265538014,
},
geometry: { type: 'Point', coordinates: [112.44130331631172, 29.251784265538014] },
id: 430921,
},
'430922': {
type: 'Feature',
properties: {
code: '430922',
name: '桃江县',
province: '43',
city: '09',
area: '22',
lat: 111.9788721714533,
lon: 28.467955009053846,
},
geometry: { type: 'Point', coordinates: [111.9788721714533, 28.467955009053846] },
id: 430922,
},
'430923': {
type: 'Feature',
properties: {
code: '430923',
name: '安化县',
province: '43',
city: '09',
area: '23',
lat: 111.40082428138818,
lon: 28.233232888403453,
},
geometry: { type: 'Point', coordinates: [111.40082428138818, 28.233232888403453] },
id: 430923,
},
'430981': {
type: 'Feature',
properties: {
code: '430981',
name: '沅江市',
province: '43',
city: '09',
area: '81',
lat: 112.50089581209599,
lon: 28.916582582117094,
},
geometry: { type: 'Point', coordinates: [112.50089581209599, 28.916582582117094] },
id: 430981,
},
'431000': {
type: 'Feature',
properties: {
code: '431000',
name: '郴州市',
province: '43',
city: '10',
lat: 113.174094,
lon: 25.267215,
},
geometry: { type: 'Point', coordinates: [113.03426122060888, 25.960711797927825] },
id: 431000,
},
'431002': {
type: 'Feature',
properties: {
code: '431002',
name: '北湖区',
province: '43',
city: '10',
area: '02',
lat: 112.8603739081492,
lon: 25.676803625471432,
},
geometry: { type: 'Point', coordinates: [112.8603739081492, 25.676803625471432] },
id: 431002,
},
'431003': {
type: 'Feature',
properties: {
code: '431003',
name: '苏仙区',
province: '43',
city: '10',
area: '03',
lat: 113.04903992690875,
lon: 25.854975847004248,
},
geometry: { type: 'Point', coordinates: [113.04903992690875, 25.854975847004248] },
id: 431003,
},
'431021': {
type: 'Feature',
properties: {
code: '431021',
name: '桂阳县',
province: '43',
city: '10',
area: '21',
lat: 112.6145289820538,
lon: 25.858304100235213,
},
geometry: { type: 'Point', coordinates: [112.6145289820538, 25.858304100235213] },
id: 431021,
},
'431022': {
type: 'Feature',
properties: {
code: '431022',
name: '宜章县',
province: '43',
city: '10',
area: '22',
lat: 112.98307352927853,
lon: 25.35377147492103,
},
geometry: { type: 'Point', coordinates: [112.98307352927853, 25.35377147492103] },
id: 431022,
},
'431023': {
type: 'Feature',
properties: {
code: '431023',
name: '永兴县',
province: '43',
city: '10',
area: '23',
lat: 113.10712408844373,
lon: 26.178289490152817,
},
geometry: { type: 'Point', coordinates: [113.10712408844373, 26.178289490152817] },
id: 431023,
},
'431024': {
type: 'Feature',
properties: {
code: '431024',
name: '嘉禾县',
province: '43',
city: '10',
area: '24',
lat: 112.40578037476992,
lon: 25.630956809378976,
},
geometry: { type: 'Point', coordinates: [112.40578037476992, 25.630956809378976] },
id: 431024,
},
'431025': {
type: 'Feature',
properties: {
code: '431025',
name: '临武县',
province: '43',
city: '10',
area: '25',
lat: 112.5580873801755,
lon: 25.382447796561063,
},
geometry: { type: 'Point', coordinates: [112.5580873801755, 25.382447796561063] },
id: 431025,
},
'431026': {
type: 'Feature',
properties: {
code: '431026',
name: '汝城县',
province: '43',
city: '10',
area: '26',
lat: 113.63807314437123,
lon: 25.609015492382653,
},
geometry: { type: 'Point', coordinates: [113.63807314437123, 25.609015492382653] },
id: 431026,
},
'431027': {
type: 'Feature',
properties: {
code: '431027',
name: '桂东县',
province: '43',
city: '10',
area: '27',
lat: 113.89127171187062,
lon: 25.974885169989438,
},
geometry: { type: 'Point', coordinates: [113.89127171187062, 25.974885169989438] },
id: 431027,
},
'431028': {
type: 'Feature',
properties: {
code: '431028',
name: '安仁县',
province: '43',
city: '10',
area: '28',
lat: 113.36302129296655,
lon: 26.57906345240132,
},
geometry: { type: 'Point', coordinates: [113.36302129296655, 26.57906345240132] },
id: 431028,
},
'431081': {
type: 'Feature',
properties: {
code: '431081',
name: '资兴市',
province: '43',
city: '10',
area: '81',
lat: 113.40058490444207,
lon: 25.975670305481366,
},
geometry: { type: 'Point', coordinates: [113.40058490444207, 25.975670305481366] },
id: 431081,
},
'431100': {
type: 'Feature',
properties: {
code: '431100',
name: '永州市',
province: '43',
city: '11',
lat: 111.7183,
lon: 25.173925,
},
geometry: { type: 'Point', coordinates: [111.7416062371912, 25.951811794383925] },
id: 431100,
},
'431102': {
type: 'Feature',
properties: {
code: '431102',
name: '零陵区',
province: '43',
city: '11',
area: '02',
lat: 111.55553448031758,
lon: 26.14780552161849,
},
geometry: { type: 'Point', coordinates: [111.55553448031758, 26.14780552161849] },
id: 431102,
},
'431103': {
type: 'Feature',
properties: {
code: '431103',
name: '冷水滩区',
province: '43',
city: '11',
area: '03',
lat: 111.60293938240702,
lon: 26.548297585691408,
},
geometry: { type: 'Point', coordinates: [111.60293938240702, 26.548297585691408] },
id: 431103,
},
'431121': {
type: 'Feature',
properties: {
code: '431121',
name: '祁阳县',
province: '43',
city: '11',
area: '21',
lat: 111.94631312198649,
lon: 26.495727084112442,
},
geometry: { type: 'Point', coordinates: [111.94631312198649, 26.495727084112442] },
id: 431121,
},
'431122': {
type: 'Feature',
properties: {
code: '431122',
name: '东安县',
province: '43',
city: '11',
area: '22',
lat: 111.36564723490737,
lon: 26.495431710870363,
},
geometry: { type: 'Point', coordinates: [111.36564723490737, 26.495431710870363] },
id: 431122,
},
'431123': {
type: 'Feature',
properties: {
code: '431123',
name: '双牌县',
province: '43',
city: '11',
area: '23',
lat: 111.68023339268507,
lon: 25.926714417073473,
},
geometry: { type: 'Point', coordinates: [111.68023339268507, 25.926714417073473] },
id: 431123,
},
'431124': {
type: 'Feature',
properties: {
code: '431124',
name: '道县',
province: '43',
city: '11',
area: '24',
lat: 111.62976720388347,
lon: 25.48272502173264,
},
geometry: { type: 'Point', coordinates: [111.62976720388347, 25.48272502173264] },
id: 431124,
},
'431125': {
type: 'Feature',
properties: {
code: '431125',
name: '江永县',
province: '43',
city: '11',
area: '25',
lat: 111.2683147354019,
lon: 25.20222755314931,
},
geometry: { type: 'Point', coordinates: [111.2683147354019, 25.20222755314931] },
id: 431125,
},
'431126': {
type: 'Feature',
properties: {
code: '431126',
name: '宁远县',
province: '43',
city: '11',
area: '26',
lat: 112.01649011439686,
lon: 25.676554968597507,
},
geometry: { type: 'Point', coordinates: [112.01649011439686, 25.676554968597507] },
id: 431126,
},
'431127': {
type: 'Feature',
properties: {
code: '431127',
name: '蓝山县',
province: '43',
city: '11',
area: '27',
lat: 112.18281610315154,
lon: 25.321074398054705,
},
geometry: { type: 'Point', coordinates: [112.18281610315154, 25.321074398054705] },
id: 431127,
},
'431128': {
type: 'Feature',
properties: {
code: '431128',
name: '新田县',
province: '43',
city: '11',
area: '28',
lat: 112.22263983583727,
lon: 25.84740084494276,
},
geometry: { type: 'Point', coordinates: [112.22263983583727, 25.84740084494276] },
id: 431128,
},
'431129': {
type: 'Feature',
properties: {
code: '431129',
name: '江华瑶族自治县',
province: '43',
city: '11',
area: '29',
lat: 111.73228282511425,
lon: 25.02747266665342,
},
geometry: { type: 'Point', coordinates: [111.73228282511425, 25.02747266665342] },
id: 431129,
},
'431200': {
type: 'Feature',
properties: {
code: '431200',
name: '怀化市',
province: '43',
city: '12',
lat: 109.964839,
lon: 27.038146,
},
geometry: { type: 'Point', coordinates: [109.93376549193455, 27.41806844326335] },
id: 431200,
},
'431202': {
type: 'Feature',
properties: {
code: '431202',
name: '鹤城区',
province: '43',
city: '12',
area: '02',
lat: 109.94361045769725,
lon: 27.626946360447516,
},
geometry: { type: 'Point', coordinates: [109.94361045769725, 27.626946360447516] },
id: 431202,
},
'431221': {
type: 'Feature',
properties: {
code: '431221',
name: '中方县',
province: '43',
city: '12',
area: '21',
lat: 110.14201631780014,
lon: 27.48984603854742,
},
geometry: { type: 'Point', coordinates: [110.14201631780014, 27.48984603854742] },
id: 431221,
},
'431222': {
type: 'Feature',
properties: {
code: '431222',
name: '沅陵县',
province: '43',
city: '12',
area: '22',
lat: 110.54133596555236,
lon: 28.562858239106827,
},
geometry: { type: 'Point', coordinates: [110.54133596555236, 28.562858239106827] },
id: 431222,
},
'431223': {
type: 'Feature',
properties: {
code: '431223',
name: '辰溪县',
province: '43',
city: '12',
area: '23',
lat: 110.23399906856866,
lon: 27.86245497946487,
},
geometry: { type: 'Point', coordinates: [110.23399906856866, 27.86245497946487] },
id: 431223,
},
'431224': {
type: 'Feature',
properties: {
code: '431224',
name: '溆浦县',
province: '43',
city: '12',
area: '24',
lat: 110.64377455063104,
lon: 27.775056932014728,
},
geometry: { type: 'Point', coordinates: [110.64377455063104, 27.775056932014728] },
id: 431224,
},
'431225': {
type: 'Feature',
properties: {
code: '431225',
name: '会同县',
province: '43',
city: '12',
area: '25',
lat: 109.720566701154,
lon: 26.91265274694139,
},
geometry: { type: 'Point', coordinates: [109.720566701154, 26.91265274694139] },
id: 431225,
},
'431226': {
type: 'Feature',
properties: {
code: '431226',
name: '麻阳苗族自治县',
province: '43',
city: '12',
area: '26',
lat: 109.74096819221954,
lon: 27.80477572603321,
},
geometry: { type: 'Point', coordinates: [109.74096819221954, 27.80477572603321] },
id: 431226,
},
'431227': {
type: 'Feature',
properties: {
code: '431227',
name: '新晃侗族自治县',
province: '43',
city: '12',
area: '27',
lat: 109.15330997959687,
lon: 27.216016737978528,
},
geometry: { coordinates: [109.15330997959687, 27.216016737978528], type: 'Point' },
id: 431227,
},
'431228': {
type: 'Feature',
properties: {
code: '431228',
name: '芷江侗族自治县',
province: '43',
city: '12',
area: '28',
lat: 109.60670870031228,
lon: 27.361569083500004,
},
geometry: { type: 'Point', coordinates: [109.60670870031228, 27.361569083500004] },
id: 431228,
},
'431229': {
type: 'Feature',
properties: {
code: '431229',
name: '靖州苗族侗族自治县',
province: '43',
city: '12',
area: '29',
lat: 109.56544327505064,
lon: 26.554838657161742,
},
geometry: { type: 'Point', coordinates: [109.56544327505064, 26.554838657161742] },
id: 431229,
},
'431230': {
type: 'Feature',
properties: {
code: '431230',
name: '通道侗族自治县',
province: '43',
city: '12',
area: '30',
lat: 109.71154332595692,
lon: 26.198655734905234,
},
geometry: { type: 'Point', coordinates: [109.71154332595692, 26.198655734905234] },
id: 431230,
},
'431281': {
type: 'Feature',
properties: {
code: '431281',
name: '洪江市',
province: '43',
city: '12',
area: '81',
lat: 110.01878196661865,
lon: 27.256748654565424,
},
geometry: { type: 'Point', coordinates: [110.01878196661865, 27.256748654565424] },
id: 431281,
},
'431300': {
type: 'Feature',
properties: {
code: '431300',
name: '娄底市',
province: '43',
city: '13',
lat: 111.611947,
lon: 27.282518,
zoom: 6.9,
},
geometry: { type: 'Point', coordinates: [111.677667188117, 27.71099136302805] },
id: 431300,
},
'431302': {
type: 'Feature',
properties: {
code: '431302',
name: '娄星区',
province: '43',
city: '13',
area: '02',
lat: 111.98755537122673,
lon: 27.750035741737094,
},
geometry: { type: 'Point', coordinates: [111.98755537122673, 27.750035741737094] },
id: 431302,
},
'431321': {
type: 'Feature',
properties: {
code: '431321',
name: '双峰县',
province: '43',
city: '13',
area: '21',
lat: 112.13962661839318,
lon: 27.461794849263505,
},
geometry: { type: 'Point', coordinates: [112.13962661839318, 27.461794849263505] },
id: 431321,
},
'431322': {
type: 'Feature',
properties: {
code: '431322',
name: '新化县',
province: '43',
city: '13',
area: '22',
lat: 111.3152264307285,
lon: 27.880228572009678,
},
geometry: { type: 'Point', coordinates: [111.3152264307285, 27.880228572009678] },
id: 431322,
},
'431381': {
type: 'Feature',
properties: {
code: '431381',
name: '冷水江市',
province: '43',
city: '13',
area: '81',
lat: 111.46825896274329,
lon: 27.674135497452824,
},
geometry: { type: 'Point', coordinates: [111.46825896274329, 27.674135497452824] },
id: 431381,
},
'431382': {
type: 'Feature',
properties: {
code: '431382',
name: '涟源市',
province: '43',
city: '13',
area: '82',
lat: 111.78308744709621,
lon: 27.74119687592407,
},
geometry: { type: 'Point', coordinates: [111.78308744709621, 27.74119687592407] },
id: 431382,
},
'433100': {
type: 'Feature',
properties: {
code: '433100',
name: '湘西土家族苗族自治州',
province: '43',
city: '31',
lat: 109.70778,
lon: 28.247456,
zoom: 6.8,
},
geometry: { type: 'Point', coordinates: [109.73313149247407, 28.430490460403302] },
id: 433100,
},
'433101': {
type: 'Feature',
properties: {
code: '433101',
name: '吉首市',
province: '43',
city: '31',
area: '01',
lat: 109.76055819701659,
lon: 28.300831512998258,
},
geometry: { type: 'Point', coordinates: [109.76055819701659, 28.300831512998258] },
id: 433101,
},
'433122': {
type: 'Feature',
properties: {
code: '433122',
name: '泸溪县',
province: '43',
city: '31',
area: '22',
lat: 109.94535577515231,
lon: 28.164097254481,
},
geometry: { type: 'Point', coordinates: [109.94535577515231, 28.164097254481] },
id: 433122,
},
'433123': {
type: 'Feature',
properties: {
code: '433123',
name: '凤凰县',
province: '43',
city: '31',
area: '23',
lat: 109.53346313796658,
lon: 28.0311076077105,
},
geometry: { type: 'Point', coordinates: [109.53346313796658, 28.0311076077105] },
id: 433123,
},
'433124': {
type: 'Feature',
properties: {
code: '433124',
name: '花垣县',
province: '43',
city: '31',
area: '24',
lat: 109.46394455809526,
lon: 28.404231328040005,
},
geometry: { type: 'Point', coordinates: [109.46394455809526, 28.404231328040005] },
id: 433124,
},
'433125': {
type: 'Feature',
properties: {
code: '433125',
name: '保靖县',
province: '43',
city: '31',
area: '25',
lat: 109.59422040807006,
lon: 28.59272649621564,
},
geometry: { type: 'Point', coordinates: [109.59422040807006, 28.59272649621564] },
id: 433125,
},
'433126': {
type: 'Feature',
properties: {
code: '433126',
name: '古丈县',
province: '43',
city: '31',
area: '26',
lat: 109.97191426812222,
lon: 28.564002304513735,
},
geometry: { type: 'Point', coordinates: [109.97191426812222, 28.564002304513735] },
id: 433126,
},
'433127': {
type: 'Feature',
properties: {
code: '433127',
name: '永顺县',
province: '43',
city: '31',
area: '27',
lat: 109.9984961148913,
lon: 29.079962858908143,
},
geometry: { type: 'Point', coordinates: [109.9984961148913, 29.079962858908143] },
id: 433127,
},
'433130': {
type: 'Feature',
properties: {
code: '433130',
name: '龙山县',
province: '43',
city: '31',
area: '30',
lat: 109.47646903781634,
lon: 29.275202241677274,
},
geometry: { type: 'Point', coordinates: [109.47646903781634, 29.275202241677274] },
id: 433130,
},
}
export type * from './src/types'
export * from './src/hook'
export { default as AffixFilterWidget } from './src/AffixFilter.vue'
/**
* 吸附过滤器组件
*
* 说明: 用于吸附在地图上的过滤器组件,可以用于筛选地图上的数据或改变查询条件等,支持的组件有: Select
*
* 可以应用到的一些模块:
* 1. 山洪地质灾害
* 2. 森林火险
* 3. 水情监测
* 4. 卫星云图
* 5. 形势场
* 6. 雷达拼图
* ...
*/
<!-- 吸附过滤器组件 -->
<script setup lang="ts">
import type { Option, OptionItem } from './types'
import { deepMerge } from '@/utils'
const props = defineProps({
// 是否显示
show: {
type: Boolean,
default: true,
},
// 布局
layout: {
type: String,
default: 'horizontal',
},
// 风格
style: {
type: String,
default: 'button',
},
// 距离顶部的距离
top: {
type: Number,
default: 125,
},
// 距离左侧的距离
left: {
type: Number,
default: 30,
},
// 选项
options: {
type: Array as PropType<Option[]>,
default: () => [],
},
})
const emits = defineEmits(['register'])
const positionTop = computed(() => `${data.top}rpx`)
const positionLeft = computed(() => `${data.left}rpx`)
const data = reactive({
show: props.show,
layout: props.layout,
style: props.style,
top: props.top,
left: props.left,
options: props.options,
})
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
function setProps(value: Partial<typeof props>) {
deepMerge(data, value)
}
function getProp(key: keyof typeof props) {
return data[key]
}
function setOptionItems(key: string, items: OptionItem[]) {
data.options = data.options.map((option) => {
if (option.key === key) {
option.items = items
}
return option
})
}
function setOptionItemChecked(key: string, value: string) {
data.options = data.options.map((option) => {
if (option.key === key) {
option.items = option.items.map((item) => {
item.checked = item.value === value
return item
})
}
return option
})
}
const zIndex = computed(() => (model.show ? 200 : 110))
const model = reactive({
show: false,
activeOption: null as Option | null,
})
function optionClick(option: Option) {
console.log(option)
model.activeOption = option
model.show = true
}
function getItemLabel(option: Option) {
return option.items.find((item) => item.checked)?.text ?? option.placeholder
}
function optionChange(e: { text: string; value: string }) {
const currentOptionChecked = model.activeOption?.items.find((item) => item.checked)
data.options = data.options.map((option) => {
if (option.key === model.activeOption?.key) {
option.items = option.items.map((item) => {
item.checked = item.value === e.value
return item
})
}
return option
})
// 判断是否真的发生了变化
if (
currentOptionChecked?.value !==
data.options.find((option) => option.key === model.activeOption?.key)?.items.find((item) => item.checked)
?.value
) {
model.activeOption?.onChange?.(
{
key: model.activeOption.key,
value: toRaw(model.activeOption.items.find((item) => item.checked)),
},
data.options.map((option) => {
return {
key: option.key,
value: toRaw(option.items.find((item) => item.checked)),
}
}),
)
}
closePicker()
}
function closePicker() {
model.activeOption = null
model.show = false
}
emits('register', {
setProps,
getProp,
toggleShow,
setOptionItems,
setOptionItemChecked,
})
</script>
<template>
<view class="wrap affix-filter" :class="[data.layout, data.style]">
<!-- -->
<view class="filter-item flex-center" v-for="option in data.options" :key="option.key">
<view class="fui-filter__item flex-center" @tap="optionClick(option)">
<text>{{ getItemLabel(option) }}</text>
<view class="ml-10rpx icon" :class="{ open: model.activeOption?.key === option.key }">
<fui-icon name="turningdown" :size="32" color="#999" />
</view>
</view>
</view>
<!-- 复用选择器 -->
<fui-picker
linkage
:options="model.activeOption?.items"
:show="model.show"
@change="optionChange"
@cancel="closePicker"
/>
</view>
</template>
<style lang="scss" scoped>
@use '../../common.scss' as *;
.affix-filter {
display: flex;
box-sizing: border-box;
position: absolute;
top: v-bind(positionTop);
left: v-bind(positionLeft);
z-index: v-bind(zIndex);
&.vertical {
flex-direction: column;
.filter-item + .filter-item {
margin-top: 30rpx;
}
}
&.horizontal {
.filter-item + .filter-item {
margin-left: 30rpx;
}
}
&.bar {
width: 100%;
background-color: white;
left: 0;
padding: 10rpx 30rpx;
.filter-item {
flex: 1;
box-shadow: none !important;
}
}
.filter-item {
background-color: white;
padding: 15rpx 30rpx;
font-size: 28rpx;
color: #333;
border-radius: 10rpx;
box-shadow: 0 0 15rpx rgba(0, 0, 0, 0.1);
.icon {
@include animate();
transform: rotate(0deg);
&.open {
transform: rotate(180deg);
}
}
}
}
</style>
import { registerWidgetFactory, unpackWidgetInstance } from '../../utils'
import type { AffixFilterInstance, AffixFilterProps, OptionItem } from './types'
// 组件名称
export const name = 'AffixFilterWidget'
/**
* 吸附过滤器组件
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useAffixFilterWidget<T extends AffixFilterInstance, P extends AffixFilterProps>(
props: P,
): [(instance: T) => void, AffixFilterInstance] {
const instanceRef = ref<T>()
const register = registerWidgetFactory(instanceRef, props)
const { get, ...fns } = unpackWidgetInstance(instanceRef, name)
return [
register,
{
...fns,
setOptionItem: (key: string, item: OptionItem) => get()?.setOptionItem(key, item),
},
]
}
import type { BasicWidgetInstance, BasicWidgetProps } from '../../utils'
export interface CheckedOption {
key: string
value: OptionItem
}
export interface OptionItem {
text: string
value: string
checked?: boolean
disabled?: boolean
[key: string]: any
}
export interface Option {
// 标识
key: string
// 提示信息
placeholder?: string
// 选项
items: OptionItem[]
// 选项改变时的回调
onChange?: (checkedOption: CheckedOption, checkedOptions: CheckedOption[]) => void
}
export interface AffixFilterProps extends BasicWidgetProps {
/**
* 布局方式,水平或垂直
* @default horizontal
*/
layout?: 'horizontal' | 'vertical'
/**
* 风格,按钮或者条形
* @default button
*/
style?: 'button' | 'bar'
/**
* 距离顶部的距离 rpx
* @default 130rpx
*/
top?: number
/**
* 距离左侧的距离 rpx
* @default 30rpx
*/
left?: number
/**
* 过滤选项集合,仅支持 Select
*/
options: Option[]
}
export interface AffixFilterInstance extends BasicWidgetInstance<AffixFilterProps> {
/**
* 设置选项
* @param key 选项标识
* @param items 选项可选 options
*/
setOptionItems: (key: string, items: OptionItem[]) => void
/**
* 设置选项选中
* @param key 选项标识
* @param value 选项值
*/
setOptionItemChecked: (key: string, value: string) => void
}
export type * from './src/types'
export * from './src/hook'
export { default as BottomBarWidget } from './src/BottomBar.vue'
/**
* 底部工具栏组件
*
* 说明: 此组件仅提供一个可交互的容器,用于放置其他组件,如: Tabs、Button 等一些自定义场景
*
* 可以应用到的一些模块:(基于已有蓝湖设计稿评估,仅供参考)
* 1. 降水监测
* 2. 气温监测
* 3. 相对湿度
* 4. 雷达拼图
* 5. 强对流监测
* 6. 冰雪天气
* 7. 台风监测
* 8. 卫星云图
* 9. 环境监测
* 10. 山洪地质灾害、面雨量相关
* 11. 相关预报模块
* ...
*
*/
<!-- 底部 Bar 组件 -->
<script setup lang="ts">
import { deepMerge } from '@/utils'
const props = defineProps({
// 是否显示
show: {
type: Boolean,
default: true,
},
// 是否展开
expand: {
type: Boolean,
default: false,
},
// 展开按钮标题
expandTitle: {
type: String,
},
// 是否有展开按钮
showExpandButton: {
type: Boolean,
default: true,
},
// 是否有展开按钮下边框
showExpandBorder: {
type: Boolean,
default: false,
},
// 高度
height: {
type: Number,
default: 0,
},
// 最高高度
maxHeight: {
type: Number,
default: 0,
},
// 是否 Y 轴可滚动
scrollY: {
type: Boolean,
default: false,
},
})
const emits = defineEmits(['register'])
const iHeight = computed(() => `${data.height}rpx`)
const iMaxHeight = computed(() => `${data.maxHeight}rpx`)
const data = reactive({
show: props.show,
expand: props.expand,
expandTitle: props.expandTitle,
showExpandButton: props.showExpandButton,
showExpandBorder: props.showExpandBorder,
height: props.height,
maxHeight: props.maxHeight,
scrollY: props.scrollY,
})
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
function toggleExpand(expand?: boolean) {
if (data.showExpandButton) {
data.expand = expand ?? !data.expand
}
}
function setProps(value: Partial<typeof props>) {
deepMerge(data, value)
}
function getProp(key: keyof typeof props) {
return data[key]
}
emits('register', {
setProps,
getProp,
toggleShow,
toggleExpand,
// TODO: 此处高度还需考虑底部安全区
height: computed(() => (data.show ? (data.expand ? data.maxHeight : data.height) : 0)),
})
</script>
<template>
<view class="wrap bottom-bar" :class="{ expand: data.expand }" v-show="data.show">
<view
class="action flex-center"
:class="{ border: data.showExpandBorder }"
v-show="data.showExpandButton"
@tap="toggleExpand()"
>
<text class="text" v-if="data.expandTitle">{{ data.expandTitle }}</text>
<Icon icon="solar-double-alt-arrow-up-line-duotone" size="42" color="#999" class="icon" />
</view>
<!-- 自定义内容 -->
<scroll-view class="content flex-center" :scroll-y="data.scrollY">
<slot></slot>
</scroll-view>
<!-- 底部安全区 -->
<fui-safe-area />
</view>
</template>
<style lang="scss" scoped>
@use '../../common.scss' as *;
.wrap {
@include animate();
position: absolute;
left: 30rpx;
bottom: 30rpx;
z-index: 99;
background-color: white;
}
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
// padding: 30rpx 12rpx;
padding: 10px 12px;
// width: calc(100% - 60rpx);
width: 100%;
height: v-bind(iHeight);
max-height: v-bind(iMaxHeight);
box-shadow: 0 0 15rpx rgba(0, 0, 0, 0.1);
box-sizing: border-box;
&.expand {
height: v-bind(iMaxHeight);
.icon {
transform: rotateX(180deg) !important;
}
}
.action {
width: 100%;
position: relative;
// top: -15rpx;
font-size: 24rpx;
color: #999;
padding-bottom: 10rpx;
&.border {
border-bottom: 2rpx solid #f6f6f6;
}
.text {
margin-right: 0.5em;
}
.icon {
@include animate();
transform: rotateX(0);
transform-style: preserve-3d;
}
}
.content {
box-sizing: border-box;
width: 100%;
// height: calc(100% - 60rpx - 20rpx);
height: 100%;
overflow: hidden;
position: relative;
}
}
</style>
import { getBooleanOrDefault, registerWidgetFactory, unpackWidgetInstance } from '../../utils'
import type { BottomBarInstance, BottomBarProps } from './types'
// 组件名称
export const name = 'BottomBarWidget'
/**
* 底部交互/展示小部件响应式 Hook
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useBottomBarWidget<T extends BottomBarInstance, P extends BottomBarProps>(
props: P,
): [(instance: T) => void, BottomBarInstance] {
const instanceRef = ref<T>()
const register = registerWidgetFactory(instanceRef, props)
const { get, ...fns } = unpackWidgetInstance(instanceRef, name)
return [
register,
{
...fns,
toggleExpand: (expand?: boolean) => get()?.toggleExpand(getBooleanOrDefault(expand)),
height: computed(() => instanceRef?.value?.height) as unknown as ComputedRef<number>,
},
]
}
import type { BasicWidgetInstance, BasicWidgetProps } from '../../utils'
export interface BottomBarProps extends BasicWidgetProps {
/**
* 是否展开
* @default false
*/
expand?: boolean
/**
* 展开标题
*/
expandTitle?: string
/**
* 是否有展开按钮
* @default true
*/
showExpandButton?: boolean
/**
* 是否有展开边框
* @default false
*/
showExpandBorder?: boolean
/**
* 高度 rpx
* @default 0 rpx
*/
height: number
/**
* 最大高度 rpx
* @default 0 rpx
*/
maxHeight?: number
/**
* 是否支持 Y 滚动
* @default false
*/
scrollY?: boolean
}
export interface BottomBarInstance extends BasicWidgetInstance<BottomBarProps> {
/**
* 响应式组件当前高度,单位 rpx
*/
height: ComputedRef<number>
/**
* 切换展开状态
* @param expand 是否展开
*/
toggleExpand: (expand?: boolean) => void
}
export type * from './src/types'
export * from './src/hook'
export { default as LegendWidget } from './src/Legend.vue'
/**
* 图例组件
*
* 说明: 配合地图上的数据展示的图例,支持动态配置
*
* 可以应用到的一些模块:(基于已有蓝湖设计稿评估,仅供参考)
* 所有需要用到图例展示的模块
* 各种场景下的配置项,可以参考: \src\pages\business\monitor\rain\config.ts defaultLegendConfig
*
*/
<!-- 图例组件 -->
<script setup lang="ts">
import { isArray } from 'lodash-es'
import { createReusableTemplate } from '@vueuse/core'
import type { Option } from './types'
import { deepMerge } from '@/utils'
import { cssAdditionCalc } from '@/components/Map/Widgets/utils'
const props = defineProps({
// 显示
show: {
type: Boolean,
default: true,
},
// 展开
expand: {
type: Boolean,
default: false,
},
// 标题
title: {
type: String,
},
// 底部距离 rpx
bottom: {
type: Number,
default: 0,
},
// JSON 数据
option: {
type: Object as PropType<Option>,
default: () => ({}) as Option,
},
})
const emits = defineEmits(['register'])
// 定义复用渲染组件
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{ item: Recordable; sub?: boolean }>()
const positionBottom = computed(() => cssAdditionCalc(30, data.bottom))
const data = reactive({
show: props.show,
expand: props.expand,
title: props.title,
option: props.option,
bottom: props.bottom,
})
function setTitle(title: string) {
data.title = title
}
function setOption(option: Option) {
data.option = option
}
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
function toggleExpand(expand?: boolean) {
data.expand = expand ?? !data.expand
}
function setProps(value: Partial<typeof props>) {
deepMerge(data, value)
}
function getProp(key: keyof typeof props) {
return data[key]
}
emits('register', {
setProps,
getProp,
setTitle,
setOption,
toggleShow,
toggleExpand,
})
function getBorderColor(color: string) {
switch (color) {
case 'white':
case '#fff':
case '#ffffff':
case '#FFFFFF':
case 'rgb(255, 255, 255)':
case 'rgba(255, 255, 255, 1)':
return '#d7d7d7'
default:
return color
}
}
</script>
<template>
<view class="wrap legend flex-center" @click="data.expand = !data.expand" v-show="data.show">
<!-- 展开/收起 -->
<view class="expand-action flex-center" v-show="!data.expand">
<view class="text">图例</view>
<Icon icon="ic-round-keyboard-arrow-up" color="#1890ff" size="42" />
</view>
<!-- 内容 -->
<view class="expand-content flex-center" v-show="data.expand">
<!-- 标题 -->
<view v-if="data.title" class="title">{{ data.title }}</view>
<!-- 定义复用渲染组件 -->
<DefineTemplate v-slot="{ item, sub }">
<view class="item">
<!-- 图标 -->
<view v-if="item.icon" class="icon flex-center">
<CacheImage :src="item.icon" width="34" height="34" background="transparent" />
</view>
<!-- 色块 -->
<view
v-if="item.color"
class="color"
:style="{
backgroundColor: item.color,
border: `1rpx solid ${getBorderColor(item.color)}`,
...data.option.blockStyle,
}"
/>
<!-- 标签 -->
<view
v-if="String(item.label)"
class="label"
:class="{ 'color-label': item.color }"
:style="!sub && { ...data.option.labelStyle }"
>
{{ item.label }}
</view>
<!-- 子项集合 -->
<view v-if="item.items" class="sub-items">
<view v-for="(subItem, index) in item.items" :key="index" class="item">
<ReuseTemplate :item="subItem" :sub="true" />
</view>
</view>
</view>
</DefineTemplate>
<!-- JSON -->
<view class="option">
<template v-if="isArray(data.option.items?.[0])">
<view v-for="(item, index) in data.option.items" :key="index" class="item-wrap">
<view v-for="(subItem, subIndex) in item" :key="subIndex" class="items">
<ReuseTemplate :item="subItem" />
</view>
</view>
</template>
<template v-else>
<view class="item-wrap">
<view v-for="(item, index) in data.option.items" :key="index" class="items">
<ReuseTemplate :item="item" />
</view>
</view>
</template>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
@use '../../common.scss' as *;
.wrap {
@include animate();
position: absolute;
left: 30rpx;
bottom: v-bind(positionBottom);
z-index: 99;
}
.legend {
flex-direction: column;
padding: 12rpx 20rpx;
background-color: #fff;
border-radius: 10rpx;
box-shadow: 0 0 15rpx rgba(0, 0, 0, 0.2);
.expand-action {
font-size: 26rpx;
font-weight: bold;
color: #1890ff;
.text {
margin-right: 6rpx;
}
}
.expand-content {
flex-direction: column;
font-size: 24rpx;
color: #555;
.title {
margin-bottom: 15rpx;
font-weight: bold;
}
.option {
display: flex;
.item-wrap {
+ .item-wrap {
margin-left: 15rpx;
}
.item {
display: flex;
align-items: center;
justify-content: flex-start;
.icon {
margin-right: 8rpx;
padding: 4rpx;
}
.color {
width: 42rpx;
height: 24rpx;
margin-right: 10rpx !important;
}
.label {
color: #777;
letter-spacing: 1px;
white-space: pre-wrap;
text-align: center;
&.color-label {
height: 24rpx;
}
}
}
.sub-items {
display: flex;
.item {
flex-direction: column;
justify-content: center;
margin: 2rpx 5rpx;
font-size: 20rpx;
}
}
}
}
}
}
</style>
import { getBooleanOrDefault, registerWidgetFactory, unpackWidgetInstance } from '../../utils'
import type { LegendInstance, LegendProps, Option } from './types'
// 组件名称
export const name = 'LegendWidget'
/**
* 图例组件响应式 Hook
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useLegendWidget<T extends LegendInstance, P extends LegendProps>(
props: P,
): [(instance: T) => void, LegendInstance] {
const instanceRef = ref<T>()
const register = registerWidgetFactory(instanceRef, props)
const { get, ...fns } = unpackWidgetInstance(instanceRef, name)
return [
register,
{
...fns,
setTitle: (title: string) => get()?.setTitle(title),
setOption: (option: Option) => get()?.setOption(option),
toggleExpand: (expand?: boolean) => get()?.toggleExpand(getBooleanOrDefault(expand)),
},
]
}
import type { BasicWidgetInstance, BasicWidgetProps } from '../../utils'
export interface OptionItem {
// 颜色
color?: string
// 图标
icon?: string
// 标签
label?: string
// 多子项
items?: OptionItem[]
}
export interface Option {
// 选项
items?: OptionItem[] | OptionItem[][]
// label 样式
labelStyle?: Recordable
// 色块样式
blockStyle?: Recordable
}
export interface LegendProps extends BasicWidgetProps {
/**
* 是否展开
* @default false
*/
expand?: boolean
/**
* 标题
*/
title: string
/**
* 选项
*/
option?: Option
/**
* 底部距离 rpx
* @default 0
*/
bottom?: number
}
export interface LegendInstance extends BasicWidgetInstance<LegendProps> {
/**
* 设置标题
* @param title 标题
*/
setTitle: (title: string) => void
/**
* 设置选项
* @param option 选项
*/
setOption: (option: Option) => void
/**
* 切换展开状态
* @param expand 是否展开
*/
toggleExpand: (expand?: boolean) => void
}
export type * from './src/types'
export * from './src/hook'
export { default as SwitchWidget } from './src/Switch.vue'
/**
* 前后切换组件
*
* 说明: 用于地图页面两侧的前后切换组件,通常与 TimeBarWidget 配合使用
*
* 可以应用到的一些模块:(基于已有蓝湖设计稿评估,仅供参考)
* 所有需要用到前后切换时间的场景,实况、预报等
*
*/
<!-- 前后切换组件 -->
<script setup lang="ts">
const props = defineProps({
// 是否显示
show: {
type: Boolean,
default: true,
},
// 底部距离 rpx
bottom: {
type: Number,
default: 0,
},
})
const emits = defineEmits(['register', 'prev', 'next'])
const height = computed(() => `calc(50% - 60rpx${data.bottom ? ` - ${data.bottom / 2}rpx` : ''})`)
const data = reactive({
show: props.show,
bottom: props.bottom,
prev: () => {},
next: () => {},
})
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
function setProps(value: Partial<typeof props>) {
Object.assign(data, value)
}
function getProp(key: keyof typeof props) {
return data[key]
}
function onPrev() {
data.prev?.()
emits('prev')
}
function onNext() {
data.next?.()
emits('next')
}
emits('register', {
setProps,
getProp,
toggleShow,
prev: onPrev,
next: onNext,
})
</script>
<template>
<view class="wrap switch-control" v-show="data.show">
<view class="flex-center prev" @tap="onPrev">
<Icon class="icon" icon="ic-baseline-arrow-back-ios" size="50" color="white" />
</view>
<view class="flex-center next" @tap="onNext">
<Icon class="icon" icon="ic-baseline-arrow-forward-ios" size="50" color="white" />
</view>
</view>
</template>
<style lang="scss" scoped>
@use '../../common.scss' as *;
.switch-control {
> * {
@include animate();
width: 60rpx;
height: 120rpx;
background-color: rgba(0, 0, 0, 0.3);
position: absolute;
top: v-bind(height);
z-index: 99;
}
.prev {
left: 0;
border-radius: 0 15rpx 15rpx 0;
.icon {
position: relative;
left: 12rpx;
}
}
.next {
right: 0;
border-radius: 15rpx 0 0 15rpx;
}
}
</style>
import { registerWidgetFactory, unpackWidgetInstance } from '../../utils'
import type { SwitchControlInstance, SwitchControlProps } from './types'
// 组件名称
export const name = 'SwitchControlWidget'
/**
* 前后切换组件响应式 Hook
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useSwitchWidget<T extends SwitchControlInstance, P extends SwitchControlProps>(
props: P,
): [(instance: T) => void, SwitchControlInstance] {
const instanceRef = ref<T>()
const register = registerWidgetFactory(instanceRef, props)
const { get, ...fns } = unpackWidgetInstance(instanceRef, name)
return [
register,
{
...fns,
prev: () => get()?.prev(),
next: () => get()?.next(),
},
]
}
import type { BasicWidgetInstance, BasicWidgetProps } from '../../utils'
export interface SwitchControlProps extends BasicWidgetProps {
// 底部距离 rpx
bottom?: number
// 上一个
prev?: () => void
// 下一个
next?: () => void
}
export interface SwitchControlInstance extends BasicWidgetInstance<SwitchControlProps> {
/**
* 手动调用上一个
*/
prev: () => void
/**
* 手动调用下一个
*/
next: () => void
}
export type * from './src/types'
export * from './src/hook'
export { default as TimeBarWidget } from './src/TimeBar.vue'
/**
* 时间栏组件
*
* 说明: 用于页面顶部的时间选择和过滤
*
* 可以应用到的一些模块:(基于已有蓝湖设计稿评估,仅供参考)
* 所有需要用到时间轴的场景,实况、预报以及部分非地图的内页,均可以实现适配
*
*/
<!-- 顶部时间栏 Bar 组件 -->
<script setup lang="ts">
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import type { TimeBarButton, TimeBarLabel, TimeBarTime } from './types'
import { formatTime, isAfter, isBefore } from './hook'
import { deepMerge } from '@/utils'
const props = defineProps({
// 是否显示
show: {
type: Boolean,
default: true,
},
// 是否只读
readonly: {
type: Boolean,
default: false,
},
// 对齐方式
align: {
type: String,
default: 'left',
},
// 标签
label: {
type: Object as PropType<TimeBarLabel>,
default: () =>
({
text: '时间',
}) as TimeBarLabel,
},
// 时间
time: {
type: Object as PropType<TimeBarTime>,
default: () => ({}) as TimeBarTime,
},
// 最小时间
minTime: {
type: Object as PropType<Dayjs>,
},
// 最大时间
maxTime: {
type: Object as PropType<Dayjs>,
},
// 扩展按钮
buttons: {
type: Array as PropType<TimeBarButton[]>,
default: () => [] as TimeBarButton[],
},
})
const emits = defineEmits(['register'])
const data = reactive({
show: props.show,
readonly: props.readonly,
align: props.align,
label: props.label,
time: props.time,
minTime: props.minTime,
maxTime: props.maxTime,
buttons: props.buttons,
})
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
function setProps(value: Partial<typeof props>) {
deepMerge(data, value)
// if (!data?.time?.value) {
// const time = dayjs()
// data.time.value = data.time.type === 'range' ? [time.subtract(1, 'days'), time] : [time]
// }
}
function getProp(key: keyof typeof props) {
return data[key]
}
function getTime(): Dayjs[] {
return toRaw(data.time.value)
}
function setTime(value: Dayjs[]) {
data.time.value = value
}
function getCheckedOption() {
return toRaw(checkedLabelOption.value)
}
function setCheckedOption(index: number) {
onLabelOptionChange((_, i) => i === index)
}
function getTimeBarValue() {
return {
option: getCheckedOption(),
value: getTime(),
}
}
const labelText = computed(() => checkedLabelOption.value?.text || data.label.text || '')
const timePickerType = computed(() => checkedLabelOption.value?.timeType || data.time?.timeType || 3)
const timeText = computed(() => {
const format = checkedLabelOption.value?.format || data.time?.format
return data.time?.value?.map((item) => dayjs(item).format(format)).join(' - ') || ''
})
// ================== Option 下拉选择相关开始 ===================
const dropdownMenu = ref(null)
const showDropdownMenu = ref(false)
const checkedLabelOption = computed(() => data.label?.options?.find((option) => option.checked) || {})
function changeLabelOption(option: TimeBarLabel['options'][0]) {
showDropdownMenu.value = false
onLabelOptionChange((item) => item.value === option.value)
}
function onLabelOptionChange(cb: (e: TimeBarLabel['options'][0], i: number) => boolean) {
data.label.options.forEach((item, index) => {
item.checked = cb(item, index)
if (item.checked) {
data.label?.onChange?.(getTimeBarValue())
}
})
}
function openDropdownMenu() {
if (data.readonly) {
return
}
showDropdownMenu.value = true
dropdownMenu.value?.show()
}
// ================== Option 下拉选择相关结束 ===================
// ================== TimePicker 选择相关开始 ===================
const showTimePicker = ref(false)
const zIndex = computed(() => (showTimePicker.value ? 200 : 110))
function changeTime(e: Recordable) {
if (e.startDate) {
const [startTime, endTime] = [dayjs(e.startDate.result), dayjs(e.endDate.result)]
if (isAfter(endTime, data.maxTime)) {
Message.toast('结束时间不能大于最大时间')
} else if (isBefore(startTime, data.minTime)) {
Message.toast('开始时间不能小于最小时间')
} else {
data.time.value = [startTime, endTime]
}
} else {
const [startTime] = [dayjs(e.result)]
if (isAfter(startTime, data.maxTime)) {
Message.toast('时间不能大于最大时间')
} else if (isBefore(startTime, data.minTime)) {
Message.toast('时间不能小于最小时间')
} else {
data.time.value = [startTime]
}
}
showTimePicker.value = false
}
function openTimePicker() {
if (!data.readonly) {
showTimePicker.value = true
}
}
watch(
() => data.time.value,
() => data.time.onChange?.(getTimeBarValue()),
)
// ================== TimePicker 选择相关结束 ===================
emits('register', {
setProps,
getProp,
toggleShow,
getTime,
setTime,
getCheckedOption,
setCheckedOption,
getTimeBarValue,
})
</script>
<template>
<view class="wrap time-bar" :class="[data.align]" v-show="data.show">
<view class="label">
<Icon
icon="ic-outline-access-time"
size="36"
color="#333"
class="icon"
v-if="data.label.breforeIcon !== false"
/>
<template v-if="data.label.options?.length">
<fui-dropdown-menu
:size="28"
selectedColor="#465CFF"
:options="data.label.options"
@click="changeLabelOption"
@close="showDropdownMenu = false"
ref="dropdownMenu"
>
<view class="fui-filter__item flex-center" @tap="openDropdownMenu">
<text>{{ checkedLabelOption.text }}</text>
<view class="fui-filter__icon" :class="{ 'fui-icon__ani': showDropdownMenu }">
<fui-icon name="turningdown" :size="32" />
</view>
</view>
</fui-dropdown-menu>
</template>
<template v-else>
<view class="text after">{{ labelText }}</view>
</template>
</view>
<view class="flex flex-auto justify-between items-center h-55rpx">
<view class="time-wrap" :class="{ left: data.buttons?.length }" @tap="openTimePicker">
<view class="time flex items-center" :class="{ readonly: data.readonly }">{{ timeText }}</view>
<Icon icon="ic-baseline-keyboard-arrow-right" size="46" color="#666" v-if="!data.readonly" />
</view>
<view class="buttons" v-if="data.buttons?.length">
<!-- -->
<fui-button
bold
:size="24"
width="130rpx"
height="55rpx"
radius="8rpx"
color="#1890FF"
background="#E7F3FF"
v-for="(button, index) in data.buttons"
:key="`time_bar_button_${index}`"
@tap="button.onClick?.({ index, label: button.label }, getTimeBarValue())"
>
<fui-icon
v-if="button.icon"
:name="button.icon"
:size="28"
style="color: #1890ff !important; margin-right: 6rpx"
/>
<text>{{ button.label }}</text>
<Icon icon="ic-baseline-keyboard-arrow-right" size="32" color="#666" />
</fui-button>
</view>
</view>
<!-- 时间选择组件 -->
<fui-date-picker
start="开始时间"
end="结束时间"
:range="data.time.type === 'range'"
:show="showTimePicker"
:type="timePickerType"
:value="formatTime(data.time?.value?.[0])"
:valueEnd="formatTime(data.time?.value?.[1])"
:minDate="formatTime(data.minTime)"
:maxDate="formatTime(data.maxTime)"
@change="changeTime"
@cancel="showTimePicker = false"
/>
</view>
</template>
<style lang="scss" scoped>
@use '../../common.scss' as *;
.wrap {
@include animate();
position: absolute;
top: 0;
left: 0;
// 层级需要比其他 widget 高,否则 picker 会被遮挡
z-index: v-bind(zIndex);
background-color: white;
}
.time-bar {
padding: 20rpx;
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
font-size: 28rpx;
// box-shadow: 0 0 15rpx rgba(0, 0, 0, 0.1);
&.center {
justify-content: center;
}
> .label {
display: flex;
align-items: center;
font-size: 28rpx;
color: #333;
.icon {
margin-right: 6rpx;
}
.text {
margin-right: 2rpx;
&.after::after {
content: ':';
}
}
.fui-filter__item {
/* #ifdef H5 */
cursor: pointer;
/* #endif */
background-color: #fff;
margin-right: 6rpx;
}
.fui-filter__icon {
transition: all 0.15s linear;
margin-left: 5rpx;
}
.fui-icon__ani {
transform: rotate(180deg);
}
:deep(.fui-dropdown__menu-list) {
width: 230rpx;
}
}
.time-wrap {
display: flex;
justify-content: space-between;
flex: auto;
&.left {
justify-content: flex-start;
flex: none;
.time {
max-width: 350rpx;
}
}
.time {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #1890ff;
&.readonly {
color: #666;
}
}
}
}
</style>
import type { Dayjs } from 'dayjs'
import { registerWidgetFactory, unpackWidgetInstance } from '../../utils'
import type { TimeBarInstance, TimeBarProps } from './types'
// 组件名称
export const name = 'TimeBarWidget'
/**
* 顶部时间栏组件响应式 Hook
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useTimeBarWidget<T extends TimeBarInstance, P extends TimeBarProps>(
props: P,
): [(instance: T) => void, TimeBarInstance] {
const instanceRef = ref<T>()
const register = registerWidgetFactory(instanceRef, props)
const { get, ...fns } = unpackWidgetInstance(instanceRef, name)
return [
register,
{
...fns,
getTime: () => get()?.getTime(),
setTime: (time: Dayjs[], mode: boolean) => get()?.setTime(time, mode),
getCheckedOption: () => get()?.getCheckedOption(),
setCheckedOption: (index: number) => get()?.setCheckedOption(index),
getTimeBarValue: () => get()?.getTimeBarValue(),
setHourRange: (hourRange: Array<string | number> | string) => get()?.setHourRange(hourRange),
setMinuteRange: (minuteRange: Array<string | number> | string) => get()?.setMinuteRange(minuteRange),
},
]
}
/**
* 格式化时间
* @param time 时间
* @param format 格式化
* @returns 格式化后的时间,如果时间为空则返回空字符串
*/
export function formatTime(time: Dayjs, format = 'YYYY-MM-DD HH:mm:ss') {
return time?.format(format) ?? ''
}
export function isAfter(time: Dayjs, compare: Dayjs) {
return time && compare && time.isAfter(compare)
}
export function isBefore(time: Dayjs, compare: Dayjs) {
return time && compare && time.isBefore(compare)
}
export function isAfterAndEqual(time: Dayjs, compare: Dayjs) {
return time && compare && (time.isAfter(compare) || time.isSame(compare))
}
export function isBeforeAndEqual(time: Dayjs, compare: Dayjs) {
return time && compare && (time.isBefore(compare) || time.isSame(compare))
}
/**
* 根据 type 属性返回对应的格式化字符串
* @param type fui-date-picker 组件的 type 属性
* @returns 根据 type 属性返回对应的格式化字符串
*/
export function getFormatByType(type: number) {
switch (type) {
case 1:
return 'YYYY'
case 2:
return 'YYYY-MM'
case 3:
return 'YYYY-MM-DD'
case 4:
return 'YYYY-MM-DD HH:00:00'
case 5:
return 'YYYY-MM-DD HH:mm:00'
case 6:
return 'YYYY-MM-DD HH:mm:ss'
default:
return 'YYYY-MM-DD HH:mm:ss'
}
}
import type { Dayjs } from 'dayjs'
import type { BasicWidgetInstance, BasicWidgetProps } from '../../utils'
/**
* 对应 first-ui datePicker 组件 type 参数
*/
type FirstUIDatePickerType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8
export interface TimeBarChangeEvent {
value: Dayjs[]
option?: TimeBarLabel['options'][0]
}
export interface TimeBarLabel {
text?: string
breforeIcon?: boolean
afterIcon?: boolean
options?: {
text?: string
value?: string
checked?: boolean
format?: string
timeType?: FirstUIDatePickerType
}[]
onClick?: () => void
onChange?: (e: TimeBarChangeEvent) => void
}
export interface TimeBarTime {
type?: 'single' | 'range'
timeType?: FirstUIDatePickerType
format?: string
value?: Dayjs[]
onChange?: (e: TimeBarChangeEvent) => void
hourRange?: Array<number | string> | string
minuteRange?: Array<number | string> | string
}
export interface TimeBarButton {
/**
* 图标,仅支持 fui-icon name
*/
icon?: string
label: string
onClick?: (e: { index: number; label: string }, time: TimeBarChangeEvent) => void
}
export interface TimeBarProps extends BasicWidgetProps {
/**
* 是否只读
* @default false
*/
readonly?: boolean
/**
* 对齐方式
* @default left
*/
align?: 'left' | 'center' | 'right'
/**
* 标题
* @default { text: '时间' }
*/
label?: TimeBarLabel
/**
* 时间
*/
time?: TimeBarTime
/**
* 最小时间
*/
minTime?: Dayjs
/**
* 最大时间
*/
maxTime?: Dayjs
/**
* 扩展按钮
*/
buttons?: TimeBarButton[]
}
export interface TimeBarInstance extends BasicWidgetInstance<TimeBarProps> {
/**
* 获取时间
* @returns 时间
*/
getTime: () => Dayjs[]
/**
* 设置时间
* @param time 时间
*/
setTime: (time: Dayjs[], mode?: boolean) => void
/**
* 获取选中的选项
* @returns 选中的选项
*/
getCheckedOption: () => TimeBarLabel['options'][0]
/**
* 设置选中的选项
* @param index 选项索引
*/
setCheckedOption: (index: number) => void
/**
* 获取时间栏的值
* @returns 时间栏的值
*/
getTimeBarValue: () => TimeBarChangeEvent
/**
* 设置小时可选范围
* @param hourRange
* @returns
*/
setHourRange: (hourRange: Array<number | string> | string) => void
/**
* 设置分钟可选范围
* @param minuteRange
* @returns
*/
setMinuteRange: (minuteRange: Array<number | string> | string) => void
}
export type * from './src/types'
export * from './src/hook'
export { default as ToolBoxWidget } from './src/ToolBox.vue'
/**
* 工具箱组件
*
* 说明: 用于展示地图上的右侧的工具箱,支持的组件有: Select、Filter、Button 之类的实现
*
* 可以应用到的一些模块:(基于已有蓝湖设计稿评估,仅供参考)
* 所有需要用到工具箱的场景,实况、预报,以及部分需要使用 AffixFilter 的场景,也可以考虑并入到工具箱中
*
*/
<!-- 左侧工具盒子控制组件 -->
<script setup lang="ts">
import { createReusableTemplate } from '@vueuse/core'
import type { ToolBoxButton, ToolBoxButtonGroup } from './types'
import { cssAdditionCalc } from '@/components/Map/Widgets/utils'
import { deepMerge } from '@/utils'
const props = defineProps({
// 显示
show: {
type: Boolean,
default: true,
},
// 展开
expand: {
type: Boolean,
default: false,
},
// 是否显示是否展开按钮
showExpandButton: {
type: Boolean,
default: false,
},
// 顶部距离 rpx
top: {
type: Number,
default: 0,
},
// 底部距离 rpx
bottom: {
type: Number,
default: 0,
},
// 底部内边距 rpx (用于避免遮挡)
bottomPadding: {
type: Number,
default: 0,
},
// 工具集
tools: {
type: Array as PropType<ToolBoxButtonGroup[]>,
default: () => [],
},
})
const emits = defineEmits(['register'])
// 定义复用渲染组件
const [DefineTemplate, ReuseTemplate] = createReusableTemplate<{ items: ToolBoxButton[] }>()
const positionTop = computed(() => cssAdditionCalc(95, 30, data.top))
const height = computed(() => cssAdditionCalc(30, data.bottom, data.bottomPadding))
const data = reactive({
show: props.show,
expand: props.expand,
showExpandButton: props.showExpandButton,
top: props.top,
bottom: props.bottom,
bottomPadding: props.bottomPadding,
tools: props.tools,
})
function toggleShow(show?: boolean) {
data.show = show ?? !data.show
}
function toggleExpand(expand?: boolean) {
data.expand = expand ?? !data.expand
}
function setProps(value: Partial<typeof props>) {
deepMerge(data, value)
}
function getProp(key: keyof typeof props) {
return data[key]
}
function onButtonTap(button: ToolBoxButton) {
if (button.disabled) {
return
}
if (button.type === 'button') {
button.handle?.({ type: 'click', name: button.name })
} else if (button.type === 'select') {
// 打开 Select 组件
model.selectPopup.title = button.name
model.selectPopup.options = button.options ?? []
model.selectPopup.checked = button.options?.filter((item) => item.checked).map((item) => item.value) ?? []
model.selectPopup.multiple = button.multiple ?? false
model.selectPopup.show = true
} else if (button.type === 'filter') {
// 打开 Filter 组件
model.filterPopup.title = button.name
model.filterPopup.show = true
}
// 设置当前激活的按钮
model.activeButton = button
}
const zIndex = computed(() => (model.selectPopup.show || model.filterPopup.show ? 200 : 100))
const model = reactive({
activeButton: null as ToolBoxButton | null,
selectPopup: {
show: false,
multiple: false,
title: '',
options: [],
checked: [],
onConfirm: (item: Recordable, close?: boolean) => {
if (model.selectPopup.multiple) {
item.checked = !item.checked
} else {
model.selectPopup.options.forEach((option) => (option.checked = false))
item.checked = true
}
model.activeButton?.handle({
type: 'change',
name: model.activeButton.name,
value: toRaw(
model.selectPopup.options[model.selectPopup.multiple ? 'filter' : 'find'](
(item) => item.checked,
),
),
})
close && model.selectPopup.onClose()
},
onClose: () => (model.selectPopup.show = false),
},
filterPopup: {
show: false,
title: '',
min: '',
max: '',
onConfirm: () => {
model.activeButton?.handle({
type: 'change',
name: model.activeButton.name,
value: [model.filterPopup.min, model.filterPopup.max],
})
model.filterPopup.onClose()
},
onRest: () => {
model.filterPopup.min = ''
model.filterPopup.max = ''
},
onClose: () => (model.filterPopup.show = false),
},
})
emits('register', {
setProps,
getProp,
toggleShow,
toggleExpand,
})
</script>
<template>
<view class="wrap tool-box">
<DefineTemplate v-slot="{ items }">
<view class="button-group flex-center" v-show="data.expand">
<template v-for="(item, index) in items" :key="`tool_box_button_${index}_${item.name}`">
<view class="button flex-center" :class="{ disabled: item.disabled }" @tap="onButtonTap(item)">
<CacheImage class="icon" :src="item.icon" width="40" height="40" background="transparent" />
<view class="text" :style="{ color: item.color || '#666' }">{{ item.name }}</view>
<!-- TODO: 过滤的结果展示 -->
<view v-if="item.type === 'filter'" class="result">
<view class="text">{{ item.result }}</view>
</view>
</view>
<fui-divider v-if="index !== items.length - 1" :height="4" />
</template>
</view>
</DefineTemplate>
<view class="top">
<template v-for="tool in data.tools.filter((item) => item.align === 'top')" :key="`group_${tool.key}`">
<ReuseTemplate :items="tool.buttons" />
</template>
</view>
<view class="bottom">
<template v-for="tool in data.tools.filter((item) => item.align === 'bottom')" :key="`group_${tool.key}`">
<ReuseTemplate :items="tool.buttons" />
</template>
<!-- 展开/收起操作按钮 -->
<view
v-if="data.showExpandButton"
class="expand-action flex-center"
:class="{ expand: data.expand }"
@tap="data.expand = !data.expand"
>
<view class="text">{{ data.expand ? '收起' : '展开' }}</view>
<Icon icon="ic-round-keyboard-arrow-up" size="42" color="#1890ff" class="icon" />
</view>
</view>
<!-- 交互组件 -->
<!-- 1. Select Popup -->
<!-- <fui-select
maskClosable
:type="model.selectPopup.multiple ? 'select' : 'list'"
:show="model.selectPopup.show"
:title="`选择${model.selectPopup.title}`"
:options="model.selectPopup.options"
:multiple="model.selectPopup.multiple"
@click="model.selectPopup.onConfirm"
@confirm="model.selectPopup.onConfirm"
@close="model.selectPopup.onClose"
/> -->
<fui-bottom-popup
:show="model.selectPopup.show"
:z-index="900"
@close="model.selectPopup.onClose"
:safeArea="false"
>
<view class="popup-wrap">
<view class="fui-title">{{ `选择${model.selectPopup.title}` }}</view>
<view class="fui-icon__close" @tap="model.selectPopup.onClose">
<fui-icon name="close" :size="48" />
</view>
<scroll-view scroll-y class="fui-scroll__view">
<view class="fui-custom__wrap">
<!-- 多选 -->
<template v-if="model.selectPopup.multiple">
<fui-checkbox-group>
<fui-label
v-for="(item, index) in model.selectPopup.options"
:key="index"
@tap="model.selectPopup.onConfirm(item, false)"
>
<fui-list-cell>
<view class="fui-align__center">
<fui-checkbox v-model:checked="item.checked" :value="item.value" />
<text class="fui-text pl-2">{{ item.text }}</text>
</view>
</fui-list-cell>
</fui-label>
</fui-checkbox-group>
</template>
<!-- 单选 -->
<template v-else>
<!-- -->
<fui-radio-group>
<fui-label
v-for="(item, index) in model.selectPopup.options"
:key="index"
@tap="model.selectPopup.onConfirm(item, true)"
>
<fui-list-cell>
<view class="fui-align__center">
<fui-radio :checked="item.checked" :value="item.value" />
<text class="fui-text pl-2">{{ item.text }}</text>
</view>
</fui-list-cell>
</fui-label>
</fui-radio-group>
</template>
</view>
</scroll-view>
</view>
<!-- 底部安全区 -->
<fui-safe-area />
</fui-bottom-popup>
<!-- 2. 过滤 Popup -->
<fui-bottom-popup
:show="model.filterPopup.show"
:z-index="900"
@close="model.filterPopup.onClose"
:safeArea="false"
>
<view class="popup-wrap">
<view class="fui-title">{{ model.filterPopup.title }}</view>
<view class="fui-icon__close" @tap="model.filterPopup.onClose">
<fui-icon name="close" :size="48" />
</view>
<scroll-view scroll-y class="fui-scroll__view">
<view class="fui-custom__wrap">
<view class="branch">
<view class="title">阈值范围(仅支持数值)</view>
<view class="timeBox">
<!-- -->
<fui-input
type="number"
borderTop
isFillet
inputBorder
maxlength="10"
backgroundColor="#F5F5F5"
placeholder="最小值"
v-model="model.filterPopup.min"
/>
<view class="mark split" />
<fui-input
type="number"
borderTop
isFillet
inputBorder
maxlength="10"
backgroundColor="#F5F5F5"
placeholder="最大值"
v-model="model.filterPopup.max"
/>
</view>
</view>
<view class="signMan">
<fui-button
width="330rpx"
height="86rpx"
radius="86rpx"
text="重置"
background="#FFFFFF"
border-color="#AAAAAA"
color="#AAAAAA"
:margin="['70rpx', '0rpx', '0rpx', '30rpx']"
@click="model.filterPopup.onRest"
/>
<fui-button
width="330rpx"
height="90rpx"
radius="90rpx"
text="确定"
background="#1890FF"
color="#FFFFFF"
:margin="['70rpx', '30rpx', '0rpx', '0rpx']"
@click="model.filterPopup.onConfirm"
/>
</view>
</view>
</scroll-view>
</view>
<!-- 底部安全区 -->
<fui-safe-area />
</fui-bottom-popup>
</view>
</template>
<style lang="scss" scoped>
.wrap {
transition: all 0.35s;
}
.tool-box {
.expand-action {
font-size: 26rpx;
font-weight: bold;
color: #1890ff;
padding: 12rpx 20rpx;
background-color: #fff;
border-radius: 10rpx;
box-shadow: 0 0 15rpx rgba(0, 0, 0, 0.2);
margin-top: 30rpx;
.text {
margin-right: 6rpx;
}
.icon {
transition: all 0.35s;
transform: rotate(0);
}
&.expand {
.icon {
transform: rotate(180deg) !important;
}
}
}
.top {
position: absolute;
top: v-bind(positionTop);
right: 30rpx;
z-index: v-bind(zIndex);
}
.bottom {
transition: all 0.35s;
position: absolute;
right: 30rpx;
bottom: v-bind(height);
z-index: v-bind(zIndex);
display: flex;
flex-direction: column;
align-items: flex-end;
}
.button-group {
flex-direction: column;
box-shadow: 0 0 15rpx rgba(0, 0, 0, 0.2);
background-color: white;
border-radius: 10rpx;
padding: 5rpx 0;
width: 95rpx;
+ .button-group {
margin-top: 30rpx;
}
.button {
flex-direction: column;
font-size: 24rpx;
color: #666;
padding: 10rpx;
min-width: 80rpx;
&.disabled {
filter: grayscale(1);
}
.text {
margin-top: 5rpx;
}
}
}
}
.popup-wrap {
height: 520rpx;
padding-top: 30rpx;
position: relative;
}
.fui-custom__wrap {
width: 100%;
display: flex;
align-items: center;
flex-direction: column;
font-size: 28rpx;
.branch {
margin-top: 30rpx;
width: 690rpx;
display: flex;
flex-direction: column;
.title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.timeBox {
width: 100%;
margin-top: 30rpx;
display: flex;
align-items: center;
justify-content: space-between;
.mark {
width: 20rpx;
height: 4rpx;
background: #ddd;
margin: 0 20rpx;
}
.timeSelect {
width: 260rpx;
height: 80rpx;
padding: 0 30rpx;
background: #f4f5f9;
border-radius: 45rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
.text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.selected {
background: #f3f9ff;
color: #1890ff;
border: 2rpx solid #1890ff;
}
}
.typeBox {
width: 100%;
margin-top: 10rpx;
display: flex;
align-items: center;
flex-wrap: wrap;
.typeSelect {
width: 156rpx;
height: 80rpx;
margin-top: 20rpx;
padding: 0 30rpx;
background: #f4f5f9;
border-radius: 45rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid #f4f5f9;
overflow: hidden;
.text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+ .typeSelect {
margin-left: 20rpx;
}
}
.typeSelected {
background: #f3f9ff;
color: #1890ff;
border: 2rpx solid #1890ff;
}
}
}
.signMan {
width: 100%;
background: #fff;
margin-bottom: 70rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.handleBtn {
border: 1rpx solid #aaa;
border-radius: 86rpx;
}
}
.fui-title {
font-size: 32rpx;
font-weight: bold;
text-align: center;
padding-bottom: 24rpx;
}
.fui-icon__close {
position: absolute;
top: 20rpx;
right: 30rpx;
}
.fui-scroll__view {
width: 100%;
height: 790rpx;
}
</style>
import { getBooleanOrDefault, registerWidgetFactory, unpackWidgetInstance } from '../../utils'
import type { ToolBoxInstance, ToolBoxProps } from './types'
// 组件名称
export const name = 'ToolBoxWidget'
/**
* 左侧工具盒子组件响应式 Hook
* @param props 组件参数
* @returns 组件响应式数据
*/
export function useToolBoxWidget<T extends ToolBoxInstance, P extends ToolBoxProps>(
props: P,
): [(instance: T) => void, ToolBoxInstance] {
const instanceRef = ref<T>()
const register = registerWidgetFactory(instanceRef, props)
const { get, ...fns } = unpackWidgetInstance(instanceRef, name)
return [
register,
{
...fns,
toggleExpand: (expand?: boolean) => get()?.toggleExpand(getBooleanOrDefault(expand)),
},
]
}
import type { BasicWidgetInstance, BasicWidgetProps } from '../../utils'
export interface ToolBoxButtonHandleEvent {
// 事件类型
type: 'click' | 'change'
// 事件名称
name: string
// 事件值
value?: string | string[] | { text: string; value: string } | { text: string; value: string }[]
}
export interface ToolBoxButton {
// 按钮类型
type: 'select' | 'button' | 'filter'
// 按钮名称
name: string
// 按钮图标
icon?: string
// 按钮文字颜色
color?: string
// 按钮是否禁用
disabled?: boolean
// select 类型按钮选项
options?: {
id?: string
// 选项名称
text: string
// 选项值
value: string
// 是否选中
checked?: boolean
// 是否禁用
disabled?: boolean
}[]
// select 类型下是否支持多选
multiple?: boolean
// 按钮事件
handle?: (e: ToolBoxButtonHandleEvent) => void
// 按钮值
value?: string | string[]
// filter 类型的结果
result?: string | string[]
}
export interface ToolBoxButtonGroup {
// 标识
key: string
// 对齐方向
align?: 'top' | 'bottom'
// 按钮列表
buttons: ToolBoxButton[]
}
export interface ToolBoxProps extends BasicWidgetProps {
/**
* 是否展开
* @default true
*/
expand?: boolean
/**
* 是否显示是否展开按钮
* @default true
*/
showExpandButton?: boolean
/**
* 顶部距离 rpx
* @default 0
*/
top?: number
/**
* 底部距离 rpx
* @default 0
*/
bottom?: number
/**
* 底部内边距 rpx
* @default 0
*/
bottomPadding?: number
/**
* 工具集
*/
tools?: ToolBoxButtonGroup[]
}
export interface ToolBoxInstance extends BasicWidgetInstance<ToolBoxProps> {
/**
* 展开或收起工具箱
* @param expand 是否展开
*/
toggleExpand: (expand?: boolean) => void
}
@mixin animate() {
transition: all 0.35s;
}
import { isBoolean } from 'lodash-es'
import { isProdMode } from '@/utils/env'
export interface BasicWidgetProps {
/**
* 是否显示
* @default true
*/
show?: boolean
}
export interface BasicWidgetInstance<T extends BasicWidgetProps = BasicWidgetProps> {
/**
* 设置小部件属性
* @param props 小部件属性
*/
setProps: (props: Partial<T>) => void
/**
* 获取小部件属性
* @param key 属性名称
* @returns 属性值
*/
getProp: <K extends keyof T>(key: K) => T[K]
/**
* 切换显示状态
* @param show 是否显示,强制指定显示状态
*/
toggleShow: (show?: boolean) => void
}
/**
* 获取 Boolean 值或默认值
* @param value Boolean 值
* @param def 默认值
* @returns 值或默认值
*/
export function getBooleanOrDefault(value: any, def = null): boolean {
return isBoolean(value) ? value : def
}
/**
* 注册组件工厂
* @param instanceRef 组件实例 Ref
* @param props 组件属性
* @param callback 回调函数
*/
export function registerWidgetFactory<T extends BasicWidgetInstance<P>, P>(
instanceRef: Ref<Nullable<T>>,
props: P,
callback?: (instance: T) => void,
): (instance: T) => void {
function register(instance: T) {
if (isProdMode()) {
if (instance === unref(instanceRef)) {
return
}
onUnmounted(() => {
instanceRef.value = null
})
}
instanceRef.value = instance
props && instance?.setProps(props)
callback && callback(instance)
}
return register
}
/**
* 获取组件实例
* @param instanceRef 组件实例 Ref
* @param name 组件名称
* @returns 组件实例
*/
export function getWidgetInstance<T extends BasicWidgetInstance>(instanceRef: Ref<Nullable<T>>, name?: string): T {
const instance = unref(instanceRef)
if (!instance) {
console.warn(`The ${name || 'widget'} instance has not been obtained`)
}
return instance as T
}
/**
* 获取基础组件实例函数
* @param instanceRef 组件实例 Ref
* @param name 组件名称
* @returns 组件实例函数
* @example
* ```ts
* const { get, setProps, toggleShow } = unpackWidgetInstance<BasicWidgetInstance, BasicWidgetProps>(instanceRef, 'basic widget')
* ```
*/
export function unpackWidgetInstance<T extends BasicWidgetInstance, P extends BasicWidgetProps>(
instanceRef: Ref<Nullable<T>>,
name?: string,
) {
const get = () => getWidgetInstance(instanceRef, name)
return {
get,
setProps: (props: Partial<P>) => get()?.setProps(props),
// @ts-expect-error
getProp: <K extends keyof P>(key: K) => get()?.getProp(key),
toggleShow: (show?: boolean) => get()?.toggleShow(getBooleanOrDefault(show)),
} as T & { get: () => T }
}
/**
* css calc 加法计算函数
* @param values rpx 数值
* @returns css calc 函数或 rpx 数值
*/
export function cssAdditionCalc(...values: number[]): string {
const result = values
.filter((item) => item)
.map((item) => `${item}rpx`)
.join(' + ')
if (result.includes('+')) {
return `calc(${result})`
} else {
return result
}
}
import type { Dayjs } from 'dayjs'
export interface BasicMapPage {
/**
* 页面 ID,用于区分不同页面的数据
*/
id?: string
/**
* 页面是否完成初始化(默认情况下,地图加载完成,请求到第一轮带有最新时间标识的结果时,标记为初始化完成)
*/
init: boolean
/**
* 页面正在请求的数据源数量,由于 setGeoJSONSourceForRequest 的异步特性,需要记录当前正在请求的数据源数量,当所有数据源请求完成时,隐藏 Loading
*/
requests?: number
/**
* 当前请求到的数据最新时间
*/
latest?: Dayjs
}
...@@ -443,6 +443,30 @@ ...@@ -443,6 +443,30 @@
} }
} }
}, },
{
"path": "pages/nongchang/detail/index",
"style": {
"navigationBarTitleText": "",
"enablePullDownRefresh": false,
"navigationBarBackgroundColor": "#5DB66F",
"navigationBarTextStyle": "white",
"backgroundColorBottom": "#F2F2F2",
"app-plus": {
"titleNView": {
"titleAlign": "left",
"buttons": [
{
"text": "+ 添加基地",
"fontSrc": "/static/uni.ttf",
"color": "#fff",
"fontSize": "26rpx",
"width": "auto"
}
]
}
}
}
},
// === AI 聊天助手 === // === AI 聊天助手 ===
{ {
"path": "pages/common/chat/index", "path": "pages/common/chat/index",
......
<!-- 天气数据 -->
<script setup lang="ts">
//
</script>
<template>
<view class="play-wrap">
<!-- -->
<view class="play-icon">
<image class="w-60 h-60" src="/static/images/codefun/data-play.png" mode="scaleToFill" />
</view>
</view>
</template>
<style scoped lang="scss">
.play-wrap {
width: calc(100% - 20rpx);
margin: 0 auto;
border-radius: 20rpx;
}
</style>
import type { BasicMapPage } from '@/components/Map/types'
export interface Page extends BasicMapPage {
/**
* 接口查询参数
*/
query?: {
/**
* 注入的变量,请求时不会传递,仅用于注入。
* 在 setGeoJSONSourceForRequest 的 handler 中可以实现替换字符串模板的功能。
*/
inject?: Recordable
}
/**
* 过滤条件
*/
filter?: Recordable
}
<!-- 农场详情 -->
<script setup lang="ts">
import * as turf from '@turf/turf'
import type { Page } from './config'
import PlayWidget from './components/PlayWidget.vue'
import {
addDefaultGeoJSONSource,
addDefaultSplotLayer,
addDefaultSymbolLayer,
useMapbox,
} from '@/components/Map/Mapbox/hook'
import { ToolBoxWidget, useToolBoxWidget } from '@/components/Map/Widgets/ToolBox'
// 页面参数
const page = reactive<Page>({
id: 'example-mapbox',
init: false,
requests: 0,
latest: null,
query: {},
})
const model = reactive({
id: '',
name: '',
lonlat: '',
address: '',
description: '',
// 地块、位置信息 GeoJSON
plots: [],
// 设备信息
devices: [],
})
onLoad((options) => {
console.log('页面参数', options)
model.id = options.id
model.name = decodeURIComponent(options.name)
uni.setNavigationBarTitle({
title: decodeURIComponent(options.name),
})
})
// 地图组件
const center: [number, number] = [111.024108, 29.554847]
const [registerMap, map] = useMapbox({
style: { center, zoom: 15 },
onLoaded: (data) => {
console.log('✨✨✨ Map Loaded', data)
// 模拟数据
model.lonlat = '111.024108, 29.554847'
model.address = '湖南省 张家界市 慈利县 三淹桥村 村委会'
model.description = '详情信息说明'
// 渲染地块数据
model.plots = [
turf.polygon(
[
[
[111.0235, 29.5562],
[111.0255, 29.5562],
[111.0255, 29.5558],
[111.0235, 29.5558],
[111.0235, 29.5562],
],
],
{
color: 'yellow',
name: '基地 A',
popup: `{{name}}`,
},
),
turf.polygon(
[
[
[111.0225, 29.5565],
[111.0238, 29.5568],
[111.023, 29.5555],
[111.022, 29.5558],
[111.0225, 29.5565],
],
],
{
color: 'red',
name: '基地 B',
popup: `{{name}}`,
},
),
turf.polygon(
[
[
[111.0258, 29.555],
[111.0262, 29.5542],
[111.0252, 29.554],
[111.0258, 29.555],
],
],
{
color: 'blue',
name: '基地 C',
popup: `{{name}}`,
},
),
]
addDefaultGeoJSONSource(map, `${page.id}-plot`, model.plots)
addDefaultSplotLayer(map, `${page.id}-plot`, {
paint: {
'fill-opacity': 0.2,
'fill-outline-color': ['get', 'color'],
},
})
map.addLayer({
type: 'line',
id: `${page.id}-plot-line`,
source: `${page.id}-plot`,
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': ['get', 'color'],
'line-width': 2,
},
})
// 渲染设备数据
model.devices = [
turf.point([111.024108, 29.554847], {
name: '设备1',
description: '设备1描述',
icon: 'GD',
popup: `{{name}}`,
}),
turf.point([111.023139, 29.55539], {
name: '设备2',
description: '设备2描述',
icon: 'JCD',
popup: `{{name}}`,
}),
turf.point([111.024989, 29.555435], {
name: '设备3',
description: '设备3描述',
icon: 'BZ',
popup: `{{name}}`,
}),
]
addDefaultGeoJSONSource(map, `${page.id}-text`, model.devices)
addDefaultSymbolLayer(map, `${page.id}-text`, {
layout: {
'text-field': '',
'icon-image': ['get', 'icon'],
'icon-size': 1,
},
})
},
onSourceRequestHandle: () => {
page.requests--
if (page.requests === 0) {
Message.hideLoading()
}
},
onSourceRequestErrorHandle: () => {
Message.hideLoading()
},
})
// 左侧工具栏小部件
const [registerToolBoxWidget] = useToolBoxWidget({
show: true,
expand: true,
showExpandButton: false,
top: -90,
tools: [
{
key: 'action',
align: 'top',
buttons: [
{
name: '设备',
color: '#75c849',
icon: '/static/images/codefun/device.active.png',
type: 'button',
handle: () => {
Message.alert('【设备】功能正在努力开发中...', '')
},
},
],
},
{
key: 'action',
align: 'top',
buttons: [
{
name: '降雨',
color: '#75c849',
icon: '/static/images/codefun/rain.png',
type: 'button',
handle: () => {
Message.alert('【降雨】功能正在努力开发中...', '')
},
},
{
name: '温度',
icon: '/static/images/codefun/temp.png',
type: 'button',
handle: () => {
Message.alert('【温度】功能正在努力开发中...', '')
},
},
{
name: '强对流',
icon: '/static/images/codefun/severe.png',
type: 'button',
handle: () => {
Message.alert('【强对流】功能正在努力开发中...', '')
},
},
{
name: '大风',
icon: '/static/images/codefun/wind.png',
type: 'button',
handle: () => {
Message.alert('【大风】功能正在努力开发中...', '')
},
},
{
name: '其他',
icon: '/static/images/codefun/other.png',
type: 'button',
handle: () => {
Message.alert('【其他】功能正在努力开发中...', '')
},
},
],
},
],
})
</script>
<template>
<view class="page h-100vh bg-white">
<!-- 地图组件 -->
<!-- <view class="h-730 overflow-hidden map-box"> -->
<view class="h-full overflow-hidden map-box">
<!-- 地图组件 -->
<Mapbox @register="registerMap" />
</view>
<!-- 地图上方所有小部件 -->
<view class="widgets">
<!-- 左侧工具栏小部件 -->
<ToolBoxWidget @register="registerToolBoxWidget" />
<!-- 播放数据小部件 -->
<view class="play-wrap hidden">
<PlayWidget />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
//
.page {
.map-box {
// #ifdef APP-PLUS
position: relative;
top: 0;
// #endif
// #ifdef H5
position: relative;
top: 0;
// #endif
}
}
</style>
...@@ -51,6 +51,7 @@ ...@@ -51,6 +51,7 @@
// 农场信息 // 农场信息
farmInfo: { farmInfo: {
id: 1,
image: '/static/images/codefun/89a5811c35ff41e6a039c04e77d7380a.png', image: '/static/images/codefun/89a5811c35ff41e6a039c04e77d7380a.png',
name: '张家界水稻农场', name: '张家界水稻农场',
area: '32.5亩', area: '32.5亩',
...@@ -456,6 +457,10 @@ ...@@ -456,6 +457,10 @@
} }
} }
function toDetail(item: Recordable) {
Navigate.to(`/pages/nongchang/detail/index?id=${item.id}&name=${encodeURIComponent(item.name)}`)
}
onHide(() => { onHide(() => {
// 停止所有其他视频的播放(只暂停,不重置位置) // 停止所有其他视频的播放(只暂停,不重置位置)
pageData.agricultureClass.videoList.forEach((_, index) => { pageData.agricultureClass.videoList.forEach((_, index) => {
...@@ -496,7 +501,7 @@ ...@@ -496,7 +501,7 @@
</view> </view>
</view> </view>
<view class="codefun-flex-col codefun-relative group_6"> <view class="codefun-flex-col codefun-relative group_6">
<view class="codefun-flex-row section_4 flex-row"> <view class="codefun-flex-row section_4 flex-row" @click="toDetail(pageData.farmInfo)">
<view class="w-200rpx h-200rpx"> <view class="w-200rpx h-200rpx">
<image class="codefun-self-center image_7 w-full h-full" :src="pageData.farmInfo.image" /> <image class="codefun-self-center image_7 w-full h-full" :src="pageData.farmInfo.image" />
</view> </view>
......
This source diff could not be displayed because it is too large. You can view the blob instead.
.mapboxgl-map{-webkit-tap-highlight-color:rgb(0 0 0/0);font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative}.mapboxgl-canvas{left:0;position:absolute;top:0}.mapboxgl-map:-webkit-full-screen{height:100%;width:100%}.mapboxgl-canary{background-color:salmon}.mapboxgl-canvas-container.mapboxgl-interactive,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass{cursor:grab;-webkit-user-select:none;user-select:none}.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer{cursor:pointer}.mapboxgl-canvas-container.mapboxgl-interactive:active,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active{cursor:grabbing}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:pinch-zoom}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:none}.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.mapboxgl-ctrl-top-left,.mapboxgl-ctrl-top-right{pointer-events:none;position:absolute;z-index:2}.mapboxgl-ctrl-top-left{left:0;top:0}.mapboxgl-ctrl-top-right{right:0;top:0}.mapboxgl-ctrl-bottom-left{bottom:0;left:0}.mapboxgl-ctrl-bottom-right{bottom:0;right:0}.mapboxgl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.mapboxgl-ctrl-top-left .mapboxgl-ctrl{float:left;margin:10px 0 0 10px}.mapboxgl-ctrl-top-right .mapboxgl-ctrl{float:right;margin:10px 10px 0 0}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl{float:left;margin:0 0 10px 10px}.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl{float:right;margin:0 10px 10px 0}.mapboxgl-ctrl-group{background:#fff;border-radius:4px}.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (-ms-high-contrast:active){.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.mapboxgl-ctrl-group button{background-color:transparent;border:0;box-sizing:border-box;cursor:pointer;display:block;height:29px;outline:none;overflow:hidden;padding:0;width:29px}.mapboxgl-ctrl-group button+button{border-top:1px solid #ddd}.mapboxgl-ctrl button .mapboxgl-ctrl-icon{background-position:50%;background-repeat:no-repeat;display:block;height:100%;width:100%}@media (-ms-high-contrast:active){.mapboxgl-ctrl-icon{background-color:transparent}.mapboxgl-ctrl-group button+button{border-top:1px solid ButtonText}}.mapboxgl-ctrl-attrib-button:focus,.mapboxgl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl button:disabled{cursor:not-allowed}.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon{opacity:.25}.mapboxgl-ctrl-group button:first-child{border-radius:4px 4px 0 0}.mapboxgl-ctrl-group button:last-child{border-radius:0 0 4px 4px}.mapboxgl-ctrl-group button:only-child{border-radius:inherit}.mapboxgl-ctrl button:not(:disabled):hover{background-color:rgb(0 0 0/5%)}.mapboxgl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23333' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23000' viewBox='0 0 29 29'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath id='south' d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting .mapboxgl-ctrl-icon{animation:mapboxgl-spin 2s linear infinite}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2' display='none'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23000'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' display='none'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 0 0 5.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 0 0 9 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 0 0 3.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0 0 11 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 1 1 0-7z'/%3E%3Ccircle id='dot' cx='10' cy='10' r='2'/%3E%3Cpath id='stroke' d='M14 5l1 1-9 9-1-1 9-9z' fill='%23f00'/%3E%3C/svg%3E")}}@keyframes mapboxgl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='0.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='0.9' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E");background-repeat:no-repeat;cursor:pointer;display:block;height:23px;margin:0 0 -4px -4px;overflow:hidden;width:88px}a.mapboxgl-ctrl-logo.mapboxgl-compact{width:23px}@media (-ms-high-contrast:active){a.mapboxgl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23fff'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd' viewBox='0 0 88 23'%3E%3Cdefs%3E%3Cpath id='logo' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='text' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='clip'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='white'/%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/mask%3E%3Cg id='outline' opacity='1' stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23clip)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23text' mask='url(%23clip)'/%3E%3C/g%3E%3Cg id='fill' opacity='1' fill='%23000'%3E%3Cuse xlink:href='%23logo'/%3E%3Cuse xlink:href='%23text'/%3E%3C/g%3E%3C/svg%3E")}}.mapboxgl-ctrl.mapboxgl-ctrl-attrib{background-color:hsla(0,0%,100%,.5);margin:0;padding:0 5px}@media screen{.mapboxgl-ctrl-attrib.mapboxgl-compact{background-color:#fff;border-radius:12px;margin:10px;min-height:20px;padding:2px 24px 2px 0;position:relative}.mapboxgl-ctrl-attrib.mapboxgl-compact-show{padding:2px 28px 2px 8px;visibility:visible}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show,.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact-show{border-radius:12px;padding:2px 8px 2px 28px}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner{display:none}.mapboxgl-ctrl-attrib-button{background-color:hsla(0,0%,100%,.5);background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E");border:0;border-radius:12px;box-sizing:border-box;cursor:pointer;display:none;height:24px;outline:none;position:absolute;right:0;top:0;width:24px}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-top-left .mapboxgl-ctrl-attrib-button{left:0}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-button,.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-inner{display:block}.mapboxgl-ctrl-attrib.mapboxgl-compact-show .mapboxgl-ctrl-attrib-button{background-color:rgb(0 0 0/5%)}.mapboxgl-ctrl-bottom-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;right:0}.mapboxgl-ctrl-top-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{right:0;top:0}.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{left:0;top:0}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;left:0}}@media screen and (-ms-high-contrast:active){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}@media screen and (-ms-high-contrast:black-on-white){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1 0 12 0 6 6 0 1 0-12 0m5-3a1 1 0 1 0 2 0 1 1 0 1 0-2 0m0 3a1 1 0 1 1 2 0v3a1 1 0 1 1-2 0'/%3E%3C/svg%3E")}}.mapboxgl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.mapboxgl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.mapboxgl-ctrl-attrib .mapbox-improve-map{font-weight:700;margin-left:2px}.mapboxgl-attrib-empty{display:none}.mapboxgl-ctrl-scale{background-color:hsla(0,0%,100%,.75);border:2px solid #333;border-top:#333;box-sizing:border-box;color:#333;font-size:10px;padding:0 5px;white-space:nowrap}.mapboxgl-popup{display:flex;left:0;pointer-events:none;position:absolute;top:0;will-change:transform}.mapboxgl-popup-anchor-top,.mapboxgl-popup-anchor-top-left,.mapboxgl-popup-anchor-top-right{flex-direction:column}.mapboxgl-popup-anchor-bottom,.mapboxgl-popup-anchor-bottom-left,.mapboxgl-popup-anchor-bottom-right{flex-direction:column-reverse}.mapboxgl-popup-anchor-left{flex-direction:row}.mapboxgl-popup-anchor-right{flex-direction:row-reverse}.mapboxgl-popup-tip{border:10px solid transparent;height:0;width:0;z-index:1}.mapboxgl-popup-anchor-top .mapboxgl-popup-tip{align-self:center;border-bottom-color:#fff;border-top:none}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom-color:#fff;border-left:none;border-top:none}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom-color:#fff;border-right:none;border-top:none}.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip{align-self:center;border-bottom:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip{align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip{align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.mapboxgl-popup-anchor-left .mapboxgl-popup-tip{align-self:center;border-left:none;border-right-color:#fff}.mapboxgl-popup-anchor-right .mapboxgl-popup-tip{align-self:center;border-left-color:#fff;border-right:none}.mapboxgl-popup-close-button{background-color:transparent;border:0;border-radius:0 3px 0 0;cursor:pointer;position:absolute;right:0;top:0}.mapboxgl-popup-close-button:hover{background-color:rgb(0 0 0/5%)}.mapboxgl-popup-content{background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:10px 10px 15px;pointer-events:auto;position:relative}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content{border-top-left-radius:0}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content{border-top-right-radius:0}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content{border-bottom-left-radius:0}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content{border-bottom-right-radius:0}.mapboxgl-popup-track-pointer{display:none}.mapboxgl-popup-track-pointer *{pointer-events:none;user-select:none}.mapboxgl-map:hover .mapboxgl-popup-track-pointer{display:flex}.mapboxgl-map:active .mapboxgl-popup-track-pointer{display:none}.mapboxgl-marker{left:0;opacity:1;position:absolute;top:0;transition:opacity .2s;will-change:transform}.mapboxgl-user-location-dot,.mapboxgl-user-location-dot:before{background-color:#1da1f2;border-radius:50%;height:15px;width:15px}.mapboxgl-user-location-dot:before{animation:mapboxgl-user-location-dot-pulse 2s infinite;content:"";position:absolute}.mapboxgl-user-location-dot:after{border:2px solid #fff;border-radius:50%;box-shadow:0 0 3px rgba(0,0,0,.35);box-sizing:border-box;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading{height:0;width:0}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after,.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-bottom:7.5px solid #4aa1eb;content:"";position:absolute}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:before{border-left:7.5px solid transparent;transform:translateY(-28px) skewY(-20deg)}.mapboxgl-user-location-show-heading .mapboxgl-user-location-heading:after{border-right:7.5px solid transparent;transform:translate(7.5px,-28px) skewY(20deg)}@keyframes mapboxgl-user-location-dot-pulse{0%{opacity:1;transform:scale(1)}70%{opacity:0;transform:scale(3)}to{opacity:0;transform:scale(1)}}.mapboxgl-user-location-dot-stale{background-color:#aaa}.mapboxgl-user-location-dot-stale:after{display:none}.mapboxgl-user-location-accuracy-circle{background-color:#1da1f233;border-radius:100%;height:1px;width:1px}.mapboxgl-crosshair,.mapboxgl-crosshair .mapboxgl-interactive,.mapboxgl-crosshair .mapboxgl-interactive:active{cursor:crosshair}.mapboxgl-boxzoom{background:#fff;border:2px dotted #202020;height:0;left:0;opacity:.5;position:absolute;top:0;width:0}@media print{.mapbox-improve-map{display:none}}.mapboxgl-scroll-zoom-blocker,.mapboxgl-touch-pan-blocker{align-items:center;background:rgba(0,0,0,.7);color:#fff;display:flex;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif;height:100%;justify-content:center;left:0;opacity:0;pointer-events:none;position:absolute;text-align:center;top:0;transition:opacity .75s ease-in-out;transition-delay:1s;width:100%}.mapboxgl-scroll-zoom-blocker-show,.mapboxgl-touch-pan-blocker-show{opacity:1;transition:opacity .1s ease-in-out}.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page,.mapboxgl-canvas-container.mapboxgl-touch-pan-blocker-override.mapboxgl-scrollable-page .mapboxgl-canvas{touch-action:pan-x pan-y}
\ No newline at end of file
.mapboxgl-popup-tip {
position: relative;
}
.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
left: -1px;
}
.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
right: -1px;
}
.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
bottom: -1px;
}
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
top: -1px;
}
.mapboxgl-popup-content {
box-shadow: 0 1px 10px #999 !important;
z-index: 0;
}
.mapboxgl-popup-content .mapboxgl-custom-popup {
position: relative;
}
.mapboxgl-custom-popup .popup-title {
font-weight: bold;
}
.mapboxgl-ctrl-logo {
display: none !important;
}
.mapboxgl-ctrl-bottom-left {
display: flex;
bottom: 0;
left: 2px;
}
.mapboxgl-ctrl-lnglat {
background: rgb(0 0 0 / 40%);
color: white;
line-height: 2em;
padding: 0 10px;
box-shadow: 0 0 0 2px rgba(0,0,0,.1);
border-radius: 4px;
}
.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon {
background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="24" height="24" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16"%3E%3Cpath fill="currentColor" d="M3.75 3a.75.75 0 0 0-.75.75V5.5a.5.5 0 0 1-1 0V3.75C2 2.784 2.784 2 3.75 2H5.5a.5.5 0 0 1 0 1H3.75ZM10 2.5a.5.5 0 0 1 .5-.5h1.75c.966 0 1.75.784 1.75 1.75V5.5a.5.5 0 0 1-1 0V3.75a.75.75 0 0 0-.75-.75H10.5a.5.5 0 0 1-.5-.5ZM2.5 10a.5.5 0 0 1 .5.5v1.75c0 .414.336.75.75.75H5.5a.5.5 0 0 1 0 1H3.75A1.75 1.75 0 0 1 2 12.25V10.5a.5.5 0 0 1 .5-.5Zm11 0a.5.5 0 0 1 .5.5v1.75A1.75 1.75 0 0 1 12.25 14H10.5a.5.5 0 0 1 0-1h1.75a.75.75 0 0 0 .75-.75V10.5a.5.5 0 0 1 .5-.5Z"%2F%3E%3C%2Fsvg%3E') !important;
}
.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon {
background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="24" height="24" preserveAspectRatio="xMidYMid meet" viewBox="0 0 16 16"%3E%3Cpath fill="currentColor" d="M11 4a1 1 0 0 0 1 1h1.5a.5.5 0 0 1 0 1H12a2 2 0 0 1-2-2V2.5a.5.5 0 0 1 1 0V4Zm0 8a1 1 0 0 1 1-1h1.5a.5.5 0 0 0 0-1H12a2 2 0 0 0-2 2v1.5a.5.5 0 0 0 1 0V12Zm-7-1a1 1 0 0 1 1 1v1.5a.5.5 0 0 0 1 0V12a2 2 0 0 0-2-2H2.5a.5.5 0 0 0 0 1H4Zm1-7a1 1 0 0 1-1 1H2.5a.5.5 0 0 0 0 1H4a2 2 0 0 0 2-2V2.5a.5.5 0 0 0-1 0V4Z"%2F%3E%3C%2Fsvg%3E') !important;
}
.mapboxgl-ctrl .mapboxgl-ctrl-reset-button .mapboxgl-ctrl-icon {
background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="20" height="20" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"%3E%3Cpath fill="currentColor" d="M12 16c1.671 0 3-1.331 3-3s-1.329-3-3-3s-3 1.331-3 3s1.329 3 3 3z"%2F%3E%3Cpath fill="currentColor" d="M20.817 11.186a8.94 8.94 0 0 0-1.355-3.219a9.053 9.053 0 0 0-2.43-2.43a8.95 8.95 0 0 0-3.219-1.355a9.028 9.028 0 0 0-1.838-.18V2L8 5l3.975 3V6.002c.484-.002.968.044 1.435.14a6.961 6.961 0 0 1 2.502 1.053a7.005 7.005 0 0 1 1.892 1.892A6.967 6.967 0 0 1 19 13a7.032 7.032 0 0 1-.55 2.725a7.11 7.11 0 0 1-.644 1.188a7.2 7.2 0 0 1-.858 1.039a7.028 7.028 0 0 1-3.536 1.907a7.13 7.13 0 0 1-2.822 0a6.961 6.961 0 0 1-2.503-1.054a7.002 7.002 0 0 1-1.89-1.89A6.996 6.996 0 0 1 5 13H3a9.02 9.02 0 0 0 1.539 5.034a9.096 9.096 0 0 0 2.428 2.428A8.95 8.95 0 0 0 12 22a9.09 9.09 0 0 0 1.814-.183a9.014 9.014 0 0 0 3.218-1.355a8.886 8.886 0 0 0 1.331-1.099a9.228 9.228 0 0 0 1.1-1.332A8.952 8.952 0 0 0 21 13a9.09 9.09 0 0 0-.183-1.814z"%2F%3E%3C%2Fsvg%3E') !important;
}
.mapboxgl-ctrl .mapboxgl-ctrl-layer-button .mapboxgl-ctrl-icon {
background-image: url('data:image/svg+xml,%3Csvg xmlns="http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg" width="24" height="24" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"%3E%3Cpath fill="currentColor" d="m13.387 3.425l6.365 4.243a1 1 0 0 1 0 1.664l-6.365 4.244a2.5 2.5 0 0 1-2.774 0L4.248 9.332a1 1 0 0 1 0-1.664l6.365-4.243a2.5 2.5 0 0 1 2.774 0Zm6.639 8.767a2.002 2.002 0 0 1-.577.598l-6.05 4.084a2.5 2.5 0 0 1-2.798 0l-6.05-4.084a2 2 0 0 1-.779-2.29l6.841 4.56a2.5 2.5 0 0 0 2.613.098l.16-.098l6.841-4.56a1.996 1.996 0 0 1-.201 1.692Zm0 3.25a2.002 2.002 0 0 1-.577.598l-6.05 4.084a2.5 2.5 0 0 1-2.798 0l-6.05-4.084a2 2 0 0 1-.779-2.29l6.841 4.56a2.5 2.5 0 0 0 2.613.098l.16-.098l6.841-4.56a1.996 1.996 0 0 1-.201 1.692Z"%2F%3E%3C%2Fsvg%3E') !important;
}
.mapboxgl-ctrl button.-active svg {
color: #4264fb;
}
.mapboxgl-ctrl-layer-button {
z-index: 1;
}
.mapboxgl-ctrl-layer-wrap {
z-index: 100;
display: flex;
justify-items: center;
align-items: center;
position: absolute;
top: 0;
left: -220px;
visibility: hidden;
padding: 10px;
border-radius: 3px;
box-shadow: 0 0 3px #333;
background-color: #fff;
}
.mapboxgl-ctrl-layer {
width: 60px;
height: 60px;
display: flex;
justify-content: center;
border: 2px solid #fff;
position: relative;
}
.mapboxgl-ctrl-layer.active {
border-color: #3385ff;
}
.mapboxgl-ctrl-layer img {
width: 100%;
height: 100%;
}
.mapboxgl-ctrl-layer span {
display: inline-flex;
justify-content: center;
background-color: rgb(0 0 0 / 60%);
color: white;
position: absolute;
bottom: 0;
width: 100%;
}
.mapboxgl-ctrl-layer-button.-active .mapboxgl-ctrl-layer-wrap {
visibility: visible;
}
.mapboxgl-ctrl-bottom-left,
.mapboxgl-ctrl-bottom-right {
padding-bottom: 0;
/* stylelint-disable-next-line function-no-unknown */
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
transition: all 0.35s;
}
.mapboxgl-ctrl-attrib-inner {
display: inline-block !important;
vertical-align: middle;
line-height: 18px;
font-size: 12px;
font-family: -apple-system-font, BlinkMacSystemFont, 'Droid Sans', 'Noto Sans', 'PingFang SC', 'Heiti SC',
'Helvetica Neue', Helvetica, Arial, 'Microsoft YaHei', SimSun, sans-serif;
color: rgba(0, 0, 0, 0.75);
text-decoration: none;
}
.mapboxgl-ctrl-attrib.mapboxgl-compact {
padding-right: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.mapboxgl-ctrl-attrib-button {
position: initial;
}
/* Custom style */
.mapboxgl-popup-close-button {
display: none;
}
.mapboxgl-popup-content {
box-shadow: 0 1px 25px rgba(0, 0, 0, 0.2);
}
.mapboxgl-popup-content .custom-popup {
font-family: '微软雅黑';
position: relative;
}
.mapboxgl-popup-content .popup-row {
display: flex;
align-items: flex-start;
}
.mapboxgl-popup-content .popup-row.timing {
justify-content: center;
padding-top: 6px;
}
.mapboxgl-popup-content .popup-row.timing button {
background: #1890ff;
border: none;
padding: 4px 8px;
color: #fff;
border-radius: 5px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.mapboxgl-popup-content .popup-row.timing button .icon {
width: 14px;
height: 14px;
margin-right: 5px;
display: inline-block;
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00IDloNHYxMUg0em0xMiA0aDR2N2gtNHptLTYtOWg0djE2aC00eiIvPjwvc3ZnPg==');
}
.mapboxgl-popup-content .popup-chart-icon {
width: 14px;
height: 14px;
display: inline-block;
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABrUlEQVQ4T2NkwAESspK2C0oIaTIw/P//4t7zh8sXLHPAppQRlwH5jcW3TLzNVUHypzYcvTS5ZaI+7Q0ozAwsVZZmyWRmZmJ6+4NdSNLVjZeNg414F9QWBW9oTBX2Bzn15LXvDLu+ajEoaSmTb8C2D2oMavrqDGfWHbqt/uLJtL///v9advPV/OPHj3+HhQdKIKK7AGbA7VV7Gfx/v2X49o+BYcOLn/N7V25OIsmAGyv2MuT/e8UgxMfBUHnj28ruFRsjSDYg798rBmGaGNDe3hLNysyS9/ffX7H3r+6LtKUy8cBiARYGIC/AXFDylO2LpLLam48fP7159PjRNMauzva7jvY2SidOnWG4f/cag4b8H4Z///8xvHjzk+HlLz4GIV4ehjf3njIYcbMwgNLEpR+sDEqamgw2VhYM69ZvusaYmpr6NcDPi4uJCWeqxpra//z5y7Bx87avjIWFhQ9UVVXlf/z4gStbYBXn5ORkuHjx4guQAddNTEw0QKq+fv3KwMrKCqY5ODgY7ty5g9NQkPyjR4/ugbxQJCAgkPj3719eUpzw+/fvn1++fFkAALdq+Ka65YhZAAAAAElFTkSuQmCC');
}
.mapboxgl-popup-content .popup-title {
font-weight: bold;
min-width: 3em;
}
.mapboxgl-popup-content .popup-title sub {
font-size: 10px;
font-weight: 100;
}
.mapboxgl-ctrl-attrib .mapboxgl-ctrl-attrib-inner {
color: #777;
display: inline !important;
}
.mapboxgl-ctrl-attrib {
padding: 2px 4px 2px 24px !important;
border-radius: 12px 3px 3px 12px !important;
}
This source diff could not be displayed because it is too large. You can view the blob instead.
# 删除 Mapbox-GL-JS 的 Token 令牌校验,使用完整的离线版本
[参考链接](https://blog.csdn.net/Sakura1998gis/article/details/131707234)
如果升级了 js sdk,通过编辑 mapbox-gl.js 文件,搜索并修改以下代码:
```diff
- `_skuToken,this._requestManager._customAccessToken,(t=>{if(t&&(t.message===e.AUTH_ERR_MSG||401===t.status)`
+ `_skuToken,this._requestManager._customAccessToken,(t=>{if(t&&(false)`
```
export function appendStylesheet(id: string, href: string) {
return new Promise((resolve, reject) => {
const element = document.getElementById(id) as HTMLLinkElement
if (element && element.href === href) {
resolve(0)
return
}
const link = document.createElement('link')
link.id = id
link.rel = 'stylesheet'
link.href = href
link.onload = resolve
link.onerror = link.onabort = reject
document.head.appendChild(link)
})
}
export function appendScript(id: string, src: string) {
return new Promise((resolve, reject) => {
const element = document.getElementById(id) as HTMLScriptElement
if (element && element.src === src) {
resolve(0)
return
}
const script = document.createElement('script')
script.id = id
script.src = src
script.onload = resolve
script.onerror = script.onabort = reject
document.head.appendChild(script)
})
}
export function removeChild(id: string) {
const child = document.getElementById(id)
if (child) {
child.parentNode.removeChild(child)
}
}
...@@ -7,6 +7,8 @@ export {} ...@@ -7,6 +7,8 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AffixFilter: typeof import('./../src/components/Map/Widgets/AffixFilter/src/AffixFilter.vue')['default']
BottomBar: typeof import('./../src/components/Map/Widgets/BottomBar/src/BottomBar.vue')['default']
CacheImage: typeof import('./../src/components/CacheImage/index.vue')['default'] CacheImage: typeof import('./../src/components/CacheImage/index.vue')['default']
Empty: typeof import('./../src/components/Empty/index.vue')['default'] Empty: typeof import('./../src/components/Empty/index.vue')['default']
FDragItem: typeof import('./../src/components/FirstUI/fui-drag/f-drag-item.vue')['default'] FDragItem: typeof import('./../src/components/FirstUI/fui-drag/f-drag-item.vue')['default']
...@@ -145,6 +147,13 @@ declare module 'vue' { ...@@ -145,6 +147,13 @@ declare module 'vue' {
FuiWingBlank: typeof import('./../src/components/FirstUI/fui-wing-blank/fui-wing-blank.vue')['default'] FuiWingBlank: typeof import('./../src/components/FirstUI/fui-wing-blank/fui-wing-blank.vue')['default']
Icon: typeof import('./../src/components/Icon/index.vue')['default'] Icon: typeof import('./../src/components/Icon/index.vue')['default']
IframeVideo: typeof import('./../src/components/IframeVideo/index.vue')['default'] IframeVideo: typeof import('./../src/components/IframeVideo/index.vue')['default']
Legend: typeof import('./../src/components/Map/Widgets/Legend/src/Legend.vue')['default']
Mapbox: typeof import('./../src/components/Map/Mapbox/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Switch: typeof import('./../src/components/Map/Widgets/Switch/src/Switch.vue')['default']
ThumbnailPreview: typeof import('./../src/components/ThumbnailPreview/index.vue')['default'] ThumbnailPreview: typeof import('./../src/components/ThumbnailPreview/index.vue')['default']
TimeBar: typeof import('./../src/components/Map/Widgets/TimeBar/src/TimeBar.vue')['default']
ToolBox: typeof import('./../src/components/Map/Widgets/ToolBox/src/ToolBox.vue')['default']
} }
} }
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论