Created
November 25, 2023 09:59
-
-
Save Fujihai/d5ea6065ef2c9eaa0e20386c8bfe03c6 to your computer and use it in GitHub Desktop.
在 Vue2 中基于 maptalks 封装 2D 地图
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<template> | |
<div class="map-wrapper"> | |
<div class="drill-info-container"> | |
<div> | |
<span v-for="(area, idx) in drilledHistory" :key="'drilledArea' + idx">{{ `${area.name}` + `${idx === drilledHistory.length - 1 ? '' : '->'}` }}</span> | |
</div> | |
<span class="back-btn" @click="backLastDrilledArea" v-if="drilledHistory.length > 0">返回</span> | |
</div> | |
<div id="map" class="container"></div> | |
<div class="legend-container"> | |
<div class="legend-title">人口密度(人/平方千米)</div> | |
<div class="legend-density-bar" :style="{ background: `linear-gradient(to right, ${legendRangeColor.min}, ${legendRangeColor.max})`, width: `${this.popDentList.length * 50 || 700}px` }"></div> | |
<div class="legend-range"> | |
<span>{{ legendRangeValue.min }}</span> | |
<span>{{ legendRangeValue.max }}</span> | |
</div> | |
</div> | |
</div> | |
</template> | |
<script> | |
import 'maptalks/dist/maptalks.css' | |
import * as maptalks from 'maptalks' | |
import { shenzhenArea } from './shenzhen-area' | |
export default { | |
name: 'ShenzhenMap', | |
data () { | |
return { | |
map: null, | |
/** | |
* 当前地图显示层级,依次为区级、街道和社区 | |
* @typedef {('region'|'street'|'community')} | |
*/ | |
areaLevel: 'region', | |
// 区域层级 | |
areaLevelList: Object.freeze(['region', 'street', 'community']), | |
// 地图多边形图层 | |
areaPolygons: [], | |
// 区域块图层 | |
polygonsLayer: null, | |
// 指标显示窗体 | |
areaInfoWindow: null, | |
// 人口密度色块图 | |
popDentList: [], | |
// 人口密度区域色阶 | |
heatmapPolygonColorList: Object.freeze(['#82c2f6', '#7cb8ea', '#75aede', '#6fa5d2', '#689bc6', '#6291ba', '#5b87ae', '#557da2', '#4e7496', '#486a8a', '#41607e', '#3b5672', '#344c66', '#2e425a', '#27394e', '#212f42', '#1a2536']), | |
// 已下钻层级记录,用于下钻及返回处理 | |
drilledHistory: [] | |
} | |
}, | |
props: { | |
infoList: { | |
type: [], | |
default: () => { | |
return [] | |
} | |
}, | |
areaList: { | |
type: [], | |
default: () => { | |
return [] | |
} | |
}, | |
year: { | |
type: Number, | |
default: 2020 | |
} | |
}, | |
watch: { | |
infoList: { | |
immediate: true, | |
deep: true, | |
handler (newList, oldList) { | |
if (!this._.isEqual(newList, oldList)) { | |
this.genPopDentColorRuleList(newList) | |
} | |
} | |
}, | |
areaList: { | |
immediate: true, | |
deep: true, | |
handler (newList, oldList) { | |
if (!this._.isEqual(newList, oldList)) { | |
const _len = this.drilledHistory.length | |
if (_len > 0) { | |
this.addDrilledAreaPolygons() | |
// 处理下钻缩放 & 中心点控制 | |
let _zoom = 2 | |
if (this.areaLevel === 'community') { | |
_zoom = 6 | |
} | |
if (this.map) { | |
this.map.setZoom(_zoom) | |
this.map.setCenter(this.drilledHistory[_len - 1].center) | |
} | |
} else { | |
if (newList.length > 0) { | |
this.initShenzhenPolygons() | |
} | |
} | |
} | |
} | |
}, | |
year: function (val) { | |
this.onPageSwitch() | |
}, | |
$route (to, from) { | |
this.onPageSwitch() | |
} | |
}, | |
computed: { | |
legendRangeColor () { | |
const _len = this.popDentList.length | |
const _hmLen = this.heatmapPolygonColorList.length | |
if (_len > 1) { | |
return { | |
min: this.heatmapPolygonColorList[0], | |
max: this.heatmapPolygonColorList[_len - 1] | |
} | |
} | |
return { | |
min: this.heatmapPolygonColorList[0], | |
max: this.heatmapPolygonColorList[_hmLen - 1] | |
} | |
}, | |
legendRangeValue () { | |
const _len = this.popDentList.length | |
if (_len > 0) { | |
return { | |
min: this.popDentList[0].start || 0, | |
max: this.popDentList[_len - 1].end || '-' | |
} | |
} | |
return { | |
min: '--', | |
max: '--' | |
} | |
} | |
}, | |
methods: { | |
/** | |
* 页面切换后需要重置 | |
*/ | |
onPageSwitch () { | |
this.clearAreaPolygonLayer() | |
this.resetMap() | |
this.initShenzhenPolygons() | |
}, | |
/** | |
* 处理 areaLevel 变量 | |
*/ | |
rollbackAreaLevel () { | |
if (this.drilledHistory.length === 3) { | |
this.areaLevel = 'street' | |
} else if (this.drilledHistory.length === 2) { | |
this.areaLevel = 'region' | |
} else if (this.drilledHistory.length === 1) { | |
this.$emit('drillend') | |
this.resetMap() | |
this.initShenzhenPolygons() | |
} | |
}, | |
/** | |
* 下钻返回处理 | |
*/ | |
backLastDrilledArea () { | |
this.hideAreaInfoWindow() | |
this.rollbackAreaLevel() | |
// 返回当前层级 | |
if (this.drilledHistory.length > 1) { | |
this.drilledHistory.pop() | |
} | |
// 再返回上一层,重新下钻 | |
if (this.drilledHistory.length > 0) { | |
const _lastDrilledArea = this.drilledHistory.pop() | |
this.onAreaDblClick(_lastDrilledArea, true) | |
} | |
}, | |
/** | |
* 重置地图 | |
*/ | |
resetMap () { | |
// 重置相关数据 | |
this.areaLevel = 'region' | |
this.popDentList = [] | |
this.drilledHistory = [] | |
// 重置地图中心点和缩放比例 | |
this.map && this.map.setCenter(new maptalks.Coordinate([114.476164, 22.647248])) | |
this.map && this.map.setZoom(0.9) | |
// 隐藏信息窗体 | |
if (this.areaInfoWindow) { | |
this.hideAreaInfoWindow() | |
} | |
}, | |
/** | |
* 根据最大最小值动态生成热力图分层色块规则 | |
* @param { Number } min - 最小值 | |
* @param { Number } max - 最大值 | |
* @param { Number } layers - 区间数 | |
* @todo 现阶段最小值为 0,在 [较人普对比页面] 人口密度可能存在负数,分层规则不适用 | |
*/ | |
getRangeRuleList (min = 0, max = 100, layers = 15) { | |
const rules = [] | |
if (max < layers) { | |
for (let i = 0; i < max; i++) { | |
rules.push({ | |
start: i, | |
end: i + 1 | |
}) | |
} | |
} else if (max > layers) { | |
const base = parseInt(max / layers) | |
for (let i = 0; i < layers; i++) { | |
rules.push({ | |
start: i * base, | |
end: i === layers - 1 ? Math.max((i + 1) * base, max) : (i + 1) * base | |
}) | |
} | |
} | |
return rules | |
}, | |
/** | |
* 获取深圳各区区域块颜色 | |
* @param { Number | String } - adcode - 区码 | |
*/ | |
getRegionPolygonColor (adcode) { | |
if (this.areaList.length === 0) { | |
return 'rgba(135, 196, 240,0.6)' | |
} | |
const foundItem = this.areaList.find(area => Number(area.code) === Number(adcode) || area.code.indexOf(adcode.toString()) > -1) | |
if (!foundItem) { | |
return 'rgba(135, 196, 240,0.6)' | |
} | |
return this.getPolygonColor(foundItem.density) | |
}, | |
/** | |
* 获取区域块的颜色 | |
* @param { Number } - popDent人口密度数值 | |
*/ | |
getPolygonColor (popDent) { | |
if (this.popDentList.length === 0 || !popDent) { | |
return 'transparent' | |
} | |
let color = 'transparent' | |
this.popDentList.forEach((rule, idx) => { | |
if (popDent >= rule.start && popDent <= rule.end) { | |
color = this.heatmapPolygonColorList[idx] | |
} | |
}) | |
return color | |
}, | |
/** | |
* 生成人口密度热力图色块数组 | |
* 1. 按 [POPULATION_DENSITY] 字段升序,取得 min, max 值 | |
* 2. 通过 min, max 值生成区间,区间个数与显示区域个数一致 | |
* @param { Object } - infoList 区块信息数组 | |
*/ | |
genPopDentColorRuleList (infoList = []) { | |
if (infoList.length === 0) { | |
return | |
} | |
// 升序 | |
const _ascCompare = property => { | |
return function (objA, objB) { | |
return Number(objA[property]) - Number(objB[property]) | |
} | |
} | |
const _copyList = this._.cloneDeep(infoList) | |
_copyList.sort(_ascCompare('POPULATION_DENSITY')) | |
const len = _copyList.length | |
if (len < 2) { | |
return | |
} | |
const _max = _copyList[len - 1]['POPULATION_DENSITY'] | |
const _min = _copyList[0]['POPULATION_DENSITY'] | |
this.popDentList = this.getRangeRuleList(_min, _max, len) | |
}, | |
/** | |
* 获取区块显示指标映射,用于区域指标信息窗体显示 | |
* @todo 六、七人普及比较页面指标映射不完全一致,当前未做区分,后续可能需要调整 | |
*/ | |
getAreaInfoMap (year = 2020, isCompare = false) { | |
return [ | |
{ title: '行政区划名称', key: 'REGION_NAME', unit: '' }, | |
{ title: '人口密度', key: 'POPULATION_DENSITY', unit: '人/平方千米' }, | |
{ title: '常住人口', key: 'RESIDENT', unit: '万人' }, | |
{ title: '男性人口', key: 'MALE_RESIDENT', unit: '万人' }, | |
{ title: '女性人口', key: 'FEMALE_RESIDENT', unit: '万人' }, | |
{ title: '常住户籍人口', key: 'RESIDENT_HJ', unit: '万人' }, | |
{ title: '常住非户籍人口', key: 'RESIDENT_FHJ', unit: '万人' }, | |
{ title: '家庭户户数', key: 'HOUSEHOLD', unit: '万户' }, | |
{ title: '家庭户人口', key: 'HOUSEHOLD_POP', unit: '万人' }, | |
{ title: '平均每个家庭户人口', key: 'AVG_HOUSEHOLD_POP', unit: '万人' }, | |
{ title: '集体户户数', key: 'COLLECTIVE_HOUSEHOLD', unit: '万户' }, | |
{ title: '集体户人口', key: 'COLLECT_HOUSEHOLD_POP', unit: '万人' }, | |
{ title: '人户分离人口', key: 'SEPARATE_RESIDENT', unit: '万人' }, | |
{ title: '市辖区内人户分离人口', key: 'CITYAREA_SEPARATE_RESIDENT', unit: '万人' }, | |
{ title: '流动人口', key: 'FLOAT_RESIDENT', unit: '万人' } | |
] | |
}, | |
/** | |
* 绘制下钻区域 | |
*/ | |
addDrilledAreaPolygons () { | |
if (['street', 'community'].includes(this.areaLevel)) { | |
this.areaPolygons = [] | |
const labels = [] | |
this.areaList.forEach(area => { | |
if (area.borders instanceof Array && area.borders.length > 0) { | |
area.borders.forEach(borders => { | |
// 绘制边界 | |
// eslint-disable-next-line camelcase | |
const { border_points, border_center_lat, border_center_lng } = area.borders[0] | |
const { code, name } = area | |
const _polygonCfg = { | |
name, | |
adcode: code, | |
center: [Number(border_center_lng), Number(border_center_lat)] | |
} | |
let polygon = new maptalks.Polygon(JSON.parse(border_points), { | |
visible: true, | |
editable: false, | |
cursor: 'pointer', | |
shadowBlur: 0, | |
shadowColor: 'black', | |
draggable: false, | |
dragShadow: false, | |
drawOnAxis: null, | |
symbol: { | |
'lineColor': 'white', | |
'lineWidth': 1, | |
'polygonFill': this.getPolygonColor(area.density) | |
}, | |
extraCfg: _polygonCfg | |
}) | |
polygon.on('dblclick', this.onAreaDblClick) | |
polygon.on('click', this.onAreaClick) | |
this.areaPolygons.push(polygon) | |
// 文字标记生成 | |
// eslint-disable-next-line camelcase | |
let label = new maptalks.Label(name.replace(/省|市|办事处|居委会/gi, ''), [Number(border_center_lng), Number(border_center_lat)], { | |
'draggable': true, | |
'textSymbol': { | |
'textFaceName': 'monospace', | |
'textFill': '#34495e', | |
'textHaloFill': '#fff', | |
'textHaloRadius': 4, | |
'textSize': 18, | |
'textWeight': 'bold', | |
'textVerticalAlignment': 'top' | |
} | |
}) | |
labels.push(label) | |
}) | |
} | |
}) | |
if (this.polygonsLayer) { | |
this.polygonsLayer.clear() | |
} | |
this.polygonsLayer.addGeometry([...this.areaPolygons, ...labels]).addTo(this.map) | |
} | |
}, | |
/** | |
* 下钻至下一层级 | |
* 七人普支持层级区域下钻,六人普及比较页面只支持下钻至街道 | |
*/ | |
drill2NextAreaLevel () { | |
const levelLimit = this.year === 2020 ? 'community' : 'street' | |
const curLevelIdx = this.areaLevelList.findIndex(area => area === this.areaLevel) | |
if (this.areaLevel !== levelLimit && curLevelIdx < this.areaLevelList.length - 1) { | |
this.areaLevel = this.areaLevelList[curLevelIdx + 1] | |
} | |
}, | |
/** | |
* WMTS 底图服务初始化 | |
*/ | |
initMap () { | |
let resolutions = [0.001373291015625, 6.866455078125E-4, 3.433227539063E-4, 1.716613769531E-4, 8.58306884766E-5, 4.29153442383E-5, 2.14576721191E-5, 1.07288360596E-5, 5.3644180298E-6, 2.6822090149E-6, 1.3411045074E-6] | |
let mapurl = 'http://10.253.102.69/gw/OGC/Map/SZ_VEC_B4490/?LAYER=w_shenzhen&style=&SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&TILEMATRIXSET=EPSG%3A4490&FORMAT=image%2Fpng&TILEMATRIX=EPSG%3A4490%3A{z}&TileRow={y}&TileCol={x}&szvsud-license-key=ndLg0mxz3dbSZiGU5izQ4iGI2cdx/8YGya02K9UgrviZHIR/LZ72feWTJVJsOsYv' | |
this.map = new maptalks.Map('map', { | |
center: [114.477026, 22.615358], | |
zoom: 0.9, | |
minZoom: 0.9, | |
maxZooom: 6, | |
pitch: 0, | |
spatialReference: { | |
projection: 'EPSG:4326', | |
resolutions: resolutions | |
}, | |
attribution: false, | |
baseLayer: new maptalks.TileLayer('base', { | |
tileSize: [256, 256], | |
tileSystem: [1, -1, -180, 90], | |
subdomains: ['1', '2', '3', '4', '5'], | |
urlTemplate: mapurl | |
}) | |
}) | |
}, | |
/** | |
* 绘制深圳各区区域块,边界信息来自于规自局 | |
*/ | |
initShenzhenPolygons () { | |
const { geometries } = shenzhenArea | |
const polygons = [] | |
const labels = [] | |
for (let i = 0, len = geometries.length; i < len; i++) { | |
const { coordinates } = geometries[i] | |
const { name, adcode, center } = geometries[i].properties | |
const _polygonCfg = { | |
name, | |
adcode, | |
center | |
} | |
// 区域块生成 | |
let polygon = new maptalks.MultiPolygon(coordinates, { | |
visible: true, | |
editable: false, | |
cursor: 'pointer', | |
shadowBlur: 0, | |
shadowColor: 'black', | |
draggable: false, | |
dragShadow: false, | |
drawOnAxis: null, | |
symbol: { | |
'lineColor': 'black', | |
'lineWidth': 1, | |
// 'polygonFill': 'rgba(135,196,240,0.6)', | |
// 'polygonOpacity': 0.2, | |
'polygonFill': this.getRegionPolygonColor(adcode) | |
}, | |
extraCfg: _polygonCfg | |
}) | |
polygon.on('dblclick', this.onAreaDblClick) | |
polygon.on('click', this.onAreaClick) | |
polygons.push(polygon) | |
// 文字标记生成 | |
let label = new maptalks.Label(name.replace(/省|市|办事处|居委会/gi, ''), center, { | |
'draggable': true, | |
'textSymbol': { | |
'textFaceName': 'monospace', | |
'textFill': '#34495e', | |
'textHaloFill': '#fff', | |
'textHaloRadius': 4, | |
'textSize': 18, | |
'textWeight': 'bold', | |
'textVerticalAlignment': 'top' | |
} | |
}) | |
labels.push(label) | |
} | |
if (this.polygonsLayer) { | |
this.polygonsLayer.clear() | |
this.map && this.map.removeLayer(this.map.getLayers()) | |
} else { | |
this.polygonsLayer = new maptalks.VectorLayer('vector') | |
} | |
this.polygonsLayer && this.polygonsLayer.addGeometry([...polygons, ...labels]) | |
this.polygonsLayer.addTo(this.map) | |
}, | |
/** | |
* 从区域信息列表中匹配出当前点击区域的信息项 | |
*/ | |
getMatchedAreaInfo (cfg = null) { | |
if (!cfg || this.infoList.length === 0) { | |
return null | |
} | |
return this.infoList.find(info => info.REGION_NAME.replace(/省|市|区|办事处/gi, '') === cfg.name.replace(/省|市|区|办事处/gi, '')) | |
}, | |
/** | |
* 添加下钻区域项 | |
* @param { Object } extraCfg = { adcode: '44030000000', name: '深圳市' } 为绘制区域块的区域信息参数 | |
*/ | |
addDrillHistoryItem (extraCfg = null) { | |
if (!extraCfg) { | |
return | |
} | |
const _found = this.drilledHistory.find(ele => ele.adcode === extraCfg.adcode) | |
if (_found) { | |
return | |
} | |
this.drilledHistory.push(extraCfg) | |
}, | |
/** | |
* 清除当前区域边界图层 | |
*/ | |
clearAreaPolygonLayer () { | |
if (this.areaLevel !== 'community') { | |
this.polygonsLayer && this.polygonsLayer.clear() | |
} | |
}, | |
/** | |
* 单击区域块显示指标信息窗体 | |
* https://maptalks.org/maptalks.js/api/0.x/Polygon.html#event:click | |
* @param { Object } - maptalks polygon 单击事件回调参数 | |
*/ | |
onAreaClick (param) { | |
this.$emit('showInfo', param.target.options.extraCfg) | |
this.showAreaInfoWindow(param) | |
}, | |
/** | |
* 双击区域块进行下钻显示 | |
* @param { Object } param - maptalks polygon 双击事件回调参数 | 用户传递的下钻参数 | |
* @param { Boolean } isBackDrill - 是否为 [下钻返回] 操作 | |
*/ | |
onAreaDblClick (param, isBackDrill = false) { | |
this.hideAreaInfoWindow() | |
const _param = isBackDrill ? param : param.target.options.extraCfg | |
if (!_param) { | |
return | |
} | |
this.$emit('drill', _param) | |
this.addDrillHistoryItem(_param) | |
this.clearAreaPolygonLayer() | |
this.drill2NextAreaLevel() | |
}, | |
/** | |
* 隐藏区域指标信息窗体 | |
*/ | |
hideAreaInfoWindow () { | |
const popInfoWindow = document.querySelector('.pop-info-window-wrapper') | |
if (popInfoWindow && popInfoWindow.style.display !== 'none') { | |
popInfoWindow.style.display = 'none' | |
} | |
}, | |
/** | |
* 显示区域指标信息窗体 | |
*/ | |
showAreaInfoWindow (param = null) { | |
if (this.areaInfoWindow) { | |
this.hideAreaInfoWindow() | |
} | |
if (!param) { | |
return | |
} | |
let cfg = param.target.options.extraCfg | |
const _matchedItem = this.getMatchedAreaInfo(cfg) | |
if (!_matchedItem) { | |
return | |
} | |
const coordinate = param.coordinate | |
const _infoMap = this.getAreaInfoMap() | |
let _html = '' | |
_matchedItem && (_infoMap.map(info => { | |
_html += `<div class="info-item-container"><span class="title">${info.title}:</span><span class="value">${_matchedItem[info.key] ? _matchedItem[info.key] + ' ' + info.unit : '-'}</span></div>` | |
})) | |
var options = { | |
'single': true, | |
'width': 183, | |
'height': 105, | |
'custom': true, | |
'dx': -3, | |
'dy': -12, | |
'amination': 'fade', | |
'eventsPropagation': false, | |
'eventsToStop': false, | |
'content': `<div class="pop-info-window-wrapper">${_html}</div>` | |
} | |
this.areaInfoWindow = new maptalks.ui.InfoWindow(options) | |
this.areaInfoWindow.addTo(this.polygonsLayer).show(coordinate) | |
} | |
}, | |
mounted () { | |
this.$nextTick(() => { | |
this.initMap() | |
}) | |
} | |
} | |
</script> | |
<style lang='scss' scoped> | |
html,body{ margin:0px;height:100%;width:100%; } | |
.container{ width:100%;height:100% } | |
.map-wrapper { | |
height: 100%; | |
width: 100%; | |
position: relative; | |
.legend-container { | |
position: absolute; | |
bottom: 48px; | |
right: 64px; | |
.legend-title { | |
font-size: 32px; | |
color: #BCDDF7; | |
margin-bottom: 18px; | |
} | |
.legend-density-bar { | |
height: 24px; | |
width: 700px; | |
margin-bottom: 18px; | |
} | |
.legend-range { | |
display: flex; | |
font-size: 24px; | |
justify-content: space-between; | |
} | |
} | |
.drill-info-container { | |
font-size: 40px; | |
display: flex; | |
font-family: "FZZY-GBK"; | |
margin-bottom: 16px; | |
div { | |
flex: 1; | |
} | |
.back-btn { | |
padding: 8px 24px; | |
background: rgba(45, 153, 255, 0.8); | |
cursor: pointer; | |
} | |
} | |
} | |
</style> | |
<style lang="scss"> | |
@import '../../assets/scss/mixins'; | |
.pop-info-window-wrapper { | |
background-image: url('./imgs/map-tooltip-wrapper.png'); | |
@include background-image-fit; | |
width: 400px; | |
padding: 32px 22px 22px 22px; | |
position: relative; | |
.info-item-container { | |
display: flex; | |
span.title { | |
flex: 1; | |
display: inline-block; | |
} | |
span.value { | |
flex: 1; | |
display: inline-block; | |
text-align: right; | |
} | |
} | |
} | |
</style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment