d3-边际直方图

479 阅读4分钟

首先,我们将专注于增强一个我们已经制作过的图表:我们的散点图。这个图表将有多个目标,所有的目标都在探索我们的天气数据集中的每日温度范围:

  • 大多数日子的温度范围是否相似? 还是寒冷的日子比温暖的日子变化少?
  • 我们有什么异常的日子吗? 最低和最高温度都异常,还是只有一个?
  • 全年气温如何变化? 正如您所看到的,更复杂的图表能够回答多个问题——我们只需要确保它们足够集中,能够很好地回答这些问题。
    为了帮助回答这些问题,我们将在散点图中添加两个主要组件:
  • x 轴上的一个直方图显示最低温度的分布,y 轴上的一个直方图显示最高温度的分布
  • 一个将一年时间序列化的刻度尺

image.png

步骤

修改散点图

首先让我们使用已经创建过的散点图代码进行修改:

  • dimensions添加属性
  • xy轴分别表示最高最低温度
  • 创建一年日期的颜色刻度尺对点进行配色
const dimensions = {
  width: 800, // viewbox 宽度
  height: 800, // viewbox 高度
  margin: { // bounds 边距
    top: 100, 
    right: 100,
    bottom: 50,
    left: 50,
  },
  boundsWidth: 0,
  boundsHeight: 0,
  viewBox: '',
  histogramHeight: 50, // 直方图高度
  histogramMargin: 10, // 直方图相对散点图边距
  legendWidth: 200, // 图例宽度
  legendHeight: 25, // 图例高度
  legendHighlightBarWidth: 10, // 图例范围条宽度
}
// 散点图基本数据
dimensions.viewBox = `0,0,${dimensions.width},${dimensions.height}`
dimensions.boundsWidth = dimensions.width - dimensions.margin.left - dimensions.margin.right
dimensions.boundsHeight = dimensions.height - dimensions.margin.top - dimensions.margin.bottom
const xAccessor = (d: weatherData) => d.temperatureMin
const yAccessor = (d: weatherData) => d.temperatureMax
//让xy轴的温度范围一致 这样更容易观测
const temperatureExtent = d3.extent([...dataset.map(xAccessor), ...dataset.map(yAccessor)])
const xScale = d3.scaleLinear().domain(temperatureExtent).range([0, dimensions.boundsWidth]).nice()
const yScale = d3.scaleLinear().domain(temperatureExtent).range([dimensions.boundsHeight, 0]).nice()
const colorScaleYear = 2018
const parseDate = d3.timeParse('%Y-%m-%d')
const colorAccessor = (d: weatherData) => (parseDate(d.date) as Date).setFullYear(colorScaleYear)
//序列化一年时间的颜色刻度尺
const colorScale = d3
  .scaleSequential()
  .domain([
    d3.timeParse('%m/%d/%Y')(`1/1/${colorScaleYear}`) as Date,
    d3.timeParse('%m/%d/%Y')(`12/31/${colorScaleYear}`) as Date,
  ])
  .interpolator((d) => d3.interpolateRainbow(-d))
  onMounted(() => {
  const svg = d3
    .select('#marginalHistogram')
    .append('svg')
    .attr('viewBox', dimensions.viewBox)
    .attr('width', '100%')
    .attr('height', '100%')
    .attr('preserveAspectRatio', 'xMinYMin')

  const bounds = svg
    .append('g')
    .style('transform', `translate(${dimensions.margin.left}px,${dimensions.margin.top}px)`)
    .style('fill', 'white')
    .attr('width', dimensions.boundsWidth)
    .attr('height', dimensions.boundsHeight)
  bounds
    .append('rect')
    .attr('width', dimensions.boundsWidth)
    .attr('height', dimensions.boundsHeight)
    .style('fill', 'white')
  const scatters = bounds
    .selectAll('.scatters')
    .data(dataset)
    .join('circle')
    .attr('class', 'scatters')
    .attr('r', 4)
    .attr('cx', (d) => xScale(xAccessor(d)))
    .attr('cy', (d) => yScale(yAccessor(d)))
    .style('fill', (d) => colorScale(colorAccessor(d)))
  const xAxisGenerator = d3.axisBottom(xScale).ticks(4)
  const xAxis = bounds.append('g').call(xAxisGenerator).style('transform', `translate(0,${dimensions.boundsHeight}px)`)
  const yAxisGenerator = d3.axisLeft(yScale).ticks(4)
  const yAxis = bounds.append('g').call(yAxisGenerator)
  const yAxisText = bounds
    .append('text')
    .attr('x', 0)
    .attr('y', 0)
    .style('transform', `translate(-25px, ${dimensions.boundsHeight / 2}px) rotate(-90deg)`)
    .text('Maximum Temperature (°F)')
    .style('text-anchor', 'middle')
  const xAxisText = bounds
    .append('text')
    .style('transform', `translate(${dimensions.boundsWidth / 2}px, ${dimensions.boundsHeight + 35}px)`)
    .text('Minimum Temperature (°F)')
    .style('text-anchor', 'middle')
})

直方图

添加上方和右侧的直方图,表示这一年时间在最高温度和最低温度分布趋势。 回一下之前柱状图的代码:

  • 通过d3.bin初始化数据
const topHistogramGenerator = d3.bin().domain(xScale.domain()).value(xAccessor).thresholds(20)
const topHistogramBins = topHistogramGenerator(dataset)
  • 直方图y轴刻度尺
// 直方图y轴刻度尺
  const topHistogramYScale = d3
    .scaleLinear()
    .domain(d3.extent(topHistogramBins, (d) => d.length))
    .range([dimensions.histogramHeight, 0])
  • 直方图的外层g元素 这里我们要放在boudns的上方,还要添加间距
const topHistogramBounds = bounds
    .append('g')
    .attr('transform', `translate(0, ${-dimensions.histogramHeight - dimensions.histogramMargin})`)

  • 这里我们使用了面积图,使用d3.area()
 const topHistogramLineGenerator = d3
    .area()
    .x((d: any) => xScale((d.x0 + d.x1) / 2))
    .y0(dimensions.histogramHeight)
    .y1((d: any) => topHistogramYScale(d.length))
    .curve(d3.curveBasis)

  const topHistogramElement = topHistogramBounds
    .append('path')
    .attr('d', (d) => topHistogramLineGenerator(topHistogramBins))
    .attr('class', 'histogram-area')

右侧的面积图同理,只是需要调整位置做个旋转

const rightHistogramBounds = bounds
    .append('g')
    .attr('class', 'right-histogram')
    .style(
      'transform',
      `translate(${dimensions.boundsWidth + dimensions.histogramMargin}px, -${
        dimensions.histogramHeight
      }px) rotate(90deg)`
    )

voronoi 布局

和之前代码一样稍作修改

const voronois = svg
    .append('g')
    .style('transform', `translate(${dimensions.margin.left}px,${dimensions.margin.top}px)`)
    .attr('width', dimensions.boundsWidth)
    .attr('height', dimensions.boundsHeight)
  const delaunay = d3.Delaunay.from(
    dataset,
    (d) => xScale(xAccessor(d)),
    (d) => yScale(yAccessor(d))
  )
  const voronoi = delaunay.voronoi()
  voronoi.xmax = dimensions.boundsWidth
  voronoi.ymax = dimensions.boundsHeight

  voronois
    .selectAll('.voronoi')
    .data(dataset)
    .enter()
    .append('path')
    .attr('class', 'voronoi')
    .attr('d', (d, i) => voronoi.renderCell(i))

鼠标悬浮事件

鼠标选中节点时:

  • 突出选中的节点
  • 能看到对应在上方和右侧的折线图位置
  • tooltip显示具体的时间 高低温等信息 首先添加水平和垂直的rect
const hoverElementsGroup = bounds.append('g').style('opacity', 0)
const horizontalLine = hoverElementsGroup.append('rect').attr('class', 'hover-line')
const verticalLine = hoverElementsGroup.append('rect').attr('class', 'hover-line')
const r = 6

给voronoi图添加鼠标进入和离开的事件,注意由于我们用的时viewbox,所以在坐标的时候要乘以缩放比例

<template>
  <div id="marginalHistogram">
    <div id="tooltip" class="tooltip">
      <div class="tooltip-date">
        <span id="date"></span>
      </div>
      <div class="tooltip-temperature">
        <span id="min-temperature"></span>
        &deg;F -
        <span id="max-temperature"></span>
        &deg;F
      </div>
    </div>
  </div>
</template>

voronois
    .selectAll('.voronoi')
    .on('mouseenter', (e: any, d: weatherData) => {
      // 将两个rect变为可视状态
      hoverElementsGroup.style('opacity', 1)
      verticalLine.style('opacity', 1)
      horizontalLine.style('opacity', 1)
      // 添加一个外圈circle突出选中节点
      bounds
        .append('circle')
        .attr('class', 'selected-pointer')
        .attr('r', r)
        .attr('cx', xScale(xAccessor(d)))
        .attr('cy', yScale(yAccessor(d)))
      const datum = d
      const tooltip = d3.select('#tooltip')
      const formatTemperature = d3.format('.1f')
      tooltip.select('#max-temperature').text(formatTemperature(yAccessor(datum)))

      tooltip.select('#min-temperature').text(formatTemperature(xAccessor(datum)))

      const dateParser = d3.timeParse('%Y-%m-%d')
      const formatDate = d3.timeFormat('%A, %B %-d, %Y')
      tooltip.select('#date').text(formatDate(dateParser(datum.date) as Date))
      // 缩放便利
      const rate = (d3.select('.bounds').nodes()[0] as Element).getClientRects()[0].width / dimensions.boundsWidth
      const tooltipX = xScale(xAccessor(datum)) + dimensions.margin.left
      const tooltipY = yScale(yAccessor(datum)) + dimensions.margin.top - 4 // bump up so it doesn't overlap with out hover circle
      tooltip.style(
        'transform',
        `translate(` + `calc( -50% + ${tooltipX * rate}px),` + `calc(-100% + ${tooltipY * rate}px)` + `)`
      )

      tooltip.style('opacity', 1)
      horizontalLine
        .attr('x', xScale(xAccessor(d)) - r)
        .attr('y', yScale(yAccessor(d)) - r)
        .style(
          'width',
          dimensions.boundsWidth + dimensions.histogramMargin + dimensions.histogramHeight - xScale(xAccessor(d)) + r
        )
        .style('height', 12)
      verticalLine
        .attr('x', xScale(xAccessor(d)) - r)
        .attr('y', -dimensions.histogramMargin - dimensions.histogramHeight)
        .style('width', 12)
        .style('height', yScale(yAccessor(d)) + r + dimensions.histogramMargin + dimensions.histogramHeight)
    })
    .on('mouseleave', function (e, d) {
      d3.selectAll('.selected-pointer').remove()
      verticalLine.style('opacity', 0)
      horizontalLine.style('opacity', 0)
    })

image.png

图例

学习颜色的时候我们做了很多图例,拉过来用,我们还需要给图例添加刻度这样用户才能知道日期的颜色,还要添加个rect用来后面鼠标悬浮时间选中范围日期:

**const legendGroup = svg
    .append('g')
    .attr(
      'transform',
      `translate(${dimensions.boundsWidth + dimensions.margin.left - dimensions.legendWidth - 20},${
        dimensions.boundsHeight + dimensions.margin.top - 50
      })`
    )

  const defs = svg.append('defs')

  const numberOfGradientStops = 10
  const stops = d3.range(numberOfGradientStops).map((i) => i / (numberOfGradientStops - 1))
  const legendGradientId = 'legend-gradient'
  const gradient = defs
    .append('linearGradient')
    .attr('id', legendGradientId)
    .selectAll('stop')
    .data(stops)
    .enter()
    .append('stop')
    .attr('stop-color', (d) => d3.interpolateRainbow(-d))
    .attr('offset', (d) => `${d * 100}%`)

  const legendGradient = legendGroup
    .append('rect')
    .attr('height', dimensions.legendHeight)
    .attr('width', dimensions.legendWidth)
    .style('fill', `url(#${legendGradientId})`)

 // 给图例添加刻度 
  const tickValues = [
    d3.timeParse('%m/%d/%Y')(`4/1/${colorScaleYear}`),
    d3.timeParse('%m/%d/%Y')(`7/1/${colorScaleYear}`),
    d3.timeParse('%m/%d/%Y')(`10/1/${colorScaleYear}`),
  ]
  const legendTickScale = d3.scaleLinear().domain(colorScale.domain()).range([0, dimensions.legendWidth])

  const legendValues = legendGroup
    .selectAll('.legend-value')
    .data(tickValues)
    .enter()
    .append('text')
    .attr('class', 'legend-value')
    .attr('x', legendTickScale)
    .attr('y', -6)
    .text((d) => d3.timeFormat('%b')(d as Date))

  const legendValueTicks = legendGroup
    .selectAll('.legend-tick')
    .data(tickValues)
    .enter()
    .append('line')
    .attr('class', 'legend-tick')
    .attr('x1', legendTickScale)
    .attr('x2', legendTickScale)
    .attr('y1', 6)

  const legendHighlightBar = legendGroup
    .append('rect')
    .attr('class', 'legend-highlight-bar')
    .attr('width', dimensions.legendHighlightBarWidth)
    .attr('height', dimensions.legendHeight)
  const legendDateRangeText = legendGroup.append('text').attr('class', 'legend-highlight-text').attr('y', -10)

最后是图例悬浮事件:

  • 显示范围rect
  • 显示上方的时间范围字符串 隐藏tick
  • 隐藏voronoi事件
  • 筛选出当前范围的节点突出显示
  • 用筛选出的节点新建边界图颜色为当前选中的颜色
 //图例事件
  legendGradient
    .on('mousemove', (d, e) => {
      // 隐藏和显示
      legendValues.style('opacity', 0)
      legendValueTicks.style('opacity', 0)
      legendHighlightBar.style('opacity', 1)
      legendDateRangeText.style('opacity', 1)
      // 悬浮框和text
      // 获取真实rectDom
      const targetClientRect = d.target.getBoundingClientRect()
      // 获取事件在viewbox中的x坐标
      let curX = (d.x - targetClientRect.x) * (dimensions.legendWidth / targetClientRect.width)
      // 设置鼠标移动的临界值
      if (curX < dimensions.legendHighlightBarWidth / 2) {
        curX = dimensions.legendHighlightBarWidth / 2
      }
      if (curX > dimensions.legendWidth - dimensions.legendHighlightBarWidth / 2) {
        curX = dimensions.legendWidth - dimensions.legendHighlightBarWidth / 2
      }
      // 获取当前时间 最大最小x位置和时间范围
      const date = legendTickScale.invert(curX)
      const minX = curX - dimensions.legendHighlightBarWidth / 2
      const maxX = curX + dimensions.legendHighlightBarWidth / 2
      const minDateToHighlight = legendTickScale.invert(minX)
      const maxDateToHighlight = legendTickScale.invert(maxX)
      // legendHighlightBar位置设置
      legendHighlightBar.attr('x', curX - dimensions.legendHighlightBarWidth / 2)
      // 设置图例提示text 放在图例上方
      const text = d3.timeFormat('%Y-%m-%d')(minDateToHighlight) + '-' + d3.timeFormat('%Y-%m-%d')(maxDateToHighlight)
      legendDateRangeText.attr('x', curX - dimensions.legendHighlightBarWidth / 2)
      legendDateRangeText.text(text)
      // 设置个防抖 如果鼠标移动太快界面有时会卡
      if (debounceTimeout) {
        clearTimeout(debounceTimeout)
      }
      debounceTimeout = setTimeout(() => {
        // 弱化所有点
        scatters.transition().duration(100).style('opacity', 0.08).attr('r', 2)
        // 筛选出范围内的点并突出显示
        scatters
          .filter((d) => {
            return new Date(d.date) >= new Date(minDateToHighlight) && new Date(d.date) <= new Date(maxDateToHighlight)
          })
          .transition()
          .duration(100)
          .attr('r', 5)
          .style('opacity', 1)
      }, 10)

      // right top line
      // 同上面的步骤一样新增区域图 并设置为当前鼠标所在的颜色
      const lineCurrentColor = colorScale(new Date(date))
      const rangeData = dataset.filter(
        (d) => new Date(d.date) >= new Date(minDateToHighlight) && new Date(d.date) <= new Date(maxDateToHighlight)
      )
      const topRangeBins = topHistogramGenerator(rangeData)

      const topRangeLineGenerator = d3
        .area()
        .x((d: any) => xScale((d.x0 + d.x1) / 2))
        .y0(dimensions.histogramHeight)
        .y1((d: any) => topHistogramYScale(d.length))
        .curve(d3.curveBasis)

      topRangeElement.attr('d', (d) => topRangeLineGenerator(topRangeBins)).style('fill', lineCurrentColor)
      const rightRangeBins = rightHistogramGenerator(rangeData)

      const rightRangeLineGenerator = d3
        .area()
        .x((d: any) => yScale((d.x0 + d.x1) / 2))
        .y0(dimensions.histogramHeight)
        .y1((d: any) => rightHistogramYScale(d.length))
        .curve(d3.curveBasis)

      rightRangeElement.attr('d', (d) => rightRangeLineGenerator(rightRangeBins)).style('fill', lineCurrentColor)
    })
    .on('mouseleave', function (e, d) {
      // 鼠标移出时扫尾 初始化
      clearTimeout(debounceTimeout)
      legendHighlightBar.style('opacity', 0)
      legendDateRangeText.style('opacity', 0)
      legendValues.style('opacity', 1)
      legendValueTicks.style('opacity', 1)
      scatters.transition().duration(100).style('opacity', 1).attr('r', 4)
      topRangeElement.attr('d', '')
      rightRangeElement.attr('d', '')
    })

image.png