block by micahstubbs 67d7aa147948d701e336f1f0589bf1e1

Crossfilter Demo | shareable filter URL

Full Screen

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:

https://bl.ocks.org/micahstubbs/raw/67d7aa147948d701e336f1f0589bf1e1/?date=Fri%2520Feb%252009%25202001%252000%253A00%253A00%2520GMT-0800%2520%28Pacific%2520Standard%2520Time%29--Tue%2520Feb%252020%25202001%252000%253A00%253A00%2520GMT-0800%2520%28Pacific%2520Standard%2520Time%29&distance%2520%28mi.%29=255--440&arrival%2520delay%2520%28min.%29=29--69&time%2520of%2520day=19.799999999999997--22.200000000000003

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/

commit history


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).

index.js

/* 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())
}

index.html


<!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>


index.css

@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;
}