【canvas】3D 标签云

 标签云的概念是从一个博客看到的,当时博主是用 canvas 实现的,我那时候的水平也只能通过 html + css 实现。

这两天碰到项目中有行星轨迹需求,想起来原来看到的标签云,觉得挺像的,于是自己用 canvas 实现了一下。

原理分析

其实就是简单的坐标系转换,涉及到数学部分知识,主要是螺旋线均布算法。

实现

只能算是实现了,代码阻止逻辑不太好,细节处理不太好,凑活看看。

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<title>3D 标签云</title>
	<style>
		#canvas {
			width: 500px;
			height: 500px;
			border: 1px solid;
		}
		#canvas.hover {
			cursor: pointer;
		}
	</style>
</head>
<body>
	<canvas id="canvas" width="500" height="500"></canvas>
	<button id="start">开始动画</button>
	<button id="stop">停止动画</button>
	<script>
		(function() {
			// 接口对象实现
			function Interface(name, methods) {
				if (!methods || Object.prototype.toString.call(methods) !== '[object Array]' || methods.length === 0) {
					throw new Error('Interface required methods')
				}
				this.name = name
				this.methods = methods
			}
			Interface.ensureImplements = function(instance) {
				if (arguments.length === 1) {
					throw new Error('Function Interface.ensureImplements called with 1 argument, but excepted at least 2')
				}

				for (var i = 1, len = arguments.length; i < len; i++) {
					var interface = arguments[i]
					if (!interface instanceof Interface) {
						throw new Error('Interface.ensureImplements excepted arguments above to be instance of Interface')
					}

					for (var j = 0, length = interface.methods.length; j < length; j++) {
						var method = interface.methods[j]
						if (!instance[method] || typeof instance[method] !== 'function') {
							throw new Error('Function Interface.ensureImplements: object does not implement the ' + 
								interface.name + 
								' interface, method ' + 
								method + 
								' does not found')
						}
					}
				}
			}
			// 接口对象定义
			var StageInterface = new Interface('StageInterface', ['animate', 'addChild'])
			var TagInterface = new Interface('TagInterface', ['setRotate', 'setSpeed', 'translate', 'draw'])

			// 类式继承 extend 函数
			function extend(subClass, superClass) {
				// 用一个空函数引用父类原型
				// 因为是空函数,所以在构造过程中不会出现因参数问题导致的报错
				var F = function() {}
				F.prototype = superClass.prototype
				// 子类继承
				subClass.prototype = new F()
				// 重设 constructor
				subClass.constructor = subClass
				// 存储父类构造函数
				subClass.superClass = superClass
			}

			// 订阅发布者
			function Observer() {
				this.subs = {}
			}
			Observer.prototype.addSub = function(name, func) {
				if (!this.subs[name]) {
	        		this.subs[name] = []
	        	}
	        	this.subs[name].push(func)
			}
			Observer.prototype.fire = function(name, data) {
	        	var listeners = this.subs[name] || []
	        	for (var i = 0, len = listeners.length; i < len; i++) {
	        		listeners[i](data)
	        	}
	        }

			// 鼠标位移计算对象
			function MouseTransform(target, origin) {
				MouseTransform.superClass.call(this)

				this.target = target
				this.origin = origin || { x: 0, y: 0 }
				var self = this
				this.target.addEventListener('mousemove', function(event) {
					self.transform(event)
				})
	        }
	        extend(MouseTransform, Observer)
	        // 计算函数
	        MouseTransform.prototype.transform = function(event) {
	        	var offsetX = event.offsetX - this.origin.x,
	        	    offsetY = event.offsetY - this.origin.y;

	        	var rotateR = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
	        	var φ = Math.acos(offsetY / rotateR);
	        	if (offsetX < 0) {
	        		φ *= -1
	        	}
	        	// 自转轴角度
	        	var rotate = φ + Math.PI / 2
	        	// 发布订阅
	        	this.fire('polar', {
	        		angle: rotate,
	        		r: rotateR
	        	})
	        	this.fire('cartesian', {
	        		x: offsetX,
	        		y: offsetY
	        	})
	        }

			// 动画舞台
			function Stage(canvas, options) {
				if (options === undefined) {
					throw new Error('Stage class required options arugment')
				}
				// canvas
				this.canvas = canvas
				// context
				this.context = this.canvas.getContext('2d')
				// 子元素集合
				this.children = []
				this.addChildren(options.children || [])

				// 动画判断 flag
				this.isRunning = false

				// 坐标系
				this.width = this.canvas.width
				this.height = this.canvas.height
				this.origin = options.origin || {x: 0, y: 0}

				// 鼠标位置
				this.mousePosition = {}
				var self = this
				var mouseTransform = new MouseTransform(this.canvas, this.origin)
				mouseTransform.addSub('cartesian', function(mousePos) {
					self.mousePosition.x = mousePos.x
					self.mousePosition.y = mousePos.y
				})
				mouseTransform.addSub('polar', function(data) {
					self.mousePosition.angle = data.angle
					self.mousePosition.r = data.r
				})
				// 初始化时不一定存在mousemove事件,所以先模拟一个
				var mouseEvent = new MouseEvent('mousemove', {
					clientX: this.width,
					clientY: this.height / 2
				})
				this.canvas.dispatchEvent(mouseEvent)
			}
			// 添加子元素
			Stage.prototype.addChild = function(child) {
				// if (Interface.ensureImplements(child, TagInterface)) {
				// 	child.$parent = this
				// 	this.children.push(child)
				// } else {
				// 	throw new Error('Stage method addChild required a TagInstance implement TagInterface')
				// }
				child.$stage = this
				this.children.push(child)
			}
			Stage.prototype.addChildren = function(children) {
				for (var i = 0, len = children.length; i < len; i++) {
					this.addChild(children[i])
				}
			}
			// 获取舞台鼠标位置信息
			Stage.prototype.getMousePosition = function() {
				return this.mousePosition
			}
			// 子元素绘制函数
			Stage.prototype.draw = function() {
	        	for (var i = 0, len = this.children.length; i < len; i++) {
					this.children[i].draw(this.context)
				}
			}
			// 舞台开始动画函数
			Stage.prototype.start = function() {
				this.isPause = false
				if (!this.isRunning) {
					var self = this
					self.isRunning = true
					self.context.save()
					self.context.translate(self.origin.x, self.origin.y)
					var animate = function() {
						if (self.isRunning) {
							if (!self.isPause) {
								self.context.clearRect(-self.origin.x, -self.origin.y, self.width, self.height)
								self.draw()
							}
							window.requestAnimationFrame(animate)
						}
					}
					animate()
				}
			}
			// 舞台暂停动画
			Stage.prototype.pause = function() {
				this.children.forEach(function(child) {
					child.fire('pause')
				})
			}
			// 舞台停止动画
			Stage.prototype.stop = function() {
				this.isRunning = false
				this.context.restore()
			}


			// 标签对象
			function Tag(x, y, z, r, speed, rotate, label, href, fontSize) {
				Tag.superClass.call(this)
				// 球坐标信息
				this.x = x
				this.y = y
				this.z = z
				this.r = r
				this.speed = speed
				this.rotate = rotate
				
				// 显示信息
				this.info = {
					label: label,
					href: href,
					fontSize: fontSize,
				}

				this.pos = {}

				this.isPause = false
			}
			extend(Tag, Observer)
			// 设置标签自转轴角度接口
			Tag.prototype.setRotate = function(angle) {
				this.rotate = angle
			}
			// 设置标签自转速度接口
			Tag.prototype.setSpeed = function(speed) {
				this.speed = speed
			}
			// 标签对象位置变换函数
			Tag.prototype.translate = function() {
				if (!this.isPause) {
					// 从舞台获取 mouse 位置信息
					this.setRotate(this.$stage.mousePosition.angle)
					this.setSpeed(this.$stage.mousePosition.r / 250 * Math.PI / 360)
					// 绕 z 轴旋转坐标系
					var x1 = this.x * Math.cos(-this.rotate) + this.y * Math.sin(-this.rotate),
		                y1 = this.y * Math.cos(-this.rotate) - this.x * Math.sin(-this.rotate);

		            // 绕 y 轴旋转
		            var x2 = x1 * Math.cos(this.speed) + this.z * Math.sin(this.speed);
		            this.z = this.z * Math.cos(this.speed) - x1 * Math.sin(this.speed);

		            // 复原坐标系
		            this.x = x2 * Math.cos(this.rotate) + y1 * Math.sin(this.rotate);
		            this.y = y1 * Math.cos(this.rotate) - x2 * Math.sin(this.rotate);
				} else {
					this.isPause = false
				}
			}
			// 设置标签块位置大小
			Tag.prototype.setPosition = function(textWidth) {
				this.pos.x = this.x * this.r - textWidth / 2
	        	this.pos.y = this.y * this.r - this.info.fontSize / 2
	        	this.pos.z = this.z
	        	this.pos.w = textWidth
	        	this.pos.h = this.info.fontSize
			}
			// 标签是否被悬停
			Tag.prototype.isHover = function() {
				var flag = false
				var mousePos = this.$stage.getMousePosition()
				if (mousePos.x > this.pos.x &&
					mousePos.x < this.pos.x + this.pos.w &&
					mousePos.y > this.pos.y &&
					mousePos.y < this.pos.y + this.pos.h &&
					this.pos.z > 0) {
					flag = true
					this.fire('hover')
				}
				return flag
			}
			// 标签绘制接口
			Tag.prototype.draw = function(context) {
				context.textAlign = 'center';
	        	context.textBaseline = 'middle';
	        	context.font = 'bold ' + this.info.fontSize + 'px Arial';
	        	this.setPosition(context.measureText(this.info.label).width)
	        	if (this.isHover()) {
	        		context.fillStyle = "rgba(255, 0, 0, " + (this.z * 0.45 + 0.55) + ")";
	        	} else {
	        		context.fillStyle = "rgba(0, 0, 0, " + (this.z * 0.45 + 0.55) + ")";
	        	}
	        	context.fillText(this.info.label, this.x * this.r, this.y * this.r);

	        	this.translate()
			}

			// 标签创建函数
			function createTags(count, rotateCount, rotateR, rotateZ) {
				var tags = []
				for (var i = 0; i <= count; i++) {
					var θ, φ, r, x, y, z;
		    		y = 2 * i / count - 1 // 计算 y,它平均分布在螺旋曲线上
				    φ = y * Math.PI * rotateCount // 计算方位角
				    θ = Math.acos(y) // 计算天顶角
				    r = Math.sin(θ) //计算旋转半径
				    x = Math.sin(φ) * r // x坐标
				    z = Math.cos(φ) * r // z坐标

				    label = 'tag_' + i
				    href = 'https://www.baidu.com'
				    fontSize = 18
					
					tags.push(new Tag(x, y, z, rotateR, Math.PI / 360, 0, label, href, fontSize))
				}
				return tags
			}

			// 初始化函数
			function init() {
				// canvas 元素
				var canvasEl = document.querySelector('#canvas')

				// 标签数量
				var tagCount = 56
				// 均布圈数
				var rotateCount = 6
				// 运动半径
				var rotateR = 200;

				// 计算初始标签
				var tags = createTags(tagCount, rotateCount, rotateR)

				// 新建舞台
				var stage = new Stage(canvasEl, {
					children: tags,
					origin: {
						x: 250,
						y: 250
					}
				})

				document.querySelector('#canvas').addEventListener('mousemove', function(event) {
					var isPause = tags.some(function(tag) {
						return tag.isPause
					})
					event.target.className = isPause ? 'hover' : ''
				})
				document.querySelector('#canvas').addEventListener('click', function(event) {
					tags.forEach(function(tag) {
						if (tag.isHover()) {
							window.open(tag.info.href)
							event.target.dispatchEvent(new MouseEvent('mousemove', {
								clientX: 500,
								clientY: 250
							}))
						}
					})
				})

				tags.forEach(function(tag) {
					// 标签悬停时
					tag.addSub('hover', function() {
						tags.forEach(function(tag) {
							tag.isPause = true
						})
					})
				})

				document.querySelector('#start').addEventListener('click', function() {
					stage.start()
				})
				document.querySelector('#stop').addEventListener('click', function() {
					stage.stop()
				})
			}

			init()
		})()
	</script>
</body>
</html>