/* global d3 Redux */ // This stateless component renders a static "wheel" made of circles, // and rotates it depending on the value of props.angle. const wheel = d3 .component('g') .create(function(selection) { const minRadius = 4 const maxRadius = 10 const numDots = 10 const wheelRadius = 40 const rotation = 0 const rotationIncrement = 3 const radius = d3 .scaleLinear() .domain([0, numDots - 1]) .range([maxRadius, minRadius]) const angle = d3 .scaleLinear() .domain([0, numDots]) .range([0, Math.PI * 2]) selection .selectAll('circle') .data(d3.range(numDots)) .enter() .append('circle') .attr('cx', d => Math.sin(angle(d)) * wheelRadius) .attr('cy', d => Math.cos(angle(d)) * wheelRadius) .attr('r', radius) }) .render(function(selection, d) { selection.attr('transform', `rotate(${d})`) }) // This component with a local timer makes the wheel spin. const spinner = (() => { const timer = d3.local() return d3 .component('g') .create(function(selection, d) { timer.set( selection.node(), d3.timer(elapsed => { selection.call(wheel, elapsed * d.speed) }) ) }) .render(function(selection, d) { selection.attr('transform', `translate(${d.x},${d.y})`) }) .destroy(function(selection, d) { timer.get(selection.node()).stop() return selection .attr('fill-opacity', 1) .transition() .duration(3000) .attr('transform', `translate(${d.x},${d.y}) scale(10)`) .attr('fill-opacity', 0) }) })() const axis = (() => { const axisLocal = d3.local() return d3 .component('g') .create(function(selection, d) { axisLocal.set(selection.node(), d3[`axis${d.type}`]()) selection .attr('opacity', 0) .call( axisLocal .get(selection.node()) .scale(d.scale) .ticks(d.ticks || 10) ) .transition('opacity') .duration(2000) .attr('opacity', 0.8) }) .render(function(selection, d) { selection .attr( 'transform', `translate(${[d.translateX || 0, d.translateY || 0]})` ) .transition('ticks') .duration(3000) .call(axisLocal.get(selection.node())) }) })() // This component displays the visualization. const scatterPlot = (() => { const xScale = d3.scaleLinear() const yScale = d3.scaleLinear() const colorScale = d3.scaleOrdinal().range(d3.schemeCategory10) function render(selection, d) { const x = d.x const y = d.y const color = d.color const margin = d.margin const innerWidth = d.width - margin.left - margin.right const innerHeight = d.height - margin.top - margin.bottom xScale.domain(d3.extent(d.data, d => d[x])).range([0, innerWidth]) yScale.domain(d3.extent(d.data, d => d[y])).range([innerHeight, 0]) colorScale.domain(d3.extent(d.data, d => d[color])) selection .attr('transform', `translate(${margin.left},${margin.top})`) .call(axis, [ { type: 'Left', scale: yScale, translateX: -12 }, { type: 'Bottom', scale: xScale, translateY: innerHeight + 12, ticks: 20 } ]) const circles = selection.selectAll('.point').data(d.data) circles.exit().remove() circles .enter() .append('circle') .attr('class', 'point') .attr('r', 0) .attr('cx', d.width / 2 - margin.left) .attr('cy', d.height / 2 - margin.top) .merge(circles) .on('mouseover', d.show) .on('mouseout', d.hide) .transition() .duration(2000) .delay((d, i) => i * 5) .attr('r', 10) .attr('cx', d => xScale(d[x])) .attr('cy', d => yScale(d[y])) .attr('color', d => colorScale(d[color])) } return d3.component('g').render(render) })() // Use the d3-tip library for tooltips. const tooltip = (() => { const tip = d3 .tip() .attr('class', 'd3-tip') .offset([-10, 0]) return (svgSelection, state) => { // Wish we could use D3 here for DOM manipulation.. tip.html(d => [ `

${d.year} ${d.name}

`, `
${state.x}: `, `${d[state.x]}
`, `
${state.y}: `, `${d[state.y]}
`, `
${state.color}: `, `${d[state.color]}
` ].join('') ) svgSelection.call(tip) return { show: tip.show, hide: tip.hide } } })() // This component manages an svg element, and // either displays a spinner or text, // depending on the value of the `loading` state. const svg = d3.component('svg').render(function(selection, d) { const svgSelection = selection .attr('width', d.width) .attr('height', d.height) .call( spinner, !d.loading ? [] : { x: d.width / 2, y: d.height / 2, speed: 0.2 } ) const tipCallbacks = tooltip(svgSelection, d) svgSelection.call(scatterPlot, d.loading ? [] : d, tipCallbacks) }) const label = d3 .component('label', 'col-sm-2 col-form-label') .render(function(selection, d) { selection.text(d) }) const option = d3.component('option').render(function(selection, d) { selection.text(d) }) const select = d3 .component('select', 'form-control') .render(function(selection, d) { selection .call(option, d.columns) .property('value', d.value) .on('change', function() { d.action(this.value) }) }) const rowComponent = d3.component('div', 'row') const colSm10 = d3.component('div', 'col-sm-10') const menu = d3.component('div', 'col-sm-4').render(function(selection, d) { const row = rowComponent(selection).call(label, d.label) colSm10(row).call(select, d) }) const menus = d3 .component('div', 'container-fluid') .create(function(selection) { selection.style('opacity', 0) }) .render(function(selection, d) { rowComponent(selection).call( menu, [ { label: 'X', value: d.x, action: d.setX, columns: d.numericColumns }, { label: 'Y', value: d.y, action: d.setY, columns: d.numericColumns }, { label: 'Color', value: d.color, action: d.setColor, columns: d.ordinalColumns } ], d ) if (!d.loading && selection.style('opacity') === '0') { selection .transition() .duration(2000) .style('opacity', 1) } }) const app = d3.component('div').render(function(selection, d) { selection.call(menus, d).call(svg, d) }) function loadData(actions) { const numericColumns = [ 'acceleration', 'cylinders', 'displacement', 'horsepower', 'weight', 'year', 'mpg' ] const ordinalColumns = ['cylinders', 'origin', 'year'] setTimeout(() => { // Show off the spinner for a few seconds ;) d3.csv('auto-mpg.csv', type, data => { actions.ingestData(data, numericColumns, ordinalColumns) }) }, 2000) function type(d) { return numericColumns.reduce((d, column) => { d[column] = +d[column] return d }, d) } } function reducer(state, action) { state = state || { width: 960, height: 500 - 38, loading: true, margin: { top: 12, right: 12, bottom: 40, left: 50 }, x: 'acceleration', y: 'horsepower', color: 'cylinders' } switch (action.type) { case 'INGEST_DATA': return Object.assign({}, state, { loading: false, data: action.data, numericColumns: action.numericColumns, ordinalColumns: action.ordinalColumns }) case 'SET_X': return Object.assign({}, state, { x: action.column }) case 'SET_Y': return Object.assign({}, state, { y: action.column }) case 'SET_COLOR': return Object.assign({}, state, { color: action.column }) default: return state } } function actionsFromDispatch(dispatch) { return { ingestData(data, numericColumns, ordinalColumns) { dispatch({ type: 'INGEST_DATA', data, numericColumns, ordinalColumns }) }, setX(column) { dispatch({ type: 'SET_X', column }) }, setY(column) { dispatch({ type: 'SET_Y', column }) }, setColor(column) { dispatch({ type: 'SET_COLOR', column }) } } } function main() { const store = Redux.createStore(reducer) const actions = actionsFromDispatch(store.dispatch) const renderApp = () => { d3.select('body').call(app, store.getState(), actions) } renderApp() store.subscribe(renderApp) loadData(actions) } // call main() to run the app main()