开发背景
在之前的日常开发过程中,遇到了很多在图片上标注范围或者点位的需求,但是搜索到一大圈之后,发现市面上并没有相关的一些插件(🐶也许是我没找到),最后动手实现了一个,目前已经开源出来,有需要的可以直接拿来用,也欢迎star
开发思路
利用canvas实现底部图片的加载,然后使用组件化的思想将Point点位和Range范围当做组件去加载到图片上去,除此之外还要考虑到一些鼠标事件,例如:点击,拖拽,以及滚轮放大缩小事件,废话不多说,核心代码如下:
export class SpaceMaker {
constructor(parentElem, options) {
this.parentElem = parentElem
this.resizeDpiRatio()
this.initialize(options)
this.winResize()
}
// 动态调整dpi
resizeDpiRatio() {
if (window.devicePixelRatio < 2) {
this.dpiRatio = 2
} else {
this.dpiRatio = window.devicePixelRatio
}
}
// 初始化配置
initialize(options) {
if (!this.parentElem) {
throw new Error('A DOM element is required for SpaceMaker to initialize')
}
this.options = options
this.width = this.parentElem.clientWidth
this.height = this.parentElem.clientHeight
if (this.map) this.map.remove()
this.map = createMap(this.width, this.height, this.dpiRatio)
this.ctx = this.map.getContext('2d', { willReadFrequently: true })
this.ctx.scale(this.dpiRatio, this.dpiRatio)
this.parentElem.append(this.map)
this.scaleX = _.get(this.options, 'scaleX')
this.scaleY = _.get(this.options, 'scaleY')
this.translateImgTo = _.get(this.options, 'translateImgTo')
this.parseOptions(options)
// 事件
this.map.onmousemove = this.onMouseMove
this.map.onmousedown = this.onMouseDown
this.map.onmouseup = this.onMouseUp
this.map.onmouseleave = this.onMouseLeave
this.map.onwheel = this.onWheel
}
parseOptions(options) {
this.ctx.clearRect(0, 0, this.width, this.height)
this.marks = []
this.ranges = []
this.mapImgRect = {
x: 0,
y: 0,
width: _.get(options, 'mapWidth') || 0,
height: _.get(options, 'mapHeight') || 0
}
this.loadMapImg(_.get(options, 'mapImgUrl'), () => {
this.onComponentsLoad(options)
})
}
loadMapImg(src, cb = null) {
if (!src) {
throw new Error('src of picture is not found')
}
this.mapImg = new Image()
this.mapImg.crossOrigin = 'anonymous'
this.mapImg.src = src
this.mapImg.onload = () => {
this.mapImgRect = this.coverCenter(this.width, this.height, this.mapImg.width, this.mapImg.height)
if (cb) {
cb()
}
}
}
然后看一下coverCenter做了哪些事情,主要就是先判断纵向比,然后返回调整之后的坐标和宽高度,重新放在画布上
coverCenter(canvasWidth, canvasHeight, imgWidth, imgHeight) {
let x = 0;
let y = 0;
let width = imgWidth;
let height = imgHeight;
if ((imgWidth / imgHeight) > (canvasWidth / canvasHeight)) {
this.ratio = (canvasWidth / width);
y = -(canvasHeight / this.ratio - imgHeight) / 2;
height = canvasHeight / this.ratio
x = -(canvasWidth / this.ratio - imgWidth) / 2
} else {
this.ratio = (canvasHeight / height);
x = -(canvasWidth / this.ratio - imgWidth) / 2
width = canvasWidth / this.ratio
y = -(canvasHeight / this.ratio - imgHeight) / 2
}
return {
x: x,
y: y,
width: width,
height: height
};
}
再看onComponentsLoad初始化Point和Range组件
onComponentsLoad(options) {
// 从外界传进来的点位
const _markers = _.get(options, 'marks') || []
if (!_.isEmpty(_markers)) {
this.marks = _markers.map(_m => {
return new Mark(_m, this)
})
}
// 从外界传进来的范围
const _ranges = _.get(options, 'ranges') || []
if (!_.isEmpty(_ranges)) {
this.ranges = _ranges.map(_r => {
return new Range(_r, this)
})
}
this.drawMapImage()
// 抛出生命周期方法供外界使用
this.dispatch('loaded', this)
}
drawMapImage方法最终把渲染的图片放在画布上
drawMapImage() {
if (!this.mapImg || !this.mapImgRect || !this.ctx) return
this.ctx.clearRect(0, 0, this.width, this.height)
this.ctx.drawImage(this.mapImg, this.mapImgRect.x, this.mapImgRect.y, this.mapImgRect.width,
this.mapImgRect.height, 0, 0, this.width, this.height)
this.marks.forEach(m => {
m.draw()
})
this.ranges.forEach(r => {
r.draw()
})
this.dispatch('rendered', this)
}
再看Point组件和Range组件
export class Mark {
constructor(options, spaceMaker) {
this.id = s8()
this.options = options
this.spaceMaker = spaceMaker
if(_.get(this.options, 'w')) {
this.options.center.x = (this.options.center.x * this.spaceMaker.mapImg.width) / _.get(this.options, 'w', 1)
this.options.center.y = (this.options.center.y * this.spaceMaker.mapImg.height) / _.get(this.options, 'h', 1)
}
this.initOptions()
this.parseIcon()
}
initOptions() {
// 图标大小
this.radius = _.get(this.options, 'radius')
// 图标内padding
this.padding = _.get(this.options, 'padding')
// 图片url
this.icon = _.get(this.options, 'icon')
// 背景颜色
this.bgColor = _.get(this.options, 'bgColor')
// 是否高亮
this.highlight = _.get(this.options, 'highlight')
// 高亮范围大小
this.highlightSize = _.get(this.options, 'highlightSize')
// 额外信息
this.ext = _.get(this.options, 'ext')
}
parseIcon() {
if(this.icon.startsWith('http')) {
this.loadImg(this.icon)
} else if (resources[`${this.icon}`]) {
this.loadImg(resources[`${this.icon}`])
}
}
loadImg(src) {
if (!src) {
return
}
this.iconImg = new Image()
this.iconImg.crossOrigin = 'anonymous'
this.iconImg.src = src
this.iconImg.onload = () => {
this.draw()
}
}
draw() {
this.calcCenter()
// 超出屏幕范围的不画出来
if (this.center.x < 0 || this.center.y < 0 || this.center.x > this.spaceMaker.map.width || this.center.y > this.spaceMaker.map.height) return
let ctx = this.spaceMaker.ctx
ctx.save()
if(this.highlight) {
ctx.beginPath()
ctx.arc(this.center.x, this.center.y, this.radius + this.highlightSize, 0, 2 * Math.PI, false)
ctx.fillStyle = Color('rgba(9, 140, 255, 0.8)').alpha(0.4).lighten(0.5)
ctx.fill()
ctx.strokeStyle = Color('rgba(9, 140, 255, 0.8)').alpha(0.8).lighten(0.2)
ctx.lineWidth = 1
ctx.stroke()
}
if (this.iconImg) {
ctx.beginPath()
ctx.arc(this.center.x, this.center.y, this.radius, 0, 2 * Math.PI, false)
ctx.fillStyle = this.bgColor
ctx.fill()
ctx.drawImage(this.iconImg, 0, 0, this.iconImg.width, this.iconImg.height, this.centerRect.x, this.centerRect.y, this.centerRect.width, this.centerRect.height)
}
// if (this.iconFont) {
// // 因为字体图标是镂空的,所以先画一个白底
// ctx.beginPath()
// if(this.icon.startsWith('D_')) {
// ctx.arc(this.center.x, this.center.y, this.radius, 0, 2 * Math.PI, false)
// ctx.fillStyle = Color(this.bgColor).alpha(0.8)
// ctx.font = `${this.borderRect.width - this.padding / 2}px anticon`
// } else {
// ctx.arc(this.center.x, this.center.y, this.radius - 0.2, 0, 2 * Math.PI, false)
// ctx.fillStyle = 'rgba(255, 255, 255, 1)'
// ctx.font = `${this.borderRect.width}px anticon`
// }
// ctx.fill()
// ctx.textAlign = 'center'
// ctx.textBaseline = 'middle'
// ctx.fillText(this.iconFont, this.center.x, this.center.y)
// }
ctx.restore()
}
calcCenter() {
const cx = (_.get(this.options, 'center.x') - this.spaceMaker.mapImgRect.x) * this.spaceMaker.ratio
const cy = (_.get(this.options, 'center.y') - this.spaceMaker.mapImgRect.y) * this.spaceMaker.ratio
this.center = new Point(cx, cy)
const innerWidth = this.radius - this.padding
const innerHeight = this.radius - this.padding
this.centerRect = new Rect(this.center.x - innerWidth, this.center.y - innerHeight, innerWidth * 2, innerHeight * 2)
this.borderRect = new Rect(this.center.x - this.radius, this.center.y - this.radius, this.radius * 2, this.radius * 2)
}
}
Range组件
export class Range {
constructor(options, spaceMaker) {
this.id = s8()
this.options = options
this.spaceMaker = spaceMaker
this.initOptions()
this.calcPoints()
this.calcBorderRect()
}
initOptions() {
this.bgColor = _.get(this.options, 'bgColor', 'rgba(247, 159, 58, 0.6)');
this.hoverColor = _.get(this.options, 'hoverColor', 'rgba(247, 159, 58, 0.6)')
this.ext = _.get(this.options, 'ext');
}
calcPoints() {
const _points = _.get(this.options, 'points') || []
this.points = _points.map(item => {
const cx = (item[0] - this.spaceMaker.mapImgRect.x) * this.spaceMaker.ratio;
const cy = (item[1] - this.spaceMaker.mapImgRect.y) * this.spaceMaker.ratio;
return [cx, cy]
})
}
calcBorderRect() {
const xs = this.points.map(m => m[0])
const ys = this.points.map(m => m[1])
const minX = _.min(xs)
const maxX = _.max(xs)
const minY = _.min(ys)
const maxY = _.max(ys)
this.borderRect = new Rect(minX, minY, maxX - minX, maxY - minY)
}
draw() {
const ctx = this.spaceMaker.ctx
ctx.save()
ctx.beginPath()
ctx.moveTo(this.points[0][0], this.points[0][1])
for (let i = 1; i < this.points.length; i++) {
ctx.lineTo(this.points[i][0], this.points[i][1])
}
ctx.closePath();
ctx.fillStyle = this.bgColor;
if (this.isActive) {
ctx.fillStyle = this.hoverColor;
ctx.strokeStyle = Color(this.hoverColor).alpha(0.8).lighten(0.2)
ctx.lineWidth = 2;
ctx.stroke();
}
ctx.fill();
ctx.restore();
}
hit(pos) {
let px = pos.x, py = pos.y, flag = false;
let poly = this.points.map(m => new Point(m[0], m[1]))
for (let i = 0, l = poly.length, j = l - 1; i < l; j = i, i++) {
let sx = poly[i].x,
sy = poly[i].y,
tx = poly[j].x,
ty = poly[j].y
// 点与多边形顶点重合
if ((sx === px && sy === py) || (tx === px && ty === py)) {
return true
}
// 判断线段两端点是否在射线两侧
if ((sy < py && ty >= py) || (sy >= py && ty < py)) {
// 线段上与射线 Y 坐标相同的点的 X 坐标
let x = sx + (py - sy) * (tx - sx) / (ty - sy)
// 点在多边形的边上
if (x === px) {
return true
}
// 射线穿过多边形的边界
if (x > px) {
flag = !flag
}
}
}
// 射线穿过多边形边界的次数为奇数时点在多边形内
return flag
}
setActive(isActive) {
this.isActive = isActive;
this.spaceMaker.drawMapImage()
}
}
再看看在项目中如何使用
<script setup>
const drawRef = ref(null)
const drawCanvas = ref(null)
drawCanvas.value = new SpaceMaker(drawRef.value, {
marks: [
{
center: {
x: 500,
y: 550
},
padding: 0,
radius: 12,
highlight: false,
icon: 'device_normal', // 我这里使用的是内置的资源,可以用网络地址的图片
ext: null
}
],
ranges: [
{
bgColor: #000,
points: [], // 二维数组
ext: {}
},
mapImgUrl: 图片地址
})
</script>
<template>
<div>
<div ref="drawRef" class="space"></div>
</div>
</template>
<style lang="scss" scoped>
.space {
width: 100%;
height: 100vh;
}
</style>
实现的效果是这样的, 范围可以自定义title
点位效果
好了 分享就到此结束了,github地址在这里,觉得可以的话 请给个Star⭐️,谢谢~
github地址:github.com/qfengzzZ/sp…