标签云的概念是从一个博客看到的,当时博主是用 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>