LoginSignup
0

More than 5 years have passed since last update.

Mounting D3 Datamaps to containers in Cycle.js using drivers

Last updated at Posted at 2015-10-26

Recently, I made a little demo that shows my housing costs on an interactive map, like this:

Screen Shot 2015-10-26 at 7.43.35 AM.png
(Yes, San Francisco is about 3-5x the price of everywhere else in the world)

This involves using a container node to mount D3 to using Datamaps. I then take some statistics and push it to my datamaps driver and it shows my data on the map.

Making this thing

To make this work, I use the standard Cycle.js DOM driver and provide my own two drivers: one for feeding in my data, and one for handling datamaps.

Drivers

Drivers are how you do side effects and load information from those back into your application. There are two steps involved: construction and usage. My table data driver is quite simple:

export default function makeTableDataDriver() {
  // create my data
  let data = [
    ['Tokyo', 'JPN', 37.86],
    ['Nagoya', 'JPN', 47.71],
    ['Kyoto', 'JPN', 59.29],
  ];

  // transform it into the format I'm going to use
  let locations = data.map((v, i) => ({
    id: i,
    name: v[0],
    country: v[1],
    price: v[2]
  }));

  // return my driver, which can be called to get the data stream.
  // the reason for why I map my data with a function will be explained later.
  return function tableDataDriver() {
    return Rx.Observable.just(locations)
      .map(locations => state => Object.assign({}, {locations}));
  };
}

Making the Datamap driver is more involed. First, I have three regions I want to show, so I should be able to configure my driver for a given region. Second, I need a container to render in, but I will not have this container available until my application has rendered. Third, I need data to push into my maps.

While I can expose makeDataDriver : Region where type Region = String, I'm going to have to come up with a way to get my container into the input stream of this driver. Let's look at the rest of the application first.

The Application

Just like any Cycle.js application, I initialize my drivers with whatever configuration is needed and then have a main function I have run by the frameworky bits. My main function will connect my streams and transform them as needed and provide streams for the drivers themselves to consume. Nice enough, right?

Like I said above, I'll initialize my Datamap drivers with the region that I actually want.

let drivers = {
  DOM: makeDOMDriver('#app'),
  TableData: makeTableDataDriver(),
  DataMapEU: makeDatamapDriver('europe'),
  DataMapAS: makeDatamapDriver('asia'),
  DataMapUS: makeDatamapDriver('us')
};

And my main function contains this near the end:

let containerEU$ = getDOMElement$(drivers.DOM, '.datamap-europe');
let containerAS$ = getDOMElement$(drivers.DOM, '.datamap-asia');
let containerUS$ = getDOMElement$(drivers.DOM, '.datamap-us');

return {
  DOM: view$,
  DataMapEU: prepareDataMap$(containerEU$, statistics$),
  DataMapAS: prepareDataMap$(containerAS$, statistics$),
  DataMapUS: prepareDataMap$(containerUS$, statistics$)
};

where getDOMElement$ is defined

function getDOMElement$(DOMDriver, selector) {
  const DOMObservable = DOMDriver.select(selector).observable;
  return DOMObservable.map(results => results[0]);
}

Such that the DOMObservable will give me an array of document query matches, and the output stream will either output an element or [][0] === undefined.

Using this element stream, I combine this container with my statistics with a simple prepareDataMap$ function defined

function prepareDataMap$(container$, statistics$) {
  return Rx.Observable.combineLatest(
    container$, statistics$,
    (container, statistics) => ({
      container, statistics
    })
  );
}

And then my view function actually renders these containers for me:

function view(state) {
  return h('div', [
    h('div', {id: 'datamap', className: 'sections'}, [
      h('div', {className: 'datamap-europe'}),
      h('div', {className: 'datamap-asia'}),
      h('div', {className: 'datamap-us'})
    ]),
    h('div', {style: {width: '300px', margin: 'auto'}}, [
      locationsList(state.locations)
    ])
  ]);
}

Writing this Datamap driver

There's a lot of crap involved in setting up Datamaps, but I'll just paraphrase. I prepare a projection based on the region I want to look at and that is what gets used when I mount Datamaps to my container. I draw a Choropleth for country prices, and bubbles for city prices.

Here's the gist of what I do:

export default function makeDatamapDriver(region) {
  return function datamapDriver(input$) {
    let datamap;

    input$.subscribe(({container, statistics}) => {
      if (!container) {
        // if we have no container, then do cleanup on what we had before to prevent leakage
        datamap = null;
        return;
      } else if (!datamap) {
        // if we have a container but no datamap instance, create one
        datamap = new Datamap({
          element: container,
          setProjection: getProjection(region),
          [...]
        });
      }

      if (!datamap) {
        // if we don't have a datamap instance, then quit out
        return;
      }

      // draw our choropleth
      // draw our bubbles
    });
  };
}

And that's about it! This is how I handle mounting on to a container and updating my datamaps. It might not be super pretty, but I can reasonably do 'afterRender' and 'afterTeardown' operations this way.

I have an alternativeView function that you can try that will even toggle the container nodes on and off, making the Datamaps driver mount new datamaps instances accordingly.

function alternativeView(state) {
  return Rx.Observable.interval(1000).map(function (time) {
    if (time % 2 === 0) {
      return h('div');
    } else {
      return h('div', [
        h('div', {id: 'datamap', className: 'sections'}, [
          h('div', {className: 'datamap-europe'}),
          h('div', {className: 'datamap-asia'}),
          h('div', {className: 'datamap-us'})
        ]),
        h('div', {style: {width: '300px', margin: 'auto'}}, [
          locationsList(state.locations)
        ])
      ]);
    }
  });
}

Demo & Repo

I have a demo up here: http://justinwoo.github.io/housing-costs-maps
Repo is here: https://github.com/justinwoo/housing-costs-maps

Conclusion

So I hope you found this a little bit useful on how you can write drivers to have side effects in your application (even DOM side effects separate from the DOM driver).

If you made it this far, thanks! Let me know if you found this at all useful or have a bettter solution on Twitter: (@jusrin00).

References

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0