前言
在地图、大屏、无人机调度、任务管理这类项目里,我们经常需要展示各种点位图标。
如果只是普通点位,直接使用一张 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 尺寸
圆角和箭头绘制
图片跨域
失败兜底
至于多张图片、大量点位、缓存、批量生成、并发控制、分帧渲染等问题,可以放到以后继续讲。