首先,我们将专注于增强一个我们已经制作过的图表:我们的散点图。这个图表将有多个目标,所有的目标都在探索我们的天气数据集中的每日温度范围:
- 大多数日子的温度范围是否相似? 还是寒冷的日子比温暖的日子变化少?
- 我们有什么异常的日子吗? 最低和最高温度都异常,还是只有一个?
- 全年气温如何变化?
正如您所看到的,更复杂的图表能够回答多个问题——我们只需要确保它们足够集中,能够很好地回答这些问题。
为了帮助回答这些问题,我们将在散点图中添加两个主要组件: - x 轴上的一个直方图显示最低温度的分布,y 轴上的一个直方图显示最高温度的分布
- 一个将一年时间序列化的刻度尺
步骤
修改散点图
首先让我们使用已经创建过的散点图代码进行修改:
- 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>
°F -
<span id="max-temperature"></span>
°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)
})
图例
学习颜色的时候我们做了很多图例,拉过来用,我们还需要给图例添加刻度这样用户才能知道日期的颜色,还要添加个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', '')
})