概述
MapboxGL热力图的配置参数并不多,但是有时候为了或得一个比较好用的热力图配置参数,我们不得不改代码再预览,显得尤为麻烦,为方便配置,实现实时预览,本文使用ace实现了一个热力图样式在线配置页面。
效果
实现
1. 技术栈
- Vue3 + Element Plus
- ace Editor
- mapboxGL
2. 实现功能
- csv、json、geojson数据上传并解析
- mapboxGL热力图
- 热力图样式编辑与实时预览
3. 实现
3.1 交互界面
<template>
<div class="tips">
<b>说明:</b>实现热力图样式的配置与预览。
</div>
<div class="container">
<div class="setting-panel">
<div class="title">配置参数</div>
<div class="content">
<el-form
label-width="0"
:model="styleFormData"
>
<el-form-item label="">
<div class="label">
Radius
<div class="tooltip">
<el-icon><InfoFilled /></el-icon>
<div class="tooltips">
<p>Radius of influence of one heatmap point in pixels. Increasing the value makes the heatmap smoother, but less detailed.</p>
</div>
</div>
</div>
<el-input class="my-input" size="small" :rows="2" type="textarea" v-model="styleFormData.radius" />
</el-form-item>
<el-form-item label="">
<div class="label">
Color
<div class="tooltip">
<el-icon><InfoFilled /></el-icon>
<div class="tooltips">
<p>Defines the color of each pixel based on its density value in a heatmap. Should be an expression that uses ["heatmap-density"] as input.</p>
</div>
</div>
</div>
<el-input class="my-input" size="small" :rows="4" type="textarea" v-model="styleFormData.color" />
</el-form-item>
<el-form-item label="">
<div class="label">
Weight
<div class="tooltip">
<el-icon><InfoFilled /></el-icon>
<div class="tooltips">
<p>A measure of how much an individual point contributes to the heatmap. A value of 10 would be equivalent to having 10 points of weight 1 in the same spot. Especially useful when combined with clustering.</p>
</div>
</div>
</div>
<el-input class="my-input" size="small" :rows="2" type="textarea" v-model="styleFormData.weight" />
</el-form-item>
<el-form-item label="">
<div class="label">
Intensity
<div class="tooltip">
<el-icon><InfoFilled /></el-icon>
<div class="tooltips">
<p>Similar to `heatmap-weight` but controls the intensity of the heatmap globally. Primarily used for adjusting the heatmap based on zoom level.</p>
</div>
</div>
</div>
<el-input class="my-input" size="small" :rows="2" type="textarea" v-model="styleFormData.intensity" />
</el-form-item>
<el-form-item label="">
<div class="label">
Opacity
<div class="tooltip">
<el-icon><InfoFilled /></el-icon>
<div class="tooltips">
<p>The global opacity at which the heatmap layer will be drawn.</p>
</div>
</div>
</div>
<el-input class="my-input" size="small" :rows="2" type="textarea" v-model="styleFormData.opacity" />
</el-form-item>
</el-form>
</div>
<div class="title">
JSON编辑器
<div class="tools">
<el-button size="small" @click="copyStyle">复制</el-button>
</div>
</div>
<div class="content code" id="codeEditor"></div>
</div>
<div class="main-panel">
<div class="data-panel">
<el-upload
drag
ref="file"
action="''"
:multiple="false"
:auto-upload="false"
:limit="1"
:on-exceed="handleExceed"
:on-change="changeDataFile"
:accept="'.csv,.json,.geojson'"
>
<div class="el-upload__text">
拖动文件到此或 <em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
可上传csv、json、geojson等格式点数据,如为csv、json需包含lon,lat字段,如添加<b style="color: red">权重</b>,需<b style="color: red">值</b>字段
</div>
</template>
</el-upload>
</div>
<MapComponent :is-tools="false" @map-loaded="mapLoaded" style="height: 100%;"></MapComponent>
</div>
</div>
</template>
<style scoped lang="scss">
@import "../../assets/common/style";
.container {
margin-top: 1.5rem;
height: calc(100% - 3.5rem);
position: relative;
display: flex;
flex-direction: row;
.main-panel {
flex-grow: 1;
height: calc(100% - 1.8rem);
position: relative;
.data-panel {
padding: 0.8rem;
background-color: white;
position: absolute;
top: 1rem;
right: 1rem;
z-index: 99;
width: 25rem;
}
}
.setting-panel {
width: 25rem;
height: 100%;
box-shadow: 0 0 5px #ccc;
box-sizing: border-box;
margin-right: 1.5rem;
}
.title {
padding: 0.6rem 1.2rem;
font-weight: bold;
font-size: 1.1rem;
border: 1px solid #efefef;
}
.content {
padding: 1.2rem 1.2rem 0 1.2rem;
&.code {
height: calc(100% - 33.7rem)
}
}
.tools {
float: right;
}
.label, .my-input {
display: inline-block;
width: calc(100% - 7rem);
.el-input__wrapper {
width: 100%;
}
}
.label {
width: 6rem;
height: 100%;
line-height: 1.8;
text-align: right;
padding-right: 0.6rem;
}
.tooltip {
display: inline-block;
cursor: pointer;
position: relative;
&:hover {
.tooltips {
display: block;
}
}
.tooltips {
display: none;
position: absolute;
left: -8px;
top: 22px;
background-color: rgba(0,0,0,0.6);
color: #fff;
border-radius: 3px;
z-index: 999;
padding: 0.5rem;
width: 17rem;
white-space: normal;
font-size: 12px;
p {
width: 100%;
word-break: break-word;
margin: 0;
text-align: left;
line-height: 1.5;
}
&:before {
content: ' ';
width: 0;
height: 0;
border: 5px solid transparent;
border-bottom-color: rgba(0,0,0,0.6);
position: absolute;
left: 10px;
top: -10px;
}
}
}
}
</style>
3.2 数据上传与解析
changeDataFile(file, fileList) {
uploadFile = file
this.showData()
},
handleExceed(files) {
this.$refs.file.clearFiles()
this.$refs.file.handleStart(files[0])
},
showData() {
const that = this
if(!uploadFile) {
ElMessage({
message: '未上传文件!',
type: 'warning',
})
return
}
const fileType = uploadFile.name.split('.')[1]
const reader = new FileReader();
reader.readAsText(uploadFile.raw,'GB2312');
reader.onload = function () {
const fileContent = reader.result;
let geojson = null
if(fileType === 'csv') {
let {geomType, features} = csv2geojson(fileContent)
geomType = geomType.toLowerCase()
if (geomType.indexOf('point') !== -1) {
geojson = new Geojson(features)
}
} else if(fileType === 'json') {
let {geomType, features} = json2Geojson(JSON.parse(fileContent))
geomType = geomType.toLowerCase()
if (geomType.indexOf('point') !== -1) {
geojson = new Geojson(features)
}
} else {
geojson = JSON.parse(fileContent)
}
if(geojson) {
map.getSource(`${DATA_LAYER}-source`).setData(geojson);
that.styleUpdate()
const [xmin, ymin, xmax, ymax] = turf.bbox(geojson);
const bbox = [[xmin, ymin], [xmax, ymax]];
map.fitBounds(bbox, {
padding: {top: 100, bottom:100, left: 150, right: 150},
duration: 500
})
}
}
},
csv2geojson和json2Geojson转换方法如下:
import {Feature} from './geojson'
import { wktToGeoJSON } from "@terraformer/wkt"
export function csv2geojson(csvContent) {
const splitChar = csvContent.indexOf('\r') ? '\r' : '\r\n'
const lines = csvContent.split(splitChar).filter(v => Boolean(v))
const headers = lines[0].split(',').map(header => header.toLowerCase())
let geomType = '', features = [], isWkt = false
if(headers.includes('lon') && headers.includes('lat')) {
geomType = 'Point'
} else if(headers.includes('wkt')) {
isWkt = true
const geom = wktToGeoJSON(lines[1].split(',')[headers.indexOf('wkt')])
geomType = geom.type
}
if(geomType) {
for (let i = 1; i < lines.length; i++) {
const line = lines[i].split(',')
if(line.length === headers.length) {
let props = {}
headers.forEach((header, index) => {
if(!['wkt', 'lon', 'lat'].includes(header)) props[header] = line[index]
})
const lonIndex = headers.indexOf('lon')
const latIndex = headers.indexOf('lat')
const geometry = isWkt ? wktToGeoJSON(line[headers.indexOf('wkt')]) : [line[lonIndex], line[latIndex]].map(Number)
features.push(new Feature(geomType, props, geometry))
}
}
}
return {
headers,
geomType,
features
}
}
export function json2Geojson(json) {
if(!Array.isArray(json)) throw new Error('数据格式错误')
const geomType = 'Point'
const features = json.map(d => {
const {lon, lat} = d
return new Feature(geomType, d, [lon, lat])
})
return {
geomType,
features
}
}
3.3 样式编辑与实时预览
initEditor() {
editor = ace.edit("codeEditor");
const theme = "github";
const language = "json";
editor.setTheme("ace/theme/" + theme);
editor.session.setMode("ace/mode/" + language);
editor.setFontSize(14);
editor.setReadOnly(false);
editor.setOption("wrap", "free");
editor.setOptions({
enableBasicAutocompletion: true,
enableSnippets: true,
enableLiveAutocompletion: true,
tabSize: 2
});
this.styleUpdate()
},
styleUpdate() {
const style = {
"heatmap-radius": this.styleFormData.radius,
"heatmap-color": this.styleFormData.color,
"heatmap-weight": this.styleFormData.weight,
"heatmap-intensity": this.styleFormData.intensity,
"heatmap-opacity": this.styleFormData.opacity,
}
let isOk = true
for (const styleKey in style) {
let val = style[styleKey]
if(typeof val === 'string') val = val.replace(/'/g, '"')
if(val === '') isOk = false
if(styleKey !== 'heatmap-color' && ! Number.isNaN(Number(val))) style[styleKey] = Number(va
else style[styleKey] = JSON.parse(val || '{}')
if(styleKey === 'heatmap-opacity' && style[styleKey] > 1) style[styleKey] = 1
if(styleKey === 'heatmap-opacity' && style[styleKey] < 0) style[styleKey] = 0
}
if(isOk) {
editor.setValue(JSON.stringify(style, null, 2))
if(window.map) {
if(map.getLayer(`${DATA_LAYER}-layer`)) map.removeLayer(`${DATA_LAYER}-layer`)
map.addLayer({
id: `${DATA_LAYER}-layer`,
type: "heatmap",
source: `${DATA_LAYER}-source`,
paint: style
});
}
}
},