this iteration stores the extent of each chart filter in the url query string, so that URLs to unique filter views can be shared.
for example, this url points to a filter view that looks at short-haul flights that were delayed ~40 to ~60 minutes late in the day during a particular week in February 2001:
since bl.ocks.org renders examples in an iframe, we need to view the raw example on it’s own page to see the url-filter-state feature in action.
this iteration was motivated by a desire to be able to generate a downloadable image or document of the crossfilter dashboard for the user, at the current filter state. storing the filter states in the url is one way to communicate to the screenshot server how the dashboard should look when the server captures the screenshot.
I encourage you to read more about URLSearchParams and the browser history pushState() method used to read and write the page’s url without triggering a full page reload.
an iteration on Crossfilter Demo | es2015 d3v4 from @micahstubbs
in repo form https://github.com/micahstubbs/crossfilter-experiments
this iteration
this iteration prettier formats the js
this iteration converts the code to ES2015 in something like the airbnb style
forked from @alexmacy‘s block: Updated Crossfilter.js demo
see also an earlier iteration that retains the plot width and table width of the original Crossfilter example at http://square.github.io/crossfilter/
This is an updated version of this demo of the crossfilter library. Crossfilter has been one of my favorite - and what I think to be on of the most underrated - JavaScript libraries. It hasn’t seen much of any updates in quite a while, so I wanted to find out how it would work with version 4 of d3.js.
There were some issues that came up with how d3-brush has been updated for v4. Big thanks goes to Alastair Dant (@ajdant) for helping to figure out a couple of those issues!
Also worth reading, is this discussion started by Robert Monfera (@monfera).
/* global d3 crossfilter reset */
d3.csv('./flights-3m.csv', (error, flights) => {
console.log(flights.length)
// Various formatters.
const formatNumber = d3.format(',d')
const formatChange = d3.format('+,d')
const formatDate = d3.timeFormat('%B %d, %Y')
const formatTime = d3.timeFormat('%I:%M %p')
// A nest operator, for grouping the flight list.
const nestByDate = d3.nest().key(d => d3.timeDay(d.date))
// A little coercion, since the CSV is untyped.
flights.forEach((d, i) => {
d.index = i
d.date = parseDate(d.date)
d.delay = +d.delay
d.distance = +d.distance
})
// Create the crossfilter for the relevant dimensions and groups.
const flight = crossfilter(flights)
// store dimensions in an object
const d8s = {}
d8s.all = flight.groupAll()
d8s.date = flight.dimension(d => d.date)
d8s.dates = d8s.date.group(d3.timeDay)
d8s.hour = flight.dimension(d => d.date.getHours() + d.date.getMinutes() / 60)
d8s.hours = d8s.hour.group(Math.floor)
d8s.delay = flight.dimension(d => Math.max(-60, Math.min(149, d.delay)))
d8s.delays = d8s.delay.group(d => Math.floor(d / 10) * 10)
d8s.distance = flight.dimension(d => Math.min(1999, d.distance))
d8s.distances = d8s.distance.group(d => Math.floor(d / 50) * 50)
const charts = [
barChart()
.dimension(d8s.hour)
.group(d8s.hours)
.x(
d3
.scaleLinear()
.domain([0, 24])
.rangeRound([0, 10 * 24])
),
barChart()
.dimension(d8s.delay)
.group(d8s.delays)
.x(
d3
.scaleLinear()
.domain([-60, 150])
.rangeRound([0, 10 * 21])
),
barChart()
.dimension(d8s.distance)
.group(d8s.distances)
.x(
d3
.scaleLinear()
.domain([0, 2000])
.rangeRound([0, 10 * 40])
),
barChart()
.dimension(d8s.date)
.group(d8s.dates)
.round(d3.timeDay.round)
.x(
d3
.scaleTime()
.domain([new Date(2001, 0, 1), new Date(2001, 3, 1)])
.rangeRound([0, 10 * 90])
)
]
const chartsByDimension = {
hour: charts[0],
delay: charts[1],
distance: charts[2],
date: charts[3]
}
const titleKeyHash = {
'distance (mi.)': 'distance',
'arrival delay (min.)': 'delay',
'time of day': 'hour',
date: 'date'
}
// read the query string in the url
// parse out and apply any filters found there
const url = new URL(window.location)
const params = new URLSearchParams(url.search)
let title
let dimensionKey
let extent
for (let entry of params.entries()) {
console.log('entry', entry)
title = decodeURIComponent(entry[0])
dimensionKey = titleKeyHash[title]
console.log('dimensionKey', dimensionKey)
extent = entry[1].split('--').map(v => decodeURIComponent(v))
console.log('extent parsed from url.search', extent)
if (dimensionKey === 'date') extent = extent.map(v => new Date(v))
console.log('extent after formatting', extent)
// apply the filter found in the query string
if (d8s[dimensionKey]) d8s[dimensionKey].filterRange(extent)
if (chartsByDimension[dimensionKey])
chartsByDimension[dimensionKey].filter(extent)
}
// Given our array of charts, which we assume are in the same order as the
// .chart elements in the DOM, bind the charts to the DOM and render them.
// We also listen to the chart's brush events to update the display.
const chart = d3.selectAll('.chart').data(charts)
// Render the initial lists.
const list = d3.selectAll('.list').data([flightList])
// Render the total.
d3.selectAll('#total').text(formatNumber(flight.size()))
renderAll()
// Renders the specified chart or list.
function render(method) {
d3.select(this).call(method)
}
// Whenever the brush moves, re-rendering everything.
function renderAll() {
chart.each(render)
list.each(render)
d3.select('#active').text(formatNumber(d8s.all.value()))
}
// Like d3.timeFormat, but faster.
function parseDate(d) {
return new Date(
2001,
d.substring(0, 2) - 1,
d.substring(2, 4),
d.substring(4, 6),
d.substring(6, 8)
)
}
window.filter = filters => {
filters.forEach((d, i) => {
console.log('filter', d)
charts[i].filter(d)
})
renderAll()
}
window.reset = i => {
charts[i].filter(null)
renderAll()
}
function flightList(div) {
const flightsByDate = nestByDate.entries(d8s.date.top(40))
div.each(function() {
const date = d3
.select(this)
.selectAll('.date')
.data(flightsByDate, d => d.key)
date.exit().remove()
date
.enter()
.append('div')
.attr('class', 'date')
.append('div')
.attr('class', 'day')
.text(d => formatDate(d.values[0].date))
.merge(date)
const flight = date
.order()
.selectAll('.flight')
.data(d => d.values, d => d.index)
flight.exit().remove()
const flightEnter = flight
.enter()
.append('div')
.attr('class', 'flight')
flightEnter
.append('div')
.attr('class', 'time')
.text(d => formatTime(d.date))
flightEnter
.append('div')
.attr('class', 'origin')
.text(d => d.origin)
flightEnter
.append('div')
.attr('class', 'destination')
.text(d => d.destination)
flightEnter
.append('div')
.attr('class', 'distance')
.text(d => `${formatNumber(d.distance)} mi.`)
flightEnter
.append('div')
.attr('class', 'delay')
.classed('early', d => d.delay < 0)
.text(d => `${formatChange(d.delay)} min.`)
flightEnter.merge(flight)
flight.order()
})
}
function barChart() {
if (!barChart.id) barChart.id = 0
let margin = { top: 10, right: 13, bottom: 20, left: 10 }
let x
let y = d3.scaleLinear().range([100, 0])
const id = barChart.id++
const axis = d3.axisBottom()
const brush = d3.brushX()
let brushDirty
let dimension
let group
let round
let gBrush
function chart(div) {
const width = x.range()[1]
const height = y.range()[0]
brush.extent([[0, 0], [width, height]])
y.domain([0, group.top(1)[0].value])
div.each(function() {
const div = d3.select(this)
let g = div.select('g')
// Create the skeletal chart.
if (g.empty()) {
div
.select('.title')
.append('a')
.attr('href', `javascript:reset(${id})`)
.attr('class', 'reset')
.text('reset')
.style('display', 'none')
g = div
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
g.append('clipPath')
.attr('id', `clip-${id}`)
.append('rect')
.attr('width', width)
.attr('height', height)
g.selectAll('.bar')
.data(['background', 'foreground'])
.enter()
.append('path')
.attr('class', d => `${d} bar`)
.datum(group.all())
g.selectAll('.foreground.bar').attr('clip-path', `url(#clip-${id})`)
g.append('g')
.attr('class', 'axis')
.attr('transform', `translate(0,${height})`)
.call(axis)
// Initialize the brush component with pretty resize handles.
gBrush = g
.append('g')
.attr('class', 'brush')
.call(brush)
gBrush
.selectAll('.handle--custom')
.data([{ type: 'w' }, { type: 'e' }])
.enter()
.append('path')
.attr('class', 'brush-handle')
.attr('cursor', 'ew-resize')
.attr('d', resizePath)
.style('display', 'none')
}
// Only redraw the brush if set externally.
if (brushDirty !== false) {
const filterVal = brushDirty
brushDirty = false
div
.select('.title a')
.style('display', d3.brushSelection(div) ? null : 'none')
if (!filterVal) {
g.call(brush)
g.selectAll(`#clip-${id} rect`)
.attr('x', 0)
.attr('width', width)
g.selectAll('.brush-handle').style('display', 'none')
renderAll()
} else {
const range = filterVal.map(x)
brush.move(gBrush, range)
}
}
g.selectAll('.bar').attr('d', barPath)
})
function barPath(groups) {
const path = []
let i = -1
const n = groups.length
let d
while (++i < n) {
d = groups[i]
path.push('M', x(d.key), ',', height, 'V', y(d.value), 'h9V', height)
}
return path.join('')
}
function resizePath(d) {
const e = +(d.type === 'e')
const x = e ? 1 : -1
const y = height / 3
return `M${0.5 * x},${y}A6,6 0 0 ${e} ${6.5 * x},${y + 6}V${2 * y -
6}A6,6 0 0 ${e} ${0.5 * x},${2 * y}ZM${2.5 * x},${y + 8}V${2 * y -
8}M${4.5 * x},${y + 8}V${2 * y - 8}`
}
}
brush.on('start.chart', function() {
const div = d3.select(this.parentNode.parentNode.parentNode)
div.select('.title a').style('display', null)
})
brush.on('brush.chart', function() {
const g = d3.select(this.parentNode)
const brushRange = d3.event.selection || d3.brushSelection(this) // attempt to read brush range
const xRange = x && x.range() // attempt to read range from x scale
let activeRange = brushRange || xRange // default to x range if no brush range available
const title = this.parentNode.parentNode.parentNode.firstElementChild.innerHTML.replace(
/<a.*a>/,
''
)
const hasRange =
activeRange &&
activeRange.length === 2 &&
!isNaN(activeRange[0]) &&
!isNaN(activeRange[1])
if (!hasRange) return // quit early if we don't have a valid range
// calculate current brush extents using x scale
let extents = activeRange.map(x.invert)
// if rounding fn supplied, then snap to rounded extents
// and move brush rect to reflect rounded range bounds if it was set by user interaction
if (round) {
extents = extents.map(round)
activeRange = extents.map(x)
if (d3.event.sourceEvent && d3.event.sourceEvent.type === 'mousemove') {
d3.select(this).call(brush.move, activeRange)
}
}
// move brush handles to start and end of range
g.selectAll('.brush-handle')
.style('display', null)
.attr('transform', (d, i) => `translate(${activeRange[i]}, 0)`)
// resize sliding window to reflect updated range
g.select(`#clip-${id} rect`)
.attr('x', activeRange[0])
.attr('width', activeRange[1] - activeRange[0])
const chartKey = encodeURIComponent(title.toLowerCase())
const extentValueString = `${encodeURIComponent(
extents[0]
)}--${encodeURIComponent(extents[1])}`
console.log('title', title)
console.log('chartKey', chartKey)
console.log('extents', extents)
updateQueryString(chartKey, extentValueString)
// filter the active dimension to the range extents
dimension.filterRange(extents)
// re-render the other charts accordingly
renderAll()
})
brush.on('end.chart', function() {
// reset corresponding filter if the brush selection was cleared
// (e.g. user "clicked off" the active range)
if (!d3.brushSelection(this)) {
reset(id)
}
})
chart.margin = function(_) {
if (!arguments.length) return margin
margin = _
return chart
}
chart.x = function(_) {
if (!arguments.length) return x
x = _
axis.scale(x)
return chart
}
chart.y = function(_) {
if (!arguments.length) return y
y = _
return chart
}
chart.dimension = function(_) {
if (!arguments.length) return dimension
dimension = _
return chart
}
chart.filter = _ => {
if (!_) dimension.filterAll()
brushDirty = _
return chart
}
chart.group = function(_) {
if (!arguments.length) return group
group = _
return chart
}
chart.round = function(_) {
if (!arguments.length) return round
round = _
return chart
}
chart.gBrush = () => gBrush
return chart
}
})
function updateQueryString(key, value) {
const url = new URL(window.location)
const params = new URLSearchParams(url.search)
if (value.length === 0) params.delete(key)
else params.set(key, value)
url.search = params.toString()
// eslint-disable-next-line no-restricted-globals
history.pushState({}, '', url.toString())
}
<!DOCTYPE html>
<meta charset='utf-8'>
<title>Crossfilter</title>
<link href='./index.css' rel='stylesheet'>
<link rel="icon" href="data:;base64,iVBORw0KGgo=">
<head>
<script src='//alexmacy.github.io/crossfilter/crossfilter.v1.min.js' defer></script>
<script src='//d3js.org/d3.v4.min.js' defer></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.23.1/babel.min.js' defer></script>
<script src='index.js' defer></script>
</head>
<body>
<div id='charts'>
<div id='hour-chart' class='chart'>
<div class='title'>Time of Day</div>
</div>
<div id='delay-chart' class='chart'>
<div class='title'>Arrival Delay (min.)</div>
</div>
<div id='distance-chart' class='chart'>
<div class='title'>Distance (mi.)</div>
</div>
<div id='date-chart' class='chart'>
<div class='title'>Date</div>
</div>
</div>
<aside id='totals'><span id='active'>-</span> of <span id='total'>-</span> flights selected.</aside>
<div id='lists'>
<div id='flight-list' class='list'></div>
</div>
</body>
@import url(https://fonts.googleapis.com/css?family=Yanone+Kaffeesatz:400, 700);
body {
font-family: 'Helvetica Neue';
margin: 40px auto;
width: 960px;
min-height: 2000px;
}
#body {
position: relative;
}
footer {
padding: 2em 0 1em 0;
font-size: 12px;
}
h1 {
font-size: 96px;
margin-top: 0.3em;
margin-bottom: 0;
}
h1 + h2 {
margin-top: 0;
}
h2 {
font-weight: 400;
font-size: 28px;
}
h1,
h2 {
font-family: 'Yanone Kaffeesatz';
text-rendering: optimizeLegibility;
}
#body > p {
line-height: 1.5em;
width: 640px;
text-rendering: optimizeLegibility;
}
#charts {
padding: 10px 0;
}
.chart {
display: inline-block;
height: 151px;
margin-bottom: 20px;
}
.reset {
padding-left: 1em;
font-size: smaller;
color: #ccc;
}
.background.bar {
fill: #ccc;
}
.foreground.bar {
fill: steelblue;
}
.brush-handle {
fill: #eee;
stroke: #666;
}
#hour-chart {
width: 260px;
}
#delay-chart {
width: 230px;
}
#distance-chart {
width: 430px;
}
#date-chart {
width: 920px;
}
#flight-list {
min-height: 1024px;
}
#flight-list .date,
#flight-list .day {
margin-bottom: 0.4em;
}
#flight-list .flight {
line-height: 1.5em;
background: #eee;
width: 925px;
margin-bottom: 1px;
}
#flight-list .time {
color: #999;
}
#flight-list .flight div {
display: inline-block;
}
#flight-list div.time {
width: 100px;
text-align: left;
}
#flight-list div.origin {
width: 50px;
text-align: right;
padding-right: 15px;
}
#flight-list div.destination {
width: 100px;
text-align: left;
padding-left: 15px;
}
#flight-list div.distance {
width: 100px;
text-align: left;
}
#flight-list div.delay {
width: 120px;
padding-right: 0px;
text-align: right;
}
#flight-list .early {
color: green;
}
aside {
position: absolute;
left: 740px;
font-size: smaller;
width: 220px;
}