数据可视化

深入学习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]
  }
}

3D表面图

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数据可视化技术涵盖了从基础图表到复杂科学可视化的广泛领域。通过合理使用投影算法、力导向布局、等高线计算等技术,可以创建高性能、交互丰富的可视化应用。关键要点包括:

  1. 架构设计:建立可扩展的可视化基类,支持数据绑定、缩放、动画等功能
  2. 地图可视化:实现投影转换、热力图层、地理数据处理
  3. 网络图:使用力导向算法实现动态布局,支持交互式探索
  4. 科学可视化:实现等高线、3D表面图等专业可视化
  5. 实时数据:高效处理数据流,实现平滑动画更新
  6. 性能优化:使用离屏渲染、空间索引、分块处理等技术处理大规模数据