block by curran f3de248a91fe2995a97aedc6ea0c39ad

Choropleth Map with Interactive Filtering

Full Screen

index.js

import {
  select,
  scaleOrdinal,
  schemeSpectral
} from 'd3';
import { loadAndProcessData } from './loadAndProcessData';
import { colorLegend } from './colorLegend';
import { choroplethMap } from './choroplethMap';

const svg = select('svg');
const choroplethMapG = svg.append('g');
const colorLegendG = svg.append('g')
    .attr('transform', `translate(40,310)`);

const colorScale = scaleOrdinal();

// const colorValue = d => d.properties.income_grp;
const colorValue = d => d.properties.economy;

let selectedColorValue;
let features;

const onClick = d => {
  selectedColorValue = d;
  render();
};

loadAndProcessData().then(countries => {
  features = countries.features;
  render();
});

const render = () => {
  colorScale
    .domain(features.map(colorValue))
    .domain(colorScale.domain().sort().reverse())
    .range(schemeSpectral[colorScale.domain().length]);
  
  colorLegendG.call(colorLegend, {
    colorScale,
    circleRadius: 8,
    spacing: 20,
    textOffset: 12,
    backgroundRectWidth: 235,
    onClick,
    selectedColorValue
  });
  
  choroplethMapG.call(choroplethMap, {
    features,
    colorScale,
    colorValue,
    selectedColorValue
  });
};

index.html


<!DOCTYPE html>
<html>
  <head>
    <title>Choropleth Map with Interactive Filtering</title>
    <link rel="stylesheet" href="styles.css">
    <script src="https://unpkg.com/d3@5.6.0/dist/d3.min.js"></script>
    <script src="https://unpkg.com/topojson@3.0.2/dist/topojson.min.js"></script>
  </head>
  <body>
    <svg width="960" height="500"></svg>
    <script src="bundle.js"></script>
  </body>
</html>

bundle.js

(function (topojson,d3) {
  'use strict';

  const loadAndProcessData = () => 
    Promise
      .all([
        d3.tsv('https://unpkg.com/world-atlas@1.1.4/world/50m.tsv'),
        d3.json('https://unpkg.com/world-atlas@1.1.4/world/50m.json')
      ])
      .then(([tsvData, topoJSONdata]) => {
        const rowById = tsvData.reduce((accumulator, d) => {
          accumulator[d.iso_n3] = d;
          return accumulator;
        }, {});

        const countries = topojson.feature(topoJSONdata, topoJSONdata.objects.countries);

        countries.features.forEach(d => {
          Object.assign(d.properties, rowById[d.id]);
        });

        return countries;
      });

  const colorLegend = (selection, props) => {
    const {                      
      colorScale,                
      circleRadius,
      spacing,                   
      textOffset,
      backgroundRectWidth,
      onClick,
      selectedColorValue
    } = props;                   
    
    const backgroundRect = selection.selectAll('rect')
      .data([null]);             
    const n = colorScale.domain().length; 
    backgroundRect.enter().append('rect')
      .merge(backgroundRect)
        .attr('x', -circleRadius * 2)   
        .attr('y', -circleRadius * 2)   
        .attr('rx', circleRadius * 2)   
        .attr('width', backgroundRectWidth)
        .attr('height', spacing * n + circleRadius * 2) 
        .attr('fill', 'white')
        .attr('opacity', 0.8);

    const groups = selection.selectAll('.tick')
      .data(colorScale.domain());
    const groupsEnter = groups
      .enter().append('g')
        .attr('class', 'tick');
    groupsEnter
      .merge(groups)
        .attr('transform', (d, i) =>    
          `translate(0, ${i * spacing})`  
        )
        .attr('opacity', d =>
          (!selectedColorValue || d === selectedColorValue)
            ? 1
            : 0.2
        )
        .on('click', d => onClick(
          d === selectedColorValue
            ? null
            : d
        ));
    groups.exit().remove();
    
    groupsEnter.append('circle')
      .merge(groups.select('circle')) 
        .attr('r', circleRadius)
        .attr('fill', colorScale);      
    
    groupsEnter.append('text')
      .merge(groups.select('text'))   
        .text(d => d)
        .attr('dy', '0.32em')
        .attr('x', textOffset);
  };

  const projection = d3.geoNaturalEarth1();
  const pathGenerator = d3.geoPath().projection(projection);

  const choroplethMap = (selection, props) => {
    const {
      features,
      colorScale,
      colorValue,
      selectedColorValue
    } = props;
    
    console.log(features);
    
    const gUpdate = selection.selectAll('g').data([null]);
    const gEnter = gUpdate.enter().append('g');
    const g = gUpdate.merge(gEnter);
    
    gEnter
      .append('path')
        .attr('class', 'sphere')
        .attr('d', pathGenerator({type: 'Sphere'}))
      .merge(gUpdate.select('.sphere'))
        .attr('opacity', selectedColorValue ? 0.05 : 1);

    selection.call(d3.zoom().on('zoom', () => {
      g.attr('transform', d3.event.transform);
    }));
    
    const countryPaths = g.selectAll('.country')
      .data(features);
    const countryPathsEnter = countryPaths
      .enter().append('path')
        .attr('class', 'country');
    countryPaths
      .merge(countryPathsEnter)
        .attr('d', pathGenerator)
        .attr('fill', d => colorScale(colorValue(d)))
        .attr('opacity', d =>
          (!selectedColorValue || selectedColorValue === colorValue(d))
            ? 1
            : 0.1
        )
        .classed('highlighted', d =>
          selectedColorValue && selectedColorValue === colorValue(d)
        );
    
    countryPathsEnter.append('title')
        .text(d => d.properties.name + ': ' + colorValue(d));
  };

  const svg = d3.select('svg');
  const choroplethMapG = svg.append('g');
  const colorLegendG = svg.append('g')
      .attr('transform', `translate(40,310)`);

  const colorScale = d3.scaleOrdinal();

  // const colorValue = d => d.properties.income_grp;
  const colorValue = d => d.properties.economy;

  let selectedColorValue;
  let features;

  const onClick = d => {
    selectedColorValue = d;
    render();
  };

  loadAndProcessData().then(countries => {
    features = countries.features;
    render();
  });

  const render = () => {
    colorScale
      .domain(features.map(colorValue))
      .domain(colorScale.domain().sort().reverse())
      .range(d3.schemeSpectral[colorScale.domain().length]);
    
    colorLegendG.call(colorLegend, {
      colorScale,
      circleRadius: 8,
      spacing: 20,
      textOffset: 12,
      backgroundRectWidth: 235,
      onClick,
      selectedColorValue
    });
    
    choroplethMapG.call(choroplethMap, {
      features,
      colorScale,
      colorValue,
      selectedColorValue
    });
  };

}(topojson,d3));

choroplethMap.js

import {
  geoPath,
  geoNaturalEarth1,
  zoom,
  event
} from 'd3';

const projection = geoNaturalEarth1();
const pathGenerator = geoPath().projection(projection);

export const choroplethMap = (selection, props) => {
  const {
    features,
    colorScale,
    colorValue,
    selectedColorValue
  } = props;
  
  console.log(features);
  
  const gUpdate = selection.selectAll('g').data([null]);
  const gEnter = gUpdate.enter().append('g');
  const g = gUpdate.merge(gEnter);
  
  gEnter
    .append('path')
      .attr('class', 'sphere')
      .attr('d', pathGenerator({type: 'Sphere'}))
    .merge(gUpdate.select('.sphere'))
      .attr('opacity', selectedColorValue ? 0.05 : 1);

  selection.call(zoom().on('zoom', () => {
    g.attr('transform', event.transform);
  }));
  
  const countryPaths = g.selectAll('.country')
    .data(features);
  const countryPathsEnter = countryPaths
    .enter().append('path')
      .attr('class', 'country');
  countryPaths
    .merge(countryPathsEnter)
      .attr('d', pathGenerator)
      .attr('fill', d => colorScale(colorValue(d)))
      .attr('opacity', d =>
        (!selectedColorValue || selectedColorValue === colorValue(d))
          ? 1
          : 0.1
      )
      .classed('highlighted', d =>
        selectedColorValue && selectedColorValue === colorValue(d)
      )
  
  countryPathsEnter.append('title')
      .text(d => d.properties.name + ': ' + colorValue(d));
};

colorLegend.js

export const colorLegend = (selection, props) => {
  const {                      
    colorScale,                
    circleRadius,
    spacing,                   
    textOffset,
    backgroundRectWidth,
    onClick,
    selectedColorValue
  } = props;                   
  
  const backgroundRect = selection.selectAll('rect')
    .data([null]);             
  const n = colorScale.domain().length; 
  backgroundRect.enter().append('rect')
    .merge(backgroundRect)
      .attr('x', -circleRadius * 2)   
      .attr('y', -circleRadius * 2)   
      .attr('rx', circleRadius * 2)   
      .attr('width', backgroundRectWidth)
      .attr('height', spacing * n + circleRadius * 2) 
      .attr('fill', 'white')
      .attr('opacity', 0.8);

  const groups = selection.selectAll('.tick')
    .data(colorScale.domain());
  const groupsEnter = groups
    .enter().append('g')
      .attr('class', 'tick');
  groupsEnter
    .merge(groups)
      .attr('transform', (d, i) =>    
        `translate(0, ${i * spacing})`  
      )
      .attr('opacity', d =>
        (!selectedColorValue || d === selectedColorValue)
          ? 1
          : 0.2
      )
      .on('click', d => onClick(
        d === selectedColorValue
          ? null
          : d
      ));
  groups.exit().remove();
  
  groupsEnter.append('circle')
    .merge(groups.select('circle')) 
      .attr('r', circleRadius)
      .attr('fill', colorScale);      
  
  groupsEnter.append('text')
    .merge(groups.select('text'))   
      .text(d => d)
      .attr('dy', '0.32em')
      .attr('x', textOffset);
}

loadAndProcessData.js

import { feature } from 'topojson';
import { tsv, json } from 'd3';
export const loadAndProcessData = () => 
  Promise
    .all([
      tsv('https://unpkg.com/world-atlas@1.1.4/world/50m.tsv'),
      json('https://unpkg.com/world-atlas@1.1.4/world/50m.json')
    ])
    .then(([tsvData, topoJSONdata]) => {
      const rowById = tsvData.reduce((accumulator, d) => {
        accumulator[d.iso_n3] = d;
        return accumulator;
      }, {});

      const countries = feature(topoJSONdata, topoJSONdata.objects.countries);

      countries.features.forEach(d => {
        Object.assign(d.properties, rowById[d.id]);
      });

      return countries;
    });

package.json

{
  "scripts": {
    "build": "rollup -c"
  },
  "devDependencies": {
    "rollup": "latest"
  }
}

rollup.config.js

export default {
  input: 'index.js',
  external: ['d3'],
  output: {
    file: 'bundle.js',
    format: 'iife',
    sourcemap: true,
    globals: { d3: 'd3' }
  }
};

styles.css

body {
  margin: 0px;
  overflow: hidden;
}

.sphere {
  fill: #4242e4;
}

.country {
  stroke: black;
  stroke-width: 0.05px;
}

.country.highlighted {
  stroke-width: 0.5px;
}

.country:hover {
  fill: red;
}

.tick {
  cursor: pointer;
}

.tick text {
  font-size: 1em;
  fill: black;
  font-family: sans-serif;
}

.tick circle {
  stroke: black;
  stroke-opacity: 0.5;
}