需求背景
有些专题图需要立体直观的展示不同地区比如农作物当季的产量,通过绘制柱状图的方式来展现,直观明了。
实现方式一
效果图:

实现思路
用canvas绘制柱子,然后转换为图片,以添加广告牌的方式打点到地图上。 关键点在于动画的制作,要让 canvas 动画"弹出"在地图上某个经纬度点的正上方,需要将地图坐标(经纬度)转换为屏幕像素坐标,然后用 canvas.style.left/top
设置到对应位置,等待动画完成后移除动画,将canvas转换的图片打点上去,实现替换。替换过程无缝衔接,不仔细看一般看不出来。
如果不这么做,动画会在内存中跑完,柱子最终还是直接展现的,无动画过程。
缺陷
柱子为二维贴图,不够立体,且随着地图放大缩小,柱子不会自适应变大或缩小。
关键代码:
js
// 添加广告牌
const addMarker = (params) => {
// viewer放在全局了,记得放自己的viewer
if (viewer.entities.getById(params.id)) return
return viewer.entities.add({
id: params.id,
name: params.name ? params.name : '',
position: Cesium.Cartesian3.fromDegrees(
params.longitude,
params.latitude,
params.height || 0
),
show: true,
type: params.entityType ? params.entityType : '',
billboard: {
image: params.imageUrl,
width: params.markerWidth ? params.markerWidth : 50,
height: params.markerHeight ? params.markerHeight : 80,
pixelOffset: new Cesium.Cartesian2(0, -10),
scaleByDistance: undefined
},
description: params.des ? params.des : '',
props: params.props || {} // marker点携带的属性信息
})
}
// 打点(柱子)
const drawBarWithIcon = ({
value = 150,
color = '#19c37d',
barHeight = 100,
id,
imgSrc,
position
}) => {
const width = 100
const height = 120
const barWidth = 40
const barX = 50
const barY = 10
const duration = 2000 // 动画时长(ms)
const frameRate = 40
const totalFrames = Math.round((duration / 1000) * frameRate)
let currentFrame = 0
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const cartesian = Cesium.Cartesian3.fromDegrees(position[0], position[1], 80000)
const windowPosition = viewer.scene.cartesianToCanvasCoordinates(cartesian)
canvas.style.position = 'fixed'
canvas.style.left = `${windowPosition.x - width / 2}px`
canvas.style.top = `${windowPosition.y - height + 50}px`
canvas.style.zIndex = 999
document.body.appendChild(canvas)
const ctx = canvas.getContext('2d')
const img = new Image()
img.src = imgSrc
img.onload = () => {
currentFrame = 0
function animate() {
ctx.clearRect(0, 0, width, height)
const percent = Math.min(currentFrame / totalFrames, 1)
const currentBarHeight = barHeight * percent
ctx.fillStyle = color
ctx.fillRect(barX, barY + (barHeight - currentBarHeight), barWidth, currentBarHeight)
ctx.font = 'bold 20px Arial'
ctx.fillStyle = '#222'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(
Math.round(value * percent),
barX + barWidth / 2,
barY + (barHeight - currentBarHeight) + currentBarHeight / 2
)
ctx.drawImage(img, 0, height - 50, 50, 50)
if (percent < 1) {
currentFrame++
requestAnimationFrame(animate)
} else {
// 动画结束,显示最终数值
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = color
ctx.fillRect(barX, barY, barWidth, barHeight)
ctx.font = 'bold 20px Arial'
ctx.fillStyle = '#222'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(value, barX + barWidth / 2, barY + barHeight / 2)
ctx.drawImage(img, 0, height - 50, 50, 50)
// 生成图片并打点
const base64 = canvas.toDataURL('image/png')
setTimeout(() => {
document.body.removeChild(canvas)
addMarker({
id: id,
longitude: position[0],
latitude: position[1],
height: 80000,
imageUrl: base64,
markerWidth: 100,
markerHeight: 120
})
}, 300) // 动画结束稍作延迟再移除
}
}
animate()
}
}
const data = [
{
name: '苹果',
value: 120,
color: '#B52C40',
img: pingGuoImg, // 填本地图片路径或放base64
positon: [82.61161148746243, 37.97755723850458]
},
{
name: '西瓜',
value: 200,
color: '#00B12C',
img: xiGuaImg,
positon: [82.61161148746243, 37.97755723850458]
},
{
name: '葡萄',
value: 110,
color: '#8E2B80',
img: puTaoImg,
positon: [81.30805401438514, 40.06123536100814]
},
{
name: '石榴',
value: 20,
color: '#FF1025',
img: shiLiuImg,
positon: [88.52063936421416, 46.25883512198139]
},
]
// 调用
data.forEach((item, index) => {
drawBarWithIcon({
id:'农作物' + index,
value: item.value,
color: item.color,
imgSrc: item.img,
position: item.positon
})
})
实现方式二
效果图

实现思路
使用 Cesium 的 3D实体 Box 绘制三维柱子。
缺陷
动画效果没有
关键代码
js
// 三维立体柱子动画
function add3DBarWithIcon({ id, value, color, imgSrc, position }) {
// 1. 先移除同位置的广告牌,避免被遮挡
const existIcon = viewer.entities.getById(id + '-icon')
if (existIcon) viewer.entities.remove(existIcon)
// 柱子参数
const width = 40000
const depth = 40000
const maxHeight = value * 1000 + 70000 // 柱子高度与数值挂钩
const minHeight = 70000 // 初始高度
const duration = 2000
const frameRate = 40
const totalFrames = Math.round((duration / 1000) * frameRate)
let currentFrame = 100
// 2. 添加柱子 box(先显示为最小高度,动画逐步拉高)
const barEntity = viewer.entities.add({
id: id + '-bar',
name: '3DBar',
position: Cesium.Cartesian3.fromDegrees(position[0], position[1], minHeight / 2),
box: {
dimensions: new Cesium.Cartesian3(width, depth, minHeight),
material: Cesium.Color.fromCssColorString(color).withAlpha(0.85),
outline: false
},
show: true // 确保动画过程中实体可见
})
// 3. 添加顶部数值 label(动画过程中始终显示)
const labelEntity = viewer.entities.add({
id: id + '-label',
position: Cesium.Cartesian3.fromDegrees(position[0], position[1], minHeight + 3000),
label: {
text: value + '',
font: 'bold 24px Arial',
fillColor: Cesium.Color.WHITE,
showBackground: true,
backgroundColor: Cesium.Color.fromCssColorString(color).withAlpha(0.7),
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
pixelOffset: new Cesium.Cartesian2(0, 0)
},
show: true
})
// 4. 添加左侧图标 billboard(动画过程中始终显示)
const iconEntity = viewer.entities.add({
id: id + '-icon',
position: Cesium.Cartesian3.fromDegrees(position[0], position[1], minHeight),
billboard: {
image: imgSrc,
width: 40,
height: 40,
pixelOffset: new Cesium.Cartesian2(-50, 0)
},
show: true
})
// 5. 柱子动画
function animate() {
currentFrame++
const percent = Math.min(currentFrame / totalFrames, 1)
const currentHeight = minHeight + (maxHeight - minHeight) * percent
// 柱子高度
barEntity.box.dimensions = new Cesium.Cartesian3(width, depth, currentHeight)
// 柱子位置
barEntity.position = Cesium.Cartesian3.fromDegrees(position[0], position[1], currentHeight / 2)
// label位置
labelEntity.position = Cesium.Cartesian3.fromDegrees(
position[0],
position[1],
currentHeight + 3000
)
// 图标位置
iconEntity.position = Cesium.Cartesian3.fromDegrees(
position[0],
position[1],
currentHeight + 22000
)
// 动画过程中始终显示
barEntity.show = true
labelEntity.show = true
iconEntity.show = true
if (percent < 1) {
requestAnimationFrame(animate)
}
}
animate()
}
const data = [
{
name: '苹果',
value: 120,
color: '#B52C40',
img: pingGuoImg, // 填本地图片路径或放base64
positon: [82.61161148746243, 37.97755723850458]
},
{
name: '西瓜',
value: 200,
color: '#00B12C',
img: xiGuaImg,
positon: [82.61161148746243, 37.97755723850458]
},
{
name: '葡萄',
value: 110,
color: '#8E2B80',
img: puTaoImg,
positon: [81.30805401438514, 40.06123536100814]
},
{
name: '石榴',
value: 20,
color: '#FF1025',
img: shiLiuImg,
positon: [88.52063936421416, 46.25883512198139]
},
]
// 调用
data.forEach((item, index) => {
add3DBarWithIcon({
id: item.name + index,
value: item.value,
color: item.color,
imgSrc: item.img,
position: item.positon
})
})