如何把一张图片做成自定义复杂 UI 图标

前言

在地图、大屏、无人机调度、任务管理这类项目里,我们经常需要展示各种点位图标。

如果只是普通点位,直接使用一张 PNG 就够了:

js 复制代码
billboard: {
  image: "/marker.png"
}

但真实业务里,点位图标往往不是这么简单。

比如产品希望地图上的事件点位长这样:

text 复制代码
外层是蓝色圆角背景
中间展示一张业务图片
底部有一个小箭头
箭头指向真实坐标
图片加载失败时使用默认图

这个图标已经不是一张普通图片,而是一个小型 UI 组件。

如果在普通页面中,我们可以用 HTML + CSS 写:

html 复制代码
<div class="marker">
  <img src="xxx.png" />
  <span class="arrow"></span>
</div>

但在 Cesium 这类地图场景里,点位通常不是普通 DOM,而是地图引擎渲染的对象。

以 Cesium 为例,点位图标一般通过 Billboard 展示:

js 复制代码
viewer.entities.add({
  position,
  billboard: {
    image,
    width,
    height,
  },
})

这里的 image 更适合接收一张图片、一个 Canvas、一个 dataURL,而不是一段 HTML。

所以这一篇只解决一个问题:

text 复制代码
如何把一张普通图片,处理成带圆角背景、边框、箭头的自定义 UI 图标?

注意,本文只讲单张图片的处理流程,不展开多张图片批量渲染、缓存、并发优化。那些可以作为下一篇继续讲。

最终效果

我们希望把一张普通图片:

text 复制代码
event.png

加工成这种结构:

也就是:

text 复制代码
圆角背景
内部图片
底部箭头
统一尺寸
可配置颜色
失败兜底

处理后的图标可以作为 Cesium Billboard 的图片:

js 复制代码
billboard: {
  image: markerIcon.image,
  width: markerIcon.width,
  height: markerIcon.height,
}

为什么不用 DOM

如果是在普通页面,DOM + CSS 是最舒服的方式。

但地图点位不一定适合 DOM。

尤其在 Cesium 这类 WebGL 地图中,点位图标通常进入地图自己的渲染体系。我们更希望给地图引擎一张已经处理好的图片,而不是让地图和 DOM 覆盖物相互配合。

所以这里选择:

text 复制代码
Canvas 绘制复杂 UI
导出成图片
交给地图引擎渲染

Canvas 的好处是:

text 复制代码
可以用代码画圆角、箭头、图片
绘制完成后就是一张图片
地图引擎可以直接使用

整体实现思路

实现流程很简单:

text 复制代码
1. 加载原始图片
2. 创建 Canvas
3. 计算图标尺寸
4. 绘制圆角背景
5. 绘制底部箭头
6. 绘制内部图片
7. 导出结果

对应代码结构大概是:

js 复制代码
async function createMarkerIcon(imageUrl, options) {
  const img = await loadImage(imageUrl)

  const canvas = document.createElement("canvas")
  const ctx = canvas.getContext("2d")

  // 绘制背景
  // 绘制箭头
  // 绘制图片

  return {
    image: canvas.toDataURL("image/png"),
    width: canvas.width,
    height: canvas.height,
  }
}

第一步:加载图片

先封装一个图片加载方法:

js 复制代码
function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.crossOrigin = "anonymous"
    img.onload = () => resolve(img)
    img.onerror = reject
    img.src = src
  })
}

这里使用 new Image() 创建图片对象。

等图片加载成功后,才能拿到:

js 复制代码
img.width
img.height

也才能把图片画到 Canvas 上:

js 复制代码
ctx.drawImage(img, x, y, width, height)

这里设置了:

js 复制代码
img.crossOrigin = "anonymous"

是为了后面调用 canvas.toDataURL() 时尽量避免跨域污染问题。

如果图片是跨域地址,服务端也需要允许跨域,否则 Canvas 仍然可能无法导出。

第二步:准备 Canvas 尺寸

我们希望图标内部图片宽度固定,比如:

js 复制代码
const markerWidth = 60

图片高度根据原始比例计算:

js 复制代码
const aspectRatio = img.width / img.height
const imageHeight = markerWidth / aspectRatio

然后再加上内边距和箭头高度:

js 复制代码
const padding = 8
const arrowHeight = 12

const canvasWidth = markerWidth + padding * 2
const canvasHeight = imageHeight + padding * 2 + arrowHeight

创建 Canvas:

js 复制代码
const canvas = document.createElement("canvas")
canvas.width = canvasWidth
canvas.height = canvasHeight

const ctx = canvas.getContext("2d")

这里的 Canvas 可以不插入页面,它只是一个临时画布。

我们在内存里把图标画好,然后导出成图片即可。

第三步:绘制圆角背景

先画一个圆角矩形背景。

为了兼容性,可以自己封装圆角矩形方法:

js 复制代码
function drawRoundRect(ctx, x, y, width, height, radius) {
  const r = Math.min(radius, width / 2, height / 2)

  ctx.beginPath()
  ctx.moveTo(x + r, y)
  ctx.lineTo(x + width - r, y)
  ctx.quadraticCurveTo(x + width, y, x + width, y + r)
  ctx.lineTo(x + width, y + height - r)
  ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height)
  ctx.lineTo(x + r, y + height)
  ctx.quadraticCurveTo(x, y + height, x, y + height - r)
  ctx.lineTo(x, y + r)
  ctx.quadraticCurveTo(x, y, x + r, y)
}

然后绘制背景:

js 复制代码
ctx.fillStyle = "#1D90FA"
ctx.strokeStyle = "#1D90FA"
ctx.lineWidth = 4

drawRoundRect(ctx, 4, 4, markerWidth + 8, imageHeight + 8, 8)
ctx.fill()
ctx.stroke()

这里背景色和边框色都是蓝色:

text 复制代码
#1D90FA

实际项目里可以根据业务状态切换颜色。

第四步:绘制底部箭头

箭头可以理解成一个三角形。

它位于圆角矩形底部中间:

js 复制代码
const centerX = canvasWidth / 2
const arrowTopY = padding + imageHeight + padding / 2
const arrowBottomY = arrowTopY + arrowHeight

ctx.beginPath()
ctx.moveTo(centerX - 10, arrowTopY)
ctx.lineTo(centerX + 10, arrowTopY)
ctx.lineTo(centerX, arrowBottomY)
ctx.closePath()
ctx.fill()

这段代码画了一个向下的小三角。

在地图上使用时,这个箭头底部通常要对齐真实坐标点。

以 Cesium 为例,可以配合:

js 复制代码
verticalOrigin: Cesium.VerticalOrigin.BOTTOM

让图标底部对齐经纬度位置。

第五步:绘制内部图片

背景和箭头画完后,再把业务图片画进去:

js 复制代码
ctx.drawImage(
  img,
  padding,
  padding,
  markerWidth,
  imageHeight
)

这里的参数含义是:

text 复制代码
img:要绘制的图片对象
padding:图片绘制的 x 坐标
padding:图片绘制的 y 坐标
markerWidth:绘制后的图片宽度
imageHeight:绘制后的图片高度

这样图片就会被绘制到蓝色背景内部。

第六步:导出成图片

Canvas 绘制完成后,可以使用:

js 复制代码
const image = canvas.toDataURL("image/png")

它会返回一个 base64 图片地址,类似:

text 复制代码
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...

浏览器可以把它当成普通图片使用。

Cesium Billboard 也可以直接使用:

js 复制代码
billboard: {
  image,
}

完整代码

把上面的步骤合起来:

js 复制代码
function loadImage(src) {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.crossOrigin = "anonymous"
    img.onload = () => resolve(img)
    img.onerror = reject
    img.src = src
  })
}

function drawRoundRect(ctx, x, y, width, height, radius) {
  const r = Math.min(radius, width / 2, height / 2)

  ctx.beginPath()
  ctx.moveTo(x + r, y)
  ctx.lineTo(x + width - r, y)
  ctx.quadraticCurveTo(x + width, y, x + width, y + r)
  ctx.lineTo(x + width, y + height - r)
  ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height)
  ctx.lineTo(x + r, y + height)
  ctx.quadraticCurveTo(x, y + height, x, y + height - r)
  ctx.lineTo(x, y + r)
  ctx.quadraticCurveTo(x, y, x + r, y)
}

async function createMarkerIcon(imageUrl, options = {}) {
  const {
    width = 60,
    padding = 8,
    arrowHeight = 12,
    borderWidth = 4,
    radius = 8,
    background = "#1D90FA",
    fallback = "",
  } = options

  let img

  try {
    img = await loadImage(imageUrl || fallback)
  } catch (error) {
    img = await loadImage(fallback)
  }

  const aspectRatio = img.width / img.height
  const imageHeight = width / aspectRatio

  const canvasWidth = width + padding * 2
  const canvasHeight = imageHeight + padding * 2 + arrowHeight

  const canvas = document.createElement("canvas")
  canvas.width = canvasWidth
  canvas.height = canvasHeight

  const ctx = canvas.getContext("2d")

  ctx.fillStyle = background
  ctx.strokeStyle = background
  ctx.lineWidth = borderWidth

  const rectX = padding - borderWidth / 2
  const rectY = padding - borderWidth / 2
  const rectWidth = width + borderWidth
  const rectHeight = imageHeight + borderWidth

  drawRoundRect(ctx, rectX, rectY, rectWidth, rectHeight, radius)

  const centerX = canvasWidth / 2
  const arrowTopY = rectY + rectHeight
  const arrowBottomY = arrowTopY + arrowHeight

  ctx.lineTo(centerX - 10, arrowTopY)
  ctx.lineTo(centerX + 10, arrowTopY)
  ctx.lineTo(centerX, arrowBottomY)
  ctx.closePath()
  ctx.fill()
  ctx.stroke()

  ctx.drawImage(img, padding, padding, width, imageHeight)

  return {
    image: canvas.toDataURL("image/png"),
    width: canvasWidth,
    height: canvasHeight,
  }
}

在 Cesium 中使用

生成图标后,就可以挂到 Cesium 点位上:

js 复制代码
async function addMarker(viewer, marker) {
  const markerIcon = await createMarkerIcon(marker.image, {
    width: marker.imageWidth || 60,
    background: "#1D90FA",
    fallback: "/default-marker.png",
  })

  const position = Cesium.Cartesian3.fromDegrees(
    Number(marker.longitude),
    Number(marker.latitude),
    Number(marker.height || 0)
  )

  viewer.entities.add({
    position,
    billboard: {
      image: markerIcon.image,
      width: markerIcon.width,
      height: markerIcon.height,
      verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
    },
  })
}

这里最关键的是:

js 复制代码
verticalOrigin: Cesium.VerticalOrigin.BOTTOM

因为图标底部有箭头,箭头要指向地图坐标,所以让 Billboard 底部对齐坐标点会更自然。

可以扩展哪些样式

上面的例子只是最基础的版本。

后续还可以继续扩展:

text 复制代码
不同状态颜色
阴影
文字标签
数字角标
告警图标
渐变背景
图片裁剪成圆形
选中态边框

例如添加阴影:

js 复制代码
ctx.shadowColor = "rgba(0, 0, 0, 0.3)"
ctx.shadowBlur = 8
ctx.shadowOffsetY = 4

例如绘制文字:

js 复制代码
ctx.font = "12px sans-serif"
ctx.fillStyle = "#fff"
ctx.fillText("告警", 10, 20)

例如绘制圆形图片,可以先裁剪路径:

js 复制代码
ctx.save()
ctx.beginPath()
ctx.arc(x + r, y + r, r, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(img, x, y, r * 2, r * 2)
ctx.restore()

也就是说,只要 Canvas 能画出来,就可以把它变成地图图标。

单张图片阶段需要注意什么

这一篇只处理单张图片,所以性能问题还不复杂。

但仍然有几个细节要注意:

1. 图片必须加载完成后再绘制

不能写:

js 复制代码
const img = new Image()
img.src = imageUrl
ctx.drawImage(img, 0, 0)

因为图片还没加载完成,绘制可能失败。

应该等:

js 复制代码
img.onload

之后再绘制。

2. 注意图片跨域

如果图片不是同域资源,并且你要调用:

js 复制代码
canvas.toDataURL()

就必须注意跨域配置。

前端可以设置:

js 复制代码
img.crossOrigin = "anonymous"

但服务端也要允许跨域。

否则 Canvas 可能无法导出图片。

3. 控制 Canvas 尺寸

图标没必要画得太大。

Canvas 越大,绘制和导出成本越高。

对于地图点位,通常几十像素到一百多像素已经够用。

例如:

js 复制代码
width: 48
width: 60
width: 80

都比较常见。

4. 失败兜底

业务图片可能会加载失败。

所以最好提供默认图:

js 复制代码
fallback: "/default-marker.png"

这样接口图片异常时,地图上仍然能展示一个可用图标,而不是空白。

总结

这篇文章只讲了最基础的一步:

text 复制代码
如何把单张图片处理成自定义复杂 UI 图标。

核心方案是:

text 复制代码
用 Canvas 绘制背景、圆角、箭头和图片
再把 Canvas 导出成图片
最后交给地图引擎作为 marker 使用

这个方案的优势是:

text 复制代码
样式灵活
不依赖 DOM Marker
适合 Cesium / WebGL 地图
可以运行时动态生成

当只处理单张图片时,我们主要关注:

text 复制代码
图片加载
Canvas 尺寸
圆角和箭头绘制
图片跨域
失败兜底

至于多张图片、大量点位、缓存、批量生成、并发控制、分帧渲染等问题,可以放到以后继续讲。

相关推荐
用户938515635072 小时前
从 0 拆解一个 Next.js 笔记系统:npx、App Router、RSC 与组件规划全记录
前端·后端·全栈
hello93072 小时前
plop代码生成器
前端
windliang3 小时前
Claude Code 源码分析(十二):错误处理与自动恢复:让 Agent 稳定运行
前端·javascript·面试
fatcoder3 小时前
玩转Docker 06 — 容器网络
前端·后端·docker
打呵欠的猫3 小时前
我用 AI 重写了项目的请求层,从 800 行"面条代码"变成 3 层洋葱模型
前端·ai编程
默_笙3 小时前
🛬 前端路由的"高级玩法":懒加载、404、鉴权路由,一个都不能少(下篇)
前端·javascript
用户921080262863 小时前
1. Cesium 在 Vue 项目中的简单初始化配置
前端
YIAN3 小时前
从 Hash 底层原理到 React Router v6 实战:我学会了什么?
前端·react.js·vue-router
lv__pf3 小时前
Spring配置类解析 【TL spring 11】
java·前端·spring