深入学习Canvas数据可视化技术,包括地图可视化、网络图、热力图、科学可视化等高级数据展示技术 数据可视化是将复杂数据转换为直观图形的技术,Canvas提供了高性能的绑制能力,适合处理大规模数据可视化需求。
class DataVisualization {
constructor(container, options = {}) {
this.container = typeof container === 'string'
? document.querySelector(container)
: container
this.options = {
width: 800,
height: 600,
backgroundColor: '#ffffff',
padding: { top: 40, right: 40, bottom: 40, left: 40 },
responsive: true,
...options
}
this.data = null
this.scales = {}
this.animation = { duration: 1000, easing: 'easeOutQuart' }
this.init()
}
init() {
this.createCanvas()
this.setupContext()
if (this.options.responsive) {
this.setupResize()
}
}
createCanvas() {
this.canvas = document.createElement('canvas')
this.canvas.width = this.options.width * window.devicePixelRatio
this.canvas.height = this.options.height * window.devicePixelRatio
this.canvas.style.width = this.options.width + 'px'
this.canvas.style.height = this.options.height + 'px'
this.container.appendChild(this.canvas)
}
setupContext() {
this.ctx = this.canvas.getContext('2d')
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
}
setupResize() {
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect
this.resize(width, height)
}
})
resizeObserver.observe(this.container)
}
resize(width, height) {
this.options.width = width
this.options.height = height
this.canvas.width = width * window.devicePixelRatio
this.canvas.height = height * window.devicePixelRatio
this.canvas.style.width = width + 'px'
this.canvas.style.height = height + 'px'
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio)
this.render()
}
setData(data) {
this.data = data
this.processData()
return this
}
processData() {}
calculateScales() {}
render() {
this.clear()
this.drawBackground()
this.drawContent()
}
clear() {
this.ctx.clearRect(0, 0, this.options.width, this.options.height)
}
drawBackground() {
this.ctx.fillStyle = this.options.backgroundColor
this.ctx.fillRect(0, 0, this.options.width, this.options.height)
}
drawContent() {}
getChartArea() {
const { padding } = this.options
return {
x: padding.left,
y: padding.top,
width: this.options.width - padding.left - padding.right,
height: this.options.height - padding.top - padding.bottom
}
}
}
class MapVisualization extends DataVisualization {
constructor(container, options = {}) {
super(container, {
projection: 'mercator',
center: [0, 0],
zoom: 1,
showGraticule: true,
showLabels: true,
...options
})
this.geoData = null
this.projection = null
}
setGeoData(geojson) {
this.geoData = geojson
this.setupProjection()
return this
}
setupProjection() {
const { projection, center, zoom } = this.options
const area = this.getChartArea()
this.projection = {
type: projection,
center: center,
zoom: zoom,
width: area.width,
height: area.height
}
}
project(lon, lat) {
const { center, zoom, width, height } = this.projection
const x = (lon - center[0]) * zoom + width / 2
const y = (center[1] - lat) * zoom + height / 2
return { x, y }
}
drawContent() {
if (!this.geoData) return
const ctx = this.ctx
const area = this.getChartArea()
ctx.save()
ctx.translate(area.x, area.y)
if (this.options.showGraticule) {
this.drawGraticule()
}
this.drawFeatures()
if (this.options.showLabels) {
this.drawLabels()
}
ctx.restore()
}
drawGraticule() {
const ctx = this.ctx
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 0.5
for (let lon = -180; lon <= 180; lon += 30) {
ctx.beginPath()
for (let lat = -90; lat <= 90; lat += 1) {
const pos = this.project(lon, lat)
if (lat === -90) {
ctx.moveTo(pos.x, pos.y)
} else {
ctx.lineTo(pos.x, pos.y)
}
}
ctx.stroke()
}
for (let lat = -90; lat <= 90; lat += 30) {
ctx.beginPath()
for (let lon = -180; lon <= 180; lon += 1) {
const pos = this.project(lon, lat)
if (lon === -180) {
ctx.moveTo(pos.x, pos.y)
} else {
ctx.lineTo(pos.x, pos.y)
}
}
ctx.stroke()
}
}
drawFeatures() {
const ctx = this.ctx
const features = this.geoData.features || [this.geoData]
features.forEach((feature, index) => {
const geometry = feature.geometry
if (!geometry) return
ctx.fillStyle = this.getFeatureColor(feature, index)
ctx.strokeStyle = '#666666'
ctx.lineWidth = 1
if (geometry.type === 'Polygon') {
this.drawPolygon(geometry.coordinates)
} else if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach(coords => {
this.drawPolygon(coords)
})
}
})
}
drawPolygon(coordinates) {
const ctx = this.ctx
ctx.beginPath()
coordinates[0].forEach((coord, i) => {
const pos = this.project(coord[0], coord[1])
if (i === 0) {
ctx.moveTo(pos.x, pos.y)
} else {
ctx.lineTo(pos.x, pos.y)
}
})
ctx.closePath()
ctx.fill()
ctx.stroke()
}
getFeatureColor(feature, index) {
const colors = [
'#4e79a7', '#f28e2c', '#e15759', '#76b7b2',
'#59a14f', '#edc949', '#af7aa1', '#ff9da7'
]
return colors[index % colors.length]
}
drawLabels() {
const ctx = this.ctx
const features = this.geoData.features || [this.geoData]
ctx.fillStyle = '#333333'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
features.forEach(feature => {
if (!feature.properties || !feature.properties.name) return
const center = this.getFeatureCenter(feature)
if (!center) return
const pos = this.project(center.lon, center.lat)
ctx.fillText(feature.properties.name, pos.x, pos.y)
})
}
getFeatureCenter(feature) {
const geometry = feature.geometry
if (!geometry) return null
let coords = []
if (geometry.type === 'Polygon') {
coords = geometry.coordinates[0]
} else if (geometry.type === 'MultiPolygon') {
coords = geometry.coordinates[0][0]
}
if (coords.length === 0) return null
let sumLon = 0, sumLat = 0
coords.forEach(coord => {
sumLon += coord[0]
sumLat += coord[1]
})
return {
lon: sumLon / coords.length,
lat: sumLat / coords.length
}
}
}
class HeatmapLayer {
constructor(mapViz, options = {}) {
this.mapViz = mapViz
this.options = {
radius: 25,
blur: 15,
gradient: {
0.0: 'rgba(0, 0, 255, 0)',
0.2: 'rgba(0, 0, 255, 1)',
0.4: 'rgba(0, 255, 255, 1)',
0.6: 'rgba(0, 255, 0, 1)',
0.8: 'rgba(255, 255, 0, 1)',
1.0: 'rgba(255, 0, 0, 1)'
},
max: 1,
...options
}
this.points = []
this.heatmapCanvas = null
}
setData(points) {
this.points = points
return this
}
render() {
if (!this.heatmapCanvas) {
this.createHeatmapCanvas()
}
this.drawHeatmap()
this.mapViz.ctx.drawImage(this.heatmapCanvas, 0, 0)
}
createHeatmapCanvas() {
this.heatmapCanvas = document.createElement('canvas')
this.heatmapCanvas.width = this.mapViz.options.width
this.heatmapCanvas.height = this.mapViz.options.height
this.heatmapCtx = this.heatmapCanvas.getContext('2d')
}
drawHeatmap() {
const ctx = this.heatmapCtx
const { radius, blur, max } = this.options
ctx.clearRect(0, 0, this.heatmapCanvas.width, this.heatmapCanvas.height)
this.points.forEach(point => {
const pos = this.mapViz.project(point.lon, point.lat)
const intensity = point.value / max
const gradient = ctx.createRadialGradient(
pos.x, pos.y, 0,
pos.x, pos.y, radius
)
gradient.addColorStop(0, `rgba(0, 0, 0, ${intensity})`)
gradient.addColorStop(1, 'rgba(0, 0, 0, 0)')
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2)
ctx.fill()
})
this.colorize()
}
colorize() {
const ctx = this.heatmapCtx
const imageData = ctx.getImageData(
0, 0,
this.heatmapCanvas.width,
this.heatmapCanvas.height
)
const data = imageData.data
const gradient = this.createGradientLookup()
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
if (alpha === 0) continue
const color = gradient[Math.floor(alpha / 255 * 255)]
data[i] = color.r
data[i + 1] = color.g
data[i + 2] = color.b
data[i + 3] = alpha
}
ctx.putImageData(imageData, 0, 0)
}
createGradientLookup() {
const lookup = []
const { gradient } = this.options
for (let i = 0; i < 256; i++) {
const t = i / 255
let color = { r: 0, g: 0, b: 0 }
const stops = Object.keys(gradient).map(Number).sort((a, b) => a - b)
for (let j = 0; j < stops.length - 1; j++) {
if (t >= stops[j] && t <= stops[j + 1]) {
const range = stops[j + 1] - stops[j]
const localT = (t - stops[j]) / range
color = this.interpolateColor(
this.parseColor(gradient[stops[j]]),
this.parseColor(gradient[stops[j + 1]]),
localT
)
break
}
}
lookup.push(color)
}
return lookup
}
parseColor(str) {
const match = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
if (match) {
return {
r: parseInt(match[1]),
g: parseInt(match[2]),
b: parseInt(match[3])
}
}
return { r: 0, g: 0, b: 0 }
}
interpolateColor(c1, c2, t) {
return {
r: Math.round(c1.r + (c2.r - c1.r) * t),
g: Math.round(c1.g + (c2.g - c1.g) * t),
b: Math.round(c1.b + (c2.b - c1.b) * t)
}
}
}
class ForceGraph extends DataVisualization {
constructor(container, options = {}) {
super(container, {
nodeRadius: 10,
linkDistance: 100,
linkStrength: 1,
chargeStrength: -300,
centerForce: 0.1,
collisionRadius: 20,
...options
})
this.nodes = []
this.links = []
this.simulation = null
this.selectedNode = null
this.hoveredNode = null
}
setData(data) {
this.nodes = data.nodes.map((n, i) => ({
id: n.id || i,
...n,
x: n.x || this.options.width / 2 + (Math.random() - 0.5) * 100,
y: n.y || this.options.height / 2 + (Math.random() - 0.5) * 100,
vx: 0,
vy: 0
}))
this.links = data.links.map(l => ({
source: typeof l.source === 'object' ? l.source.id : l.source,
target: typeof l.target === 'object' ? l.target.id : l.target,
...l
}))
this.buildNodeIndex()
this.startSimulation()
return this
}
buildNodeIndex() {
this.nodeIndex = {}
this.nodes.forEach(node => {
this.nodeIndex[node.id] = node
})
this.links.forEach(link => {
link.source = this.nodeIndex[link.source]
link.target = this.nodeIndex[link.target]
})
}
startSimulation() {
this.simulation = {
alpha: 1,
alphaDecay: 0.02,
alphaMin: 0.001,
velocityDecay: 0.4
}
this.animate()
}
animate() {
if (this.simulation.alpha < this.simulation.alphaMin) {
this.render()
return
}
this.tick()
this.render()
this.simulation.alpha *= (1 - this.simulation.alphaDecay)
requestAnimationFrame(() => this.animate())
}
tick() {
const { chargeStrength, centerForce, linkDistance, linkStrength, collisionRadius } = this.options
const { alpha, velocityDecay } = this.simulation
this.applyLinkForces(linkDistance, linkStrength, alpha)
this.applyChargeForces(chargeStrength, alpha)
this.applyCenterForce(centerForce, alpha)
this.applyCollisionForce(collisionRadius, alpha)
this.nodes.forEach(node => {
if (node.fx !== undefined) {
node.x = node.fx
node.vx = 0
} else {
node.vx *= velocityDecay
node.x += node.vx
}
if (node.fy !== undefined) {
node.y = node.fy
node.vy = 0
} else {
node.vy *= velocityDecay
node.y += node.vy
}
})
}
applyLinkForces(distance, strength, alpha) {
this.links.forEach(link => {
const dx = link.target.x - link.source.x
const dy = link.target.y - link.source.y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const force = (dist - distance) * strength * alpha
const fx = (dx / dist) * force
const fy = (dy / dist) * force
link.source.vx += fx
link.source.vy += fy
link.target.vx -= fx
link.target.vy -= fy
})
}
applyChargeForces(strength, alpha) {
const nodes = this.nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x
const dy = nodes[j].y - nodes[i].y
const dist = Math.sqrt(dx * dx + dy * dy) || 1
const force = strength * alpha / (dist * dist)
const fx = (dx / dist) * force
const fy = (dy / dist) * force
nodes[i].vx -= fx
nodes[i].vy -= fy
nodes[j].vx += fx
nodes[j].vy += fy
}
}
}
applyCenterForce(strength, alpha) {
const cx = this.options.width / 2
const cy = this.options.height / 2
this.nodes.forEach(node => {
node.vx += (cx - node.x) * strength * alpha
node.vy += (cy - node.y) * strength * alpha
})
}
applyCollisionForce(radius, alpha) {
const nodes = this.nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const dx = nodes[j].x - nodes[i].x
const dy = nodes[j].y - nodes[i].y
const dist = Math.sqrt(dx * dx + dy * dy)
const minDist = radius * 2
if (dist < minDist && dist > 0) {
const force = (minDist - dist) * alpha * 0.5
const fx = (dx / dist) * force
const fy = (dy / dist) * force
nodes[i].x -= fx
nodes[i].y -= fy
nodes[j].x += fx
nodes[j].y += fy
}
}
}
}
drawContent() {
this.drawLinks()
this.drawNodes()
}
drawLinks() {
const ctx = this.ctx
this.links.forEach(link => {
ctx.beginPath()
ctx.moveTo(link.source.x, link.source.y)
ctx.lineTo(link.target.x, link.target.y)
ctx.strokeStyle = link.color || '#999999'
ctx.lineWidth = link.width || 1
ctx.stroke()
if (link.directed) {
this.drawArrow(link)
}
})
}
drawArrow(link) {
const ctx = this.ctx
const { source, target } = link
const dx = target.x - source.x
const dy = target.y - source.y
const angle = Math.atan2(dy, dx)
const radius = this.options.nodeRadius
const endX = target.x - Math.cos(angle) * radius
const endY = target.y - Math.sin(angle) * radius
const arrowSize = 8
ctx.beginPath()
ctx.moveTo(endX, endY)
ctx.lineTo(
endX - arrowSize * Math.cos(angle - Math.PI / 6),
endY - arrowSize * Math.sin(angle - Math.PI / 6)
)
ctx.lineTo(
endX - arrowSize * Math.cos(angle + Math.PI / 6),
endY - arrowSize * Math.sin(angle + Math.PI / 6)
)
ctx.closePath()
ctx.fillStyle = link.color || '#999999'
ctx.fill()
}
drawNodes() {
const ctx = this.ctx
const { nodeRadius } = this.options
this.nodes.forEach(node => {
const r = node.radius || nodeRadius
const isHovered = node === this.hoveredNode
const isSelected = node === this.selectedNode
ctx.beginPath()
ctx.arc(node.x, node.y, r, 0, Math.PI * 2)
if (isHovered || isSelected) {
ctx.fillStyle = node.highlightColor || '#ff6b6b'
} else {
ctx.fillStyle = node.color || '#4e79a7'
}
ctx.fill()
if (isHovered || isSelected) {
ctx.strokeStyle = '#333333'
ctx.lineWidth = 3
ctx.stroke()
}
if (node.label) {
ctx.fillStyle = '#333333'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
ctx.fillText(node.label, node.x, node.y + r + 5)
}
})
}
findNode(x, y) {
const { nodeRadius } = this.options
for (let i = this.nodes.length - 1; i >= 0; i--) {
const node = this.nodes[i]
const dx = x - node.x
const dy = y - node.y
const r = node.radius || nodeRadius
if (dx * dx + dy * dy < r * r) {
return node
}
}
return null
}
bindEvents() {
let dragNode = null
this.canvas.addEventListener('mousedown', e => {
const rect = this.canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
dragNode = this.findNode(x, y)
if (dragNode) {
dragNode.fx = dragNode.x
dragNode.fy = dragNode.y
this.selectedNode = dragNode
}
})
this.canvas.addEventListener('mousemove', e => {
const rect = this.canvas.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
if (dragNode) {
dragNode.fx = x
dragNode.fy = y
this.simulation.alpha = 0.3
this.animate()
} else {
const node = this.findNode(x, y)
if (node !== this.hoveredNode) {
this.hoveredNode = node
this.canvas.style.cursor = node ? 'pointer' : 'default'
this.render()
}
}
})
this.canvas.addEventListener('mouseup', () => {
if (dragNode) {
delete dragNode.fx
delete dragNode.fy
dragNode = null
this.simulation.alpha = 0.3
this.animate()
}
})
}
}
class TreeVisualization extends DataVisualization {
constructor(container, options = {}) {
super(container, {
orientation: 'vertical',
nodeRadius: 8,
levelGap: 100,
siblingGap: 50,
showLinks: true,
showLabels: true,
...options
})
this.root = null
this.nodePositions = new Map()
}
setData(data) {
this.root = this.buildTree(data)
this.calculateLayout()
return this
}
buildTree(data) {
const node = {
id: data.id,
name: data.name,
value: data.value,
children: [],
depth: 0,
parent: null
}
if (data.children) {
data.children.forEach(child => {
const childNode = this.buildTree(child)
childNode.depth = node.depth + 1
childNode.parent = node
node.children.push(childNode)
})
}
return node
}
calculateLayout() {
const { orientation, levelGap, siblingGap } = this.options
const depths = this.calculateDepths(this.root)
this.calculatePositions(this.root, 0, 0, depths, levelGap, siblingGap)
}
calculateDepths(node, depths = {}) {
if (!depths[node.depth]) {
depths[node.depth] = { count: 0, nodes: [] }
}
depths[node.depth].count++
depths[node.depth].nodes.push(node)
node.children.forEach(child => {
this.calculateDepths(child, depths)
})
return depths
}
calculatePositions(node, index, total, depths, levelGap, siblingGap) {
const { orientation } = this.options
const depth = node.depth
const depthInfo = depths[depth]
const totalAtDepth = depthInfo.count
const nodeIndex = depthInfo.nodes.indexOf(node)
if (orientation === 'vertical') {
node.x = this.options.width / 2 + (nodeIndex - totalAtDepth / 2) * siblingGap
node.y = this.options.padding.top + depth * levelGap
} else {
node.x = this.options.padding.left + depth * levelGap
node.y = this.options.height / 2 + (nodeIndex - totalAtDepth / 2) * siblingGap
}
this.nodePositions.set(node.id, { x: node.x, y: node.y })
node.children.forEach(child => {
this.calculatePositions(child, 0, 0, depths, levelGap, siblingGap)
})
}
drawContent() {
if (!this.root) return
if (this.options.showLinks) {
this.drawLinks(this.root)
}
this.drawNodes(this.root)
}
drawLinks(node) {
const ctx = this.ctx
node.children.forEach(child => {
ctx.beginPath()
if (this.options.orientation === 'vertical') {
ctx.moveTo(node.x, node.y)
ctx.bezierCurveTo(
node.x, (node.y + child.y) / 2,
child.x, (node.y + child.y) / 2,
child.x, child.y
)
} else {
ctx.moveTo(node.x, node.y)
ctx.bezierCurveTo(
(node.x + child.x) / 2, node.y,
(node.x + child.x) / 2, child.y,
child.x, child.y
)
}
ctx.strokeStyle = '#cccccc'
ctx.lineWidth = 2
ctx.stroke()
this.drawLinks(child)
})
}
drawNodes(node) {
const ctx = this.ctx
const { nodeRadius, showLabels } = this.options
const r = nodeRadius
ctx.beginPath()
ctx.arc(node.x, node.y, r, 0, Math.PI * 2)
ctx.fillStyle = this.getNodeColor(node)
ctx.fill()
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 2
ctx.stroke()
if (showLabels && node.name) {
ctx.fillStyle = '#333333'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
ctx.fillText(node.name, node.x, node.y + r + 5)
}
node.children.forEach(child => {
this.drawNodes(child)
})
}
getNodeColor(node) {
const depthColors = ['#4e79a7', '#f28e2c', '#e15759', '#76b7b2', '#59a14f']
return depthColors[node.depth % depthColors.length]
}
}
class ContourPlot extends DataVisualization {
constructor(container, options = {}) {
super(container, {
levels: 10,
colorScale: 'viridis',
showLabels: true,
smooth: true,
...options
})
this.gridData = null
this.contours = []
}
setGridData(data) {
this.gridData = data
this.calculateContours()
return this
}
calculateContours() {
const { levels } = this.options
const { values, width, height } = this.gridData
const min = Math.min(...values.flat())
const max = Math.max(...values.flat())
const step = (max - min) / levels
this.contours = []
for (let level = min; level <= max; level += step) {
const contour = this.marchingSquares(values, width, height, level)
this.contours.push({
level: level,
paths: contour
})
}
}
marchingSquares(values, width, height, level) {
const paths = []
for (let y = 0; y < height - 1; y++) {
for (let x = 0; x < width - 1; x++) {
const v0 = values[y][x]
const v1 = values[y][x + 1]
const v2 = values[y + 1][x + 1]
const v3 = values[y + 1][x]
const index = (v0 >= level ? 1 : 0) |
(v1 >= level ? 2 : 0) |
(v2 >= level ? 4 : 0) |
(v3 >= level ? 8 : 0)
if (index === 0 || index === 15) continue
const cellSize = this.getChartArea().width / (width - 1)
const offsetX = this.getChartArea().x
const offsetY = this.getChartArea().y
const points = this.getContourPoints(index, x, y, v0, v1, v2, v3, level, cellSize, offsetX, offsetY)
if (points.length > 0) {
paths.push(points)
}
}
}
return paths
}
getContourPoints(index, x, y, v0, v1, v2, v3, level, cellSize, offsetX, offsetY) {
const interpolate = (v1, v2, p1, p2) => {
const t = (level - v1) / (v2 - v1)
return {
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t
}
}
const p0 = { x: offsetX + x * cellSize, y: offsetY + y * cellSize }
const p1 = { x: offsetX + (x + 1) * cellSize, y: offsetY + y * cellSize }
const p2 = { x: offsetX + (x + 1) * cellSize, y: offsetY + (y + 1) * cellSize }
const p3 = { x: offsetX + x * cellSize, y: offsetY + (y + 1) * cellSize }
const edges = {
1: [interpolate(v0, v3, p0, p3), interpolate(v0, v1, p0, p1)],
2: [interpolate(v0, v1, p0, p1), interpolate(v1, v2, p1, p2)],
3: [interpolate(v0, v3, p0, p3), interpolate(v1, v2, p1, p2)],
4: [interpolate(v1, v2, p1, p2), interpolate(v2, v3, p2, p3)],
5: [[interpolate(v0, v1, p0, p1), interpolate(v2, v3, p2, p3)],
[interpolate(v0, v3, p0, p3), interpolate(v1, v2, p1, p2)]],
6: [interpolate(v0, v1, p0, p1), interpolate(v2, v3, p2, p3)],
7: [interpolate(v0, v3, p0, p3), interpolate(v2, v3, p2, p3)],
8: [interpolate(v2, v3, p2, p3), interpolate(v0, v3, p0, p3)],
9: [interpolate(v0, v1, p0, p1), interpolate(v2, v3, p2, p3)],
10: [[interpolate(v0, v1, p0, p1), interpolate(v1, v2, p1, p2)],
[interpolate(v0, v3, p0, p3), interpolate(v2, v3, p2, p3)]],
11: [interpolate(v1, v2, p1, p2), interpolate(v2, v3, p2, p3)],
12: [interpolate(v0, v3, p0, p3), interpolate(v1, v2, p1, p2)],
13: [interpolate(v0, v1, p0, p1), interpolate(v1, v2, p1, p2)],
14: [interpolate(v0, v3, p0, p3), interpolate(v0, v1, p0, p1)]
}
return edges[index] || []
}
drawContent() {
const ctx = this.ctx
const { showLabels, smooth } = this.options
this.contours.forEach((contour, i) => {
const color = this.getLevelColor(contour.level, i)
contour.paths.forEach(path => {
if (Array.isArray(path[0])) {
path.forEach(segment => {
this.drawContourPath(segment, color, smooth)
})
} else {
this.drawContourPath(path, color, smooth)
}
})
})
}
drawContourPath(points, color, smooth) {
if (points.length < 2) return
const ctx = this.ctx
ctx.beginPath()
ctx.strokeStyle = color
ctx.lineWidth = 1.5
if (smooth && points.length > 2) {
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length - 1; i++) {
const xc = (points[i].x + points[i + 1].x) / 2
const yc = (points[i].y + points[i + 1].y) / 2
ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc)
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y)
} else {
ctx.moveTo(points[0].x, points[0].y)
points.forEach(p => ctx.lineTo(p.x, p.y))
}
ctx.stroke()
}
getLevelColor(level, index) {
const colors = [
'#440154', '#482878', '#3e4989', '#31688e', '#26828e',
'#1f9e89', '#35b779', '#6ece58', '#b5de2b', '#fde725'
]
return colors[index % colors.length]
}
}
class Surface3DPlot extends DataVisualization {
constructor(container, options = {}) {
super(container, {
rotationX: -30,
rotationY: 45,
perspective: 1000,
showAxes: true,
showGrid: true,
colorScale: 'coolwarm',
...options
})
this.gridData = null
this.rotation = { x: options.rotationX, y: options.rotationY }
this.isDragging = false
this.lastMouse = { x: 0, y: 0 }
}
setGridData(data) {
this.gridData = data
return this
}
project3D(x, y, z) {
const { perspective } = this.options
const radX = this.rotation.x * Math.PI / 180
const radY = this.rotation.y * Math.PI / 180
const cosX = Math.cos(radX)
const sinX = Math.sin(radX)
const cosY = Math.cos(radY)
const sinY = Math.sin(radY)
const x1 = x * cosY - z * sinY
const z1 = x * sinY + z * cosY
const y1 = y * cosX - z1 * sinX
const z2 = y * sinX + z1 * cosX
const scale = perspective / (perspective + z2)
const area = this.getChartArea()
return {
x: area.x + area.width / 2 + x1 * scale,
y: area.y + area.height / 2 - y1 * scale,
z: z2,
scale: scale
}
}
drawContent() {
if (!this.gridData) return
if (this.options.showAxes) {
this.drawAxes()
}
if (this.options.showGrid) {
this.drawGrid()
}
this.drawSurface()
}
drawAxes() {
const ctx = this.ctx
const axisLength = 100
const axes = [
{ start: [0, 0, 0], end: [axisLength, 0, 0], color: '#ff0000', label: 'X' },
{ start: [0, 0, 0], end: [0, axisLength, 0], color: '#00ff00', label: 'Y' },
{ start: [0, 0, 0], end: [0, 0, axisLength], color: '#0000ff', label: 'Z' }
]
axes.forEach(axis => {
const p1 = this.project3D(...axis.start)
const p2 = this.project3D(...axis.end)
ctx.beginPath()
ctx.moveTo(p1.x, p1.y)
ctx.lineTo(p2.x, p2.y)
ctx.strokeStyle = axis.color
ctx.lineWidth = 2
ctx.stroke()
ctx.fillStyle = axis.color
ctx.font = 'bold 14px sans-serif'
ctx.fillText(axis.label, p2.x + 5, p2.y - 5)
})
}
drawGrid() {
const ctx = this.ctx
const { values, width, height } = this.gridData
const area = this.getChartArea()
const scaleX = area.width / (width - 1)
const scaleY = area.height / (height - 1)
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 0.5
for (let i = 0; i < width; i++) {
ctx.beginPath()
for (let j = 0; j < height; j++) {
const x = (i - width / 2) * scaleX / 3
const z = (j - height / 2) * scaleY / 3
const y = values[j][i] * 50
const p = this.project3D(x, y, z)
if (j === 0) {
ctx.moveTo(p.x, p.y)
} else {
ctx.lineTo(p.x, p.y)
}
}
ctx.stroke()
}
for (let j = 0; j < height; j++) {
ctx.beginPath()
for (let i = 0; i < width; i++) {
const x = (i - width / 2) * scaleX / 3
const z = (j - height / 2) * scaleY / 3
const y = values[j][i] * 50
const p = this.project3D(x, y, z)
if (i === 0) {
ctx.moveTo(p.x, p.y)
} else {
ctx.lineTo(p.x, p.y)
}
}
ctx.stroke()
}
}
drawSurface() {
const ctx = this.ctx
const { values, width, height } = this.gridData
const area = this.getChartArea()
const scaleX = area.width / (width - 1)
const scaleY = area.height / (height - 1)
const faces = []
for (let j = 0; j < height - 1; j++) {
for (let i = 0; i < width - 1; i++) {
const x0 = (i - width / 2) * scaleX / 3
const z0 = (j - height / 2) * scaleY / 3
const x1 = ((i + 1) - width / 2) * scaleX / 3
const z1 = ((j + 1) - height / 2) * scaleY / 3
const y00 = values[j][i] * 50
const y10 = values[j][i + 1] * 50
const y01 = values[j + 1][i] * 50
const y11 = values[j + 1][i + 1] * 50
const p00 = this.project3D(x0, y00, z0)
const p10 = this.project3D(x1, y10, z0)
const p01 = this.project3D(x0, y01, z1)
const p11 = this.project3D(x1, y11, z1)
const avgZ = (p00.z + p10.z + p01.z + p11.z) / 4
const avgY = (y00 + y10 + y01 + y11) / 4
faces.push({
points: [p00, p10, p11, p01],
depth: avgZ,
value: avgY
})
}
}
faces.sort((a, b) => b.depth - a.depth)
faces.forEach(face => {
const { points, value } = face
ctx.beginPath()
ctx.moveTo(points[0].x, points[0].y)
points.forEach(p => ctx.lineTo(p.x, p.y))
ctx.closePath()
ctx.fillStyle = this.getSurfaceColor(value)
ctx.fill()
ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)'
ctx.lineWidth = 0.5
ctx.stroke()
})
}
getSurfaceColor(value) {
const normalized = (value / 50 + 1) / 2
const r = Math.round(255 * normalized)
const b = Math.round(255 * (1 - normalized))
return `rgb(${r}, 100, ${b})`
}
bindEvents() {
this.canvas.addEventListener('mousedown', e => {
this.isDragging = true
this.lastMouse = { x: e.clientX, y: e.clientY }
})
this.canvas.addEventListener('mousemove', e => {
if (!this.isDragging) return
const dx = e.clientX - this.lastMouse.x
const dy = e.clientY - this.lastMouse.y
this.rotation.y += dx * 0.5
this.rotation.x += dy * 0.5
this.lastMouse = { x: e.clientX, y: e.clientY }
this.render()
})
this.canvas.addEventListener('mouseup', () => {
this.isDragging = false
})
this.canvas.addEventListener('mouseleave', () => {
this.isDragging = false
})
}
}
class RealtimeLineChart extends DataVisualization {
constructor(container, options = {}) {
super(container, {
maxPoints: 100,
updateInterval: 50,
showGrid: true,
showAxis: true,
smoothLine: true,
fillArea: true,
...options
})
this.dataPoints = []
this.isRunning = false
this.lastUpdate = 0
this.valueRange = { min: Infinity, max: -Infinity }
}
addPoint(value, timestamp = Date.now()) {
this.dataPoints.push({ value, timestamp })
if (this.dataPoints.length > this.options.maxPoints) {
this.dataPoints.shift()
}
this.updateRange()
return this
}
updateRange() {
this.valueRange = {
min: Math.min(...this.dataPoints.map(p => p.value)),
max: Math.max(...this.dataPoints.map(p => p.value))
}
const padding = (this.valueRange.max - this.valueRange.min) * 0.1
this.valueRange.min -= padding
this.valueRange.max += padding
}
startDataStream(generator) {
this.isRunning = true
this.generator = generator
this.update()
}
stopDataStream() {
this.isRunning = false
}
update() {
if (!this.isRunning) return
const now = Date.now()
if (now - this.lastUpdate >= this.options.updateInterval) {
const value = this.generator()
this.addPoint(value)
this.render()
this.lastUpdate = now
}
requestAnimationFrame(() => this.update())
}
drawContent() {
if (this.dataPoints.length < 2) return
if (this.options.showGrid) {
this.drawGrid()
}
if (this.options.showAxis) {
this.drawAxis()
}
this.drawLine()
if (this.options.fillArea) {
this.drawArea()
}
this.drawCurrentPoint()
}
drawGrid() {
const ctx = this.ctx
const area = this.getChartArea()
ctx.strokeStyle = '#f0f0f0'
ctx.lineWidth = 1
for (let i = 0; i <= 5; i++) {
const y = area.y + (area.height / 5) * i
ctx.beginPath()
ctx.moveTo(area.x, y)
ctx.lineTo(area.x + area.width, y)
ctx.stroke()
}
for (let i = 0; i <= 10; i++) {
const x = area.x + (area.width / 10) * i
ctx.beginPath()
ctx.moveTo(x, area.y)
ctx.lineTo(x, area.y + area.height)
ctx.stroke()
}
}
drawAxis() {
const ctx = this.ctx
const area = this.getChartArea()
const { min, max } = this.valueRange
ctx.fillStyle = '#666666'
ctx.font = '11px sans-serif'
ctx.textAlign = 'right'
ctx.textBaseline = 'middle'
for (let i = 0; i <= 5; i++) {
const y = area.y + (area.height / 5) * i
const value = max - ((max - min) / 5) * i
ctx.fillText(value.toFixed(1), area.x - 10, y)
}
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const now = Date.now()
const timeRange = this.options.maxPoints * this.options.updateInterval
for (let i = 0; i <= 5; i++) {
const x = area.x + (area.width / 5) * i
const time = now - timeRange + (timeRange / 5) * i
const date = new Date(time)
ctx.fillText(
`${date.getMinutes()}:${date.getSeconds().toString().padStart(2, '0')}`,
x,
area.y + area.height + 10
)
}
}
drawLine() {
const ctx = this.ctx
const area = this.getChartArea()
const { min, max } = this.valueRange
const { smoothLine } = this.options
const points = this.dataPoints.map((p, i) => ({
x: area.x + (i / (this.options.maxPoints - 1)) * area.width,
y: area.y + area.height - ((p.value - min) / (max - min)) * area.height
}))
ctx.beginPath()
ctx.strokeStyle = '#4e79a7'
ctx.lineWidth = 2
if (smoothLine) {
ctx.moveTo(points[0].x, points[0].y)
for (let i = 1; i < points.length - 1; i++) {
const xc = (points[i].x + points[i + 1].x) / 2
const yc = (points[i].y + points[i + 1].y) / 2
ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc)
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y)
} else {
points.forEach((p, i) => {
if (i === 0) ctx.moveTo(p.x, p.y)
else ctx.lineTo(p.x, p.y)
})
}
ctx.stroke()
this.linePoints = points
}
drawArea() {
if (!this.linePoints || this.linePoints.length < 2) return
const ctx = this.ctx
const area = this.getChartArea()
const gradient = ctx.createLinearGradient(0, area.y, 0, area.y + area.height)
gradient.addColorStop(0, 'rgba(78, 121, 167, 0.3)')
gradient.addColorStop(1, 'rgba(78, 121, 167, 0)')
ctx.beginPath()
ctx.moveTo(this.linePoints[0].x, area.y + area.height)
this.linePoints.forEach(p => ctx.lineTo(p.x, p.y))
ctx.lineTo(this.linePoints[this.linePoints.length - 1].x, area.y + area.height)
ctx.closePath()
ctx.fillStyle = gradient
ctx.fill()
}
drawCurrentPoint() {
if (!this.linePoints || this.linePoints.length === 0) return
const ctx = this.ctx
const lastPoint = this.linePoints[this.linePoints.length - 1]
const lastData = this.dataPoints[this.dataPoints.length - 1]
ctx.beginPath()
ctx.arc(lastPoint.x, lastPoint.y, 5, 0, Math.PI * 2)
ctx.fillStyle = '#4e79a7'
ctx.fill()
ctx.strokeStyle = '#ffffff'
ctx.lineWidth = 2
ctx.stroke()
ctx.fillStyle = '#333333'
ctx.font = 'bold 12px sans-serif'
ctx.textAlign = 'left'
ctx.fillText(
lastData.value.toFixed(2),
lastPoint.x + 10,
lastPoint.y
)
}
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas 数据可视化演示</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
}
.demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 20px;
margin-top: 20px;
}
.demo-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 20px;
}
.demo-card h3 {
margin: 0 0 15px 0;
color: #333;
}
.chart-container {
width: 100%;
height: 400px;
position: relative;
}
.controls {
margin-top: 15px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
background: #4e79a7;
color: white;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #3d6390;
}
button.active {
background: #e15759;
}
</style>
</head>
<body>
<div class="container">
<h1>Canvas 数据可视化演示</h1>
<div class="demo-grid">
<div class="demo-card">
<h3>力导向网络图</h3>
<div class="chart-container" id="force-graph"></div>
<div class="controls">
<button onclick="resetForceGraph()">重置布局</button>
<button onclick="addRandomNode()">添加节点</button>
</div>
</div>
<div class="demo-card">
<h3>实时数据流</h3>
<div class="chart-container" id="realtime-chart"></div>
<div class="controls">
<button id="stream-btn" onclick="toggleStream()">开始</button>
<button onclick="clearRealtimeData()">清除数据</button>
</div>
</div>
<div class="demo-card">
<h3>3D表面图</h3>
<div class="chart-container" id="surface-3d"></div>
<div class="controls">
<button onclick="resetSurfaceRotation()">重置视角</button>
<button onclick="changeSurfaceFunction()">切换函数</button>
</div>
</div>
<div class="demo-card">
<h3>树状图</h3>
<div class="chart-container" id="tree-viz"></div>
<div class="controls">
<button onclick="toggleTreeOrientation()">切换方向</button>
</div>
</div>
</div>
</div>
<script>
// 力导向图演示
const forceGraph = new ForceGraph('#force-graph', {
width: 500,
height: 350,
nodeRadius: 8,
chargeStrength: -200
})
const graphData = generateGraphData(15, 20)
forceGraph.setData(graphData)
forceGraph.bindEvents()
forceGraph.render()
function generateGraphData(nodeCount, linkCount) {
const nodes = []
const links = []
for (let i = 0; i < nodeCount; i++) {
nodes.push({
id: i,
label: `节点${i}`,
color: `hsl(${i * 360 / nodeCount}, 70%, 50%)`
})
}
for (let i = 0; i < linkCount; i++) {
const source = Math.floor(Math.random() * nodeCount)
let target = Math.floor(Math.random() * nodeCount)
while (target === source) {
target = Math.floor(Math.random() * nodeCount)
}
links.push({ source, target })
}
return { nodes, links }
}
function resetForceGraph() {
forceGraph.nodes.forEach(node => {
node.x = forceGraph.options.width / 2 + (Math.random() - 0.5) * 100
node.y = forceGraph.options.height / 2 + (Math.random() - 0.5) * 100
node.vx = 0
node.vy = 0
})
forceGraph.simulation.alpha = 1
forceGraph.animate()
}
function addRandomNode() {
const newNode = {
id: forceGraph.nodes.length,
label: `节点${forceGraph.nodes.length}`,
color: `hsl(${Math.random() * 360}, 70%, 50%)`,
x: forceGraph.options.width / 2,
y: forceGraph.options.height / 2,
vx: 0,
vy: 0
}
forceGraph.nodes.push(newNode)
forceGraph.nodeIndex[newNode.id] = newNode
const randomTarget = forceGraph.nodes[Math.floor(Math.random() * (forceGraph.nodes.length - 1))]
forceGraph.links.push({ source: newNode, target: randomTarget })
forceGraph.simulation.alpha = 0.5
forceGraph.animate()
}
// 实时数据流演示
const realtimeChart = new RealtimeLineChart('#realtime-chart', {
width: 500,
height: 350,
maxPoints: 150,
updateInterval: 100
})
let streamRunning = false
let streamValue = 50
function toggleStream() {
streamRunning = !streamRunning
const btn = document.getElementById('stream-btn')
btn.textContent = streamRunning ? '停止' : '开始'
btn.classList.toggle('active', streamRunning)
if (streamRunning) {
realtimeChart.startDataStream(() => {
streamValue += (Math.random() - 0.5) * 10
streamValue = Math.max(0, Math.min(100, streamValue))
return streamValue
})
} else {
realtimeChart.stopDataStream()
}
}
function clearRealtimeData() {
realtimeChart.dataPoints = []
realtimeChart.valueRange = { min: Infinity, max: -Infinity }
realtimeChart.render()
}
// 3D表面图演示
const surface3D = new Surface3DPlot('#surface-3d', {
width: 500,
height: 350,
rotationX: -25,
rotationY: 45
})
let surfaceFunction = 0
function generateSurfaceData(width, height) {
const values = []
for (let j = 0; j < height; j++) {
values[j] = []
for (let i = 0; i < width; i++) {
const x = (i - width / 2) / 10
const z = (j - height / 2) / 10
let value
switch (surfaceFunction) {
case 0:
value = Math.sin(x) * Math.cos(z)
break
case 1:
value = Math.sin(Math.sqrt(x * x + z * z))
break
case 2:
value = Math.cos(x * z) * Math.exp(-(x * x + z * z) / 8)
break
default:
value = Math.sin(x) * Math.cos(z)
}
values[j][i] = value
}
}
return { values, width, height }
}
surface3D.setGridData(generateSurfaceData(30, 30))
surface3D.bindEvents()
surface3D.render()
function resetSurfaceRotation() {
surface3D.rotation = { x: -25, y: 45 }
surface3D.render()
}
function changeSurfaceFunction() {
surfaceFunction = (surfaceFunction + 1) % 3
surface3D.setGridData(generateSurfaceData(30, 30))
surface3D.render()
}
// 树状图演示
let treeOrientation = 'vertical'
const treeViz = new TreeVisualization('#tree-viz', {
width: 500,
height: 350,
orientation: treeOrientation,
nodeRadius: 6,
levelGap: 80
})
const treeData = {
id: 0,
name: '根节点',
children: [
{
id: 1,
name: '子节点1',
children: [
{ id: 3, name: '叶子1', children: [] },
{ id: 4, name: '叶子2', children: [] }
]
},
{
id: 2,
name: '子节点2',
children: [
{ id: 5, name: '叶子3', children: [] },
{ id: 6, name: '叶子4', children: [] },
{ id: 7, name: '叶子5', children: [] }
]
}
]
}
treeViz.setData(treeData)
treeViz.render()
function toggleTreeOrientation() {
treeOrientation = treeOrientation === 'vertical' ? 'horizontal' : 'vertical'
treeViz.options.orientation = treeOrientation
treeViz.calculateLayout()
treeViz.render()
}
</script>
</body>
</html>
class OptimizedVisualization {
constructor(canvas, options = {}) {
this.canvas = canvas
this.ctx = canvas.getContext('2d', {
alpha: false,
desynchronized: true
})
this.options = {
useOffscreen: true,
useWebWorker: false,
chunkSize: 1000,
...options
}
this.offscreenCanvas = null
this.worker = null
}
setupOffscreen() {
if (this.options.useOffscreen && typeof OffscreenCanvas !== 'undefined') {
this.offscreenCanvas = new OffscreenCanvas(
this.canvas.width,
this.canvas.height
)
this.offscreenCtx = this.offscreenCanvas.getContext('2d')
}
}
renderLargeDataset(data) {
const { chunkSize } = this.options
const chunks = []
for (let i = 0; i < data.length; i += chunkSize) {
chunks.push(data.slice(i, i + chunkSize))
}
this.renderChunks(chunks, 0)
}
renderChunks(chunks, index) {
if (index >= chunks.length) {
this.finalizeRender()
return
}
this.renderChunk(chunks[index])
requestAnimationFrame(() => this.renderChunks(chunks, index + 1))
}
renderChunk(chunk) {
const ctx = this.offscreenCtx || this.ctx
chunk.forEach(item => {
this.renderItem(ctx, item)
})
}
renderItem(ctx, item) {}
finalizeRender() {
if (this.offscreenCanvas) {
this.ctx.drawImage(this.offscreenCanvas, 0, 0)
}
}
useSpatialIndex(data) {
this.spatialIndex = new RBush()
const items = data.map(item => ({
minX: item.x,
minY: item.y,
maxX: item.x + item.width,
maxY: item.y + item.height,
data: item
}))
this.spatialIndex.load(items)
}
queryVisibleItems(bounds) {
return this.spatialIndex.search({
minX: bounds.x,
minY: bounds.y,
maxX: bounds.x + bounds.width,
maxY: bounds.y + bounds.height
}).map(item => item.data)
}
}
class ResponsiveVisualization extends DataVisualization {
constructor(container, options = {}) {
super(container, {
breakpoints: {
mobile: { width: 400, height: 300 },
tablet: { width: 600, height: 400 },
desktop: { width: 800, height: 500 }
},
...options
})
this.currentBreakpoint = 'desktop'
}
setupResize() {
this.updateBreakpoint()
super.setupResize()
}
resize(width, height) {
this.updateBreakpoint()
super.resize(width, height)
}
updateBreakpoint() {
const containerWidth = this.container.clientWidth
const { breakpoints } = this.options
if (containerWidth < breakpoints.mobile.width) {
this.currentBreakpoint = 'mobile'
} else if (containerWidth < breakpoints.tablet.width) {
this.currentBreakpoint = 'tablet'
} else {
this.currentBreakpoint = 'desktop'
}
this.applyBreakpointStyles()
}
applyBreakpointStyles() {
const bp = this.options.breakpoints[this.currentBreakpoint]
this.options.padding = bp.padding || this.options.padding
this.options.fontSize = bp.fontSize || 12
this.options.nodeRadius = bp.nodeRadius || 8
}
}
Canvas数据可视化技术涵盖了从基础图表到复杂科学可视化的广泛领域。通过合理使用投影算法、力导向布局、等高线计算等技术,可以创建高性能、交互丰富的可视化应用。关键要点包括: