Create US counties heatmap with Svelte and d3js

Let’s create US counties map like below to represent unemployment rate in different counties.


Let’s start with our imports. We are importing a TopoJSON file which contains information of counties in US and another JSON file which contains the unemployment rate per county.

<script>
  import US from './us-counties.topojson.json';
  import dataRaw from './county_unemployment_rate.json';
</script>

The us-counties.topojson file has the following structure:

- Topology
  - transform
    - scale (Array[2])
    - translate (Array[2])
  - objects
    - counties
    - states
    - land
  - arcs (Array[10890])
    - Array[5]
    - Array[3]
    - Array[6]
    - Array[3]
    - Array[5]
    - Array[4]
    - Array[4]

The county_unemployment_rate.json file contains data in the following format:


  {
    "id": "01001",
    "state": "Alabama",
    "county": "Autauga County",
    "rate": "5.1"
  },
  {
    "id": "01003",
    "state": "Alabama",
    "county": "Baldwin County",
    "rate": "4.9"
  },

  //

  //

Understanding GeoJSON & TopoJSON

GeoJSON is a format for encoding a variety of geographic data structures using JSON, making it easy to represent features like points, lines, and polygons on a map.

TopoJSON is an extension of GeoJSON that encodes geographic data with topology, it is an efficient representation of GeoJSON data that eliminates the shared borders between shapes which reduces the file size.

However, since the D3.js’s functions expect GeoJSON data, we will convert it back to GeoJSON format before using in our visualizations.

<script>
  import dataRaw from './county_unemployment_rate.json';
  import US from './us-counties.topojson.json';

  import { geoPath, geoAlbersUsa } from 'd3-geo';
  import { feature, mesh } from 'topojson-client';
  import { scaleLinear } from 'd3-scale';

  import { extent } from 'd3-array';
  import Legend from './Legend.svelte';

  let unemploymentData;
  let margin = { top: 10, left: 10, right: 10, bottom: 40 };
  let width;
  $: height = width * 0.8;

  unemploymentData = new Map(dataRaw.map((i) => [+i.id, +i.rate]));

  let counties = feature(US, US.objects.counties).features;
  let stateMesh = mesh(US, US.objects.states, (a, b) => a !== b);
  let projection, path;

  $: projection = geoAlbersUsa()
    .fitSize([width, height], feature(US, US.objects.counties))
    .translate([width / 2, height / 2]);

  $: path = geoPath().projection(projection);

  let color;

  $: if (unemploymentData) {
    color = scaleLinear()
      .domain(extent([...unemploymentData.values()]))
      .range(['white', 'purple']);
  }
</script>

<div bind:clientWidth={width}>
  {#if width}
    <svg
      width={width + margin.left + margin.right}
      height={height + margin.top + margin.bottom}>
      <!-- add counties -->
      {#each counties as county}
        <path
          d={path(county)}
          fill={unemploymentData.get(county.id) !== undefined
            ? color(unemploymentData.get(county.id))
            : 'white'}
          stroke="lightgrey"
          stroke-width="1" />
      {/each}

      <!-- add mesh of the states -->
      {#if stateMesh}
        <path d={path(stateMesh)} fill="none" stroke="gray" stroke-width="1" />
      {/if}

      <Legend
        width={width + margin.left + margin.right}
        height={height + margin.top + margin.bottom}
        {color} />
    </svg>
  {/if}
</div>

Let’s explain the code above. feature function from ‘topojson-client’ library converts the TopoJSON file to a GeoJSON type.

Typically, GeoJSON follows the following structure with a type is either Feature or FeatureCollection:

Here is an example Feature with geometry type MultiPolygon representing a county from our data.

{
  "type": "Feature",
  "id": 53073,
  "properties": {},
  "geometry": {
    "type": "MultiPolygon",
    "coordinates": [
      [
        [
          [-120.85361463682315, 49.0001146177235],
          [-120.76747157859248, 48.95445656856378],
          [-120.73516793175597, 48.783104595834956],
          [-120.65620346171119, 48.72186909460898],
          [-120.7531144022207, 48.656873518746316],
          [-120.91463263640321, 48.64075891316054],
          [-122.49033274320601, 48.645593294836274],
          [-122.54417215460018, 48.77665875360064],
          [-122.64826168329559, 48.715960405894194],
          [-122.64467238920264, 48.784178902874004],
          [-122.79183344701337, 48.894832527896384],
          [-122.75594050608393, 49.00226323180161],
          [-121.72222380731581, 48.99742885012587],
          [-120.85361463682315, 49.0001146177235]
        ]
      ]
    ]
  }
}

Feature collection type organizes multiple Features into one group:

{
  "type": "FeatureCollection",
  "features": [ ** array of Feature objects **]
}

Thus, with the below line we are getting an array of feature objects with each feature representing a county in US.

<script>
  let counties = feature(US, US.objects.counties).features;
</script>

Similarly, the following line creates a single feature object representing the mesh for the states, in this case the geometry type of our feature will be MultiLineString.

<script>
  let stateMesh = mesh(US, US.objects.states, (a, b) => a !== b);
</script>

Map Projection

Map projections enable us to draw a spherical object (like Earth) onto a flat surface such as our browser.

<script>
  $: projection = geoAlbersUsa()
    .fitSize([width, height], feature(US, US.objects.counties))
    .translate([width / 2, height / 2]);

  $: path = geoPath().projection(projection);
</script>

geoAlbersUsa(): This is a specific type of projection from D3 that is well-suited for maps of the United States. It distorts the map minimally and focuses on the US.

fitSize([width, height], feature(US, US.objects.counties)): This method scales and positions the projection so that the geographic features fit within the specified width and height of the SVG element. It ensures that the entire map of counties is visible within the given dimensions.

translate([width / 2, height / 2]): This moves the origin of the projection to the center of the SVG, so the map is centered properly.

path = geoPath().projection(projection): The path function in D3 converts the projected geographic data into SVG path data that we can feed into its d attribute.

<script>
  $: projection = geoAlbersUsa()
    .fitSize([width, height], feature(US, US.objects.counties))
    .translate([width / 2, height / 2]);

  $: path = geoPath().projection(projection);
</script>

You can console.log the objects to see their structures:

$: console.log(
  'TopoJSON:',
  US,
  'GeoJSON',
  feature(US, US.objects.counties),
  'State Mesh',
  stateMesh
);

We will use a linear color scale to represent the unemployment rate as the county background color.

  $: if (unemploymentData) {
    color = scaleLinear()
      .domain(extent([...unemploymentData.values()]))
      .range(['white', 'purple']);
  }

Finally, we are using <path> elements to render counties and the state mesh in our Svg.

{#each counties as county}
  <path
    d={path(county)}
    fill={unemploymentData.get(county.id) !== undefined
      ? color(unemploymentData.get(county.id))
      : 'white'}
    stroke="lightgrey"
    stroke-width="1" />
{/each}

<!-- add mesh of the states -->
{#if stateMesh}
  <path d={path(stateMesh)} fill="none" stroke="gray" stroke-width="1" />
{/if}

Legend

The code for the Legend component:

<script>
  import { scaleLinear } from 'd3-scale';
  import { onMount } from 'svelte';

  export let color;
  export let width;
  export let height;

  let legendWidth = 150; // Default value
  let boxHeight = 15; // Default value

  let tickValues = [];
  let gradientStops = [];

  onMount(() => {
    const domain = color.domain();
    const range = color.range();
    gradientStops = range.map((color, i) => ({
      offset: (i / (range.length - 1)) * 100,
      color
    }));

    const scale = scaleLinear().domain(domain).range([0, width]);
    const ticks = scale.ticks(5);

    tickValues = ticks.map((tick) => ({
      value: tick,
      offset: (scale(tick) / width) * 100
    }));
  });
</script>

<!-- Gradient  -->
<defs>
  <linearGradient id="gradient">
    {#each gradientStops as stop}
      <stop offset="{stop.offset}%" stop-color={stop.color} />
    {/each}
  </linearGradient>
</defs>

<!-- Axis -->
<g transform="translate({width - legendWidth - 40}, {height - width / 7 + 10})">
  <text dy="2.5em" text-anchor="start" fill="currentColor"
    >Unemployment Rate</text>
  <rect
    x="0"
    y="0"
    width={legendWidth}
    height={boxHeight}
    fill="url(#gradient)"
    class="gradient" />
  {#each tickValues as tick}
    <g transform="translate({(tick.offset * legendWidth) / 100}, -20)">
      <line y1="18" y2="15" stroke="white" />
      <text dy="1em" text-anchor="middle" font-size="12px" fill="currentColor"
        >{tick.value}</text>
    </g>
  {/each}
</g>

Happy hacking!