cesium绘制动态柱状图


需求背景

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

实现方式一

效果图:

实现思路

用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
  })
})
相关推荐
LinDaiuuj28 分钟前
最新的前端技术和趋势(2025)
前端
一只小风华~36 分钟前
JavaScript 函数
开发语言·前端·javascript·ecmascript·web
程序猿阿伟2 小时前
《不只是接口:GraphQL与RESTful的本质差异》
前端·restful·graphql
若梦plus3 小时前
Nuxt.js基础与进阶
前端·vue.js
樱花开了几轉3 小时前
React中为甚么强调props的不可变性
前端·javascript·react.js
风清云淡_A3 小时前
【REACT18.x】CRA+TS+ANTD5.X实现useImperativeHandle让父组件修改子组件的数据
前端·react.js
小飞大王6663 小时前
React与Rudex的合奏
前端·react.js·前端框架
若梦plus4 小时前
React之react-dom中的dom-server与dom-client
前端·react.js
若梦plus4 小时前
react-router-dom中的几种路由详解
前端·react.js
若梦plus4 小时前
Vue服务端渲染
前端·vue.js