Skip to content

Instantly share code, notes, and snippets.

@jsanz
Forked from andy-esch/README.md
Last active November 24, 2015 19:27
Show Gist options
  • Save jsanz/c28f0d5bd2c518cddf5c to your computer and use it in GitHub Desktop.
Save jsanz/c28f0d5bd2c518cddf5c to your computer and use it in GitHub Desktop.
Elections webinar

Get ready for elections workshop

With Jorge Sanz (@xurxosanz - jorge@cartodb.com), materials from Mamata Akella (@mamataakella) and Andy Eschbacher (@MrEPhysics)

Slides here

Part 1: Importing our Datasets

We are going to be using a dataset of 2012 Presidential Election Results from Data.gov. To make it easier tonight, we made a simplified version.

To import it into your account, go to the following page and click "CREATE MAP":

https://elections-cartocamp.cartodb.com/tables/election_results_2012/public/map

This will import the dataset as cartodb_query or something like that. We need to change the name of the table to something more reasonable. Change this by:

  1. Opening the tray on the right
  2. Clicking on SQL
  3. Clicking on the hyper-linked table name
  4. and finally double-clicking the table name in the upper right

Go back to the original map by going to your dashboard, clicking the Datasets/Map dropdown on the top, and then choosing the map that was created when you imported your dataset.

The next dataset we will import by connecting with an external source. Start by:

  1. Clicking "+ Add Layer" on the top of the tray on the right,
  2. Click "Connect Dataset"
  3. And paste in the following URL:
https://elections-cartocamp.cartodb.com:443/api/v2/sql?q=select%20*%20from%20public.state_county_boundaries&format=geojson&filename=state_county_boundaries

Exploring Our Dataset with SQL

Since we are making a choropleth map displaying the election results, we need to find the breaks for the vote percentages to change the colors that correspond to voting within ranges.

We can explore the bounds of our data using the min(value) and max(value) aggregate functions built into SQL. We will also need to filter by the winning candidate.

To find the minimum percentage Obama got to win a county, we would do the following:

SELECT 
  min(pct_obm) 
FROM 
  election_results_2012 
WHERE 
  winner = 'Obama'

This will produce:

  min
--------
48.72348

We are using a basic SELECT over an aggregate of one column and filtering by which candidate won. We will be doing similar queries later in this workshop.

Similarly, we can do the same to find the maximum for Obama, and the min and max for Romney. Once we have these values we can use them to assign values to classes to visualize our data.

To see everything together we can leverage the GROUP BY clause in order to get maximum and minimums for both candidates grouped by where they won:

SELECT
  winner,
  max(pct_obm) as max_obama,
  min(pct_obm) as min_obama,
  max(pct_rom) as max_romney,
  min(pct_rom) as min_romney
FROM election_results_2012
GROUP BY winner

"Group by"

Since the minimum value is around 48% for each, we can choose breaks such as these:

  • 45 - 55 for a smaller win
  • 55 - 65 for a larger win
  • 65+ for a huge win

In CartoCSS, we will need to write rules similar to this to make it symbolize:

#layer [pct_rom > 45] {
  'light red';
}
#layer [pct_rom > 55] {
  'medium red';
}
#layer [pct_rom > 65] {
  'dark red';
}

We don't need any of that yet, but now that we have a good understanding of our dataset, let's design a basemap for it.

If you want to get more into SQL our SQL and PostGIS in CartoDB course will give you a more in detail view of how to leverage the best geospatial database.

Part 2: Projections

Our final maps use the Albers Equal Area Conic projection centered on the contiguous United States (SRID 5070). This is a common projection for thematic maps of the US. This is an equal area projection meaning areas are preserved and distortion is minimized.

This projection is not part of the default spatial_ref_sys table in your CartoDB account. For a more detailed discussion on projections with CartoDB see this blog. So if you run the next queries on your account you'll get this error:

5070 not found

In order to fix this you need to run this query on the SQL tray so CartoDB will know about this projection:

INSERT into spatial_ref_sys (srid, auth_name, auth_srid, proj4text, srtext)
values ( 5070, 'EPSG', 5070, '+proj=aea +lat_1=29.5 +lat_2=45.5 +lat_0=23
+lon_0=-96 +x_0=0 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m
+no_defs ', 'PROJCS["NAD83 / Conus Albers",GEOGCS["NAD83",
DATUM["North_American_Datum_1983",SPHEROID["GRS 1980",6378137,298.257222101,
AUTHORITY["EPSG","7019"]],TOWGS84[0,0,0,0,0,0,0],AUTHORITY["EPSG","6269"]],
PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",
0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4269"]],
PROJECTION["Albers_Conic_Equal_Area"],PARAMETER["standard_parallel_1",29.5],
PARAMETER["standard_parallel_2",45.5],PARAMETER["latitude_of_center",23],
PARAMETER["longitude_of_center",-96],PARAMETER["false_easting",0],
PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],
AXIS["X",EAST],AXIS["Y",NORTH],AUTHORITY["EPSG","5070"]]');

Don't be afraid, just copy & paste this SQL on your tray and execute it. From now on you can use Albers projection on your maps!

The following SQL queries do a couple of things:

  • project the data using ST_Transform
  • and also define any attributes that we'll need for styling and/or querying later in the process
  • cartodb_id needs to be selected to enable interactivity on any layer

state_county_boundaries (first copy)

SELECT
  ST_Transform(the_geom, 5070) AS
    the_geom_webmercator,
  feature
FROM
  state_county_boundaries

state_county_boundaries (second copy)

SELECT
  ST_Transform(the_geom, 5070) AS
    the_geom_webmercator
FROM
  state_county_boundaries

election_results_2012

SELECT
  ST_Transform(the_geom, 5070) AS
    the_geom_webmercator,
  cartodb_id,
  county,
  fips,
  obama,
  others,
  pct_obm,
  pct_othr,
  pct_rom,
  pct_wnr,
  romney,
  state,
  state_fips,
  ttl_vt,
  winner
FROM
  election_results_2012

Part 3: Basemap Design

Now that the data are added, projected, and the attributes we need are queried, we'll make a simple basemap that we can use for all of our election maps:

basemap

The purpose of the basemap is to provide geographic context to help interpret the election results without distracting from them visually.

Rename and Order Layers

  • First, let's rename each layer and reorder them as follows:
    • reference
    • elections
    • base

Turn off Elections Data and Positron

  • Since we are designing the basemap right now, we can turn off the elections layer
  • Let's also turn off the default basemap Positron and make the background white
    • click the option Change Basemap in the bottom left hand corner of the map editor view
    • next, choose the option for Custom
    • and then click the white color chip (#FFFFFF)

Base Layer Design

  • We'll start with the base layer
  • This is the solid background for the basemap. We won't symbolize lines in this layer, we'll do that in the reference layer.
  • Expand the CartoCSS Editor by clicking on CSS in the right hand layer panel
  • We'll modify the default CartoCSS to just fill the polygon with a neutral gray:
#state_county_boundaries {
   polygon-fill: #E1E1E1;
}
  • Click Apply Style to see the changes

Reference Layer Design

  • We'll start with the reference layer where we'll symbolize state and county lines
  • Let's look at DATA VIEW to see what attributes we have in the feature column
  • We'll symbolize state lines and county lines (depending on zoom level) so we'll need the feature attribute and its two values county and state to do that
  • Let's go back to MAP VIEW and expand the reference layer and modify the defualt CartoCSS
  • First, let's differentiate between which lines are state lines and which lines are county lines using the feature attribute and assigning each type a bold color:
#state_county_boundaries {
   line-color: #3B007F;
   line-width: 0.5;
   line-opacity: 1;
 
   [feature='state']{
     line-color: blue;
   }
 
   [feature='county']{
     line-color: green;
   }
 }
  • Next, we'll define which zoom level each layer will draw:
#state_county_boundaries {
  [feature='state'][zoom>=4],
  [feature='county'][zoom>=5]{
  
    line-color: #3B007F;
    line-width: 0.5;
    line-opacity: 1;
  
    [feature='state']{
      line-color: blue;
    }
  
    [feature='county']{
      line-color: green;
    }
  }
}
  • And then we'll assign some global variables to all lines and more specific styling to state lines and county lines specifically
  • Since we want all lines to be white, we can set that as a global property:
#state_county_boundaries {
   [feature='state'][zoom>=4],
   [feature='county'][zoom>=5]{
 
     line-color: #3B007F;
     line-width: 0.5;
     line-opacity: 1;
 ...
  • Next, we can assign feature specific styling for state lines (with a larger line-width) and county lines (with a smaller line-width) to push them to the background:
#state_county_boundaries {

  [feature='states'][zoom>=4],
  [feature='county'][zoom>=5]{

    line-color: #fff;

    [feature='states']{
      line-width: 1;
    }
  
    [feature='county']{
      line-width: 0.25;
    }
  }
}

Ok! Now we're done with the basemap. Once we get our thematic information sandwiched in, we can adjust the design and any zoom dependant styling we might need.

Check the details of SQLs and symbology on this CDBFiddle

Part 4: Choropleth Map

Design Time!

Now that we know the values to use in the data, we'll write out the CartoCSS to symbolize each range of values for each candidate using appropriate colors.

Duplicate the Current Map

  • We'll keep this version of the map as our basemap template and make a copy to design the other maps
  • In the top right of the MAP VIEW click Edit and choose the option to Duplicate map
  • Rename the new map to Elections: Choropleth
  • Turn on the elections_2012 layer

Assign Colors to Each Candidate

  • We have two colors that we'll use for our maps a blue (#2F4886) for Obama/Democrat and a red (#AD373E) for Romney/Republican. We'll assign these two colors as CartoCSS variables that we can use throughout the different styles election maps
  • Open the CartoCSS Editor for the elections_2012 layer by clicking on CSS
  • Add these two variables above the CartoCSS:
@obama: #2F4886;
@romney:#AD373E;
  • As a first step, let's color each county based on the winner using the color variables for each candidate based on the winner field:
@obama: #2F4886;
@romney:#AD373E;
 
#election_results_2012 {
  
  //style for Obama
  [winner='Obama'] {
    polygon-fill: @obama;
  }
  
  //style for Romney
  [winner='Romney'] {
    polygon-fill: @romney;
  }
}
  • Click Apply Style to see the map update

winners absolute

Symbolize Each County by Winner and Percent Vote

  • Next, we'll write out the CartoCSS to symbolize each county based on the percentage votes for each candidate in the counties they won using the classifications we came up with. The fields that we'll use are winner,pct_rom,pct_obm
  • The three breaks that we determined are:
>=45
>=55
>=65
  • We'll use these numbers to write out our class breaks in CartoCSS and use a CartoCSS color variable (lighten) to make counties with less votes lighter
    • Let's start with Obama:
@obama: #2F4886;
@romney:#AD373E;
 
#election_results_2012 {
  
  //style for Obama
  [winner='Obama'] {
    polygon-fill: @obama;
    
    [pct_obm >= 45]{
      polygon-fill: lighten(@obama,40);
    }
    [pct_obm >= 55]{
      polygon-fill: lighten(@obama,20);
    }
    [pct_obm >= 65]{
      polygon-fill:@obama;
    }
  }
  
  //style for Romney
  [winner='Romney'] {
    polygon-fill: @romney;
  }
}
  • And then, the same for Romney:
@obama: #2F4886;
@romney:#AD373E;
 
#election_results_2012 {
  
  //style for Obama
  [winner='Obama'] {
    polygon-fill: @obama;
    
    [pct_obm >= 45]{
      polygon-fill: lighten(@obama,40);
    }
    [pct_obm >= 55]{
      polygon-fill: lighten(@obama,20);
    }
    [pct_obm >= 65]{
      polygon-fill:@obama;
    }
  }
  
  //style for Romney
  [winner='Romney'] {
    polygon-fill: @romney;
  
    [pct_rom >= 45]{
      polygon-fill: lighten(@romney,40);
    }
    [pct_rom >= 55]{
      polygon-fill: lighten(@romney,20);
    }
    [pct_rom >= 65]{
      polygon-fill: @romney;
    }
  }
}

winners percent

Check the details of SQLs and symbology on this CDBFiddle

Part 5: Proportional Symbol Map

Again, duplicate your map to produce this new map.

To produce a proportional map we need to compute the size of the symbol for every county. To do that we will do two things:

  • We will create a sub query that will obtain the maximum difference between the results of both candidates
  • Then we will apply a normalization formula using that difference

proportional symbols

The next query does the job but also instead of returning the county boundary as in the previous map it will return its centroid as a point geometry.

With ms As (SELECT max(abs(romney-obama)) As max_diff FROM election_results_2012)

SELECT 
  abs(romney - obama) As vote_diff,
  50 * sqrt(abs(romney - obama) / max_diff) As symbol_size,
  winner,
  romney,
  obama,
  ttl_vt,
  ST_Transform(ST_Centroid(the_geom),5070) As the_geom_webmercator,
  state_fips,
  fips,
  state,
  county,
  round(pct_obm::numeric,2) AS pct_obm,
  round(pct_rom::numeric,2) AS pct_rom,
  cartodb_id
FROM 
  election_results_2012, ms
ORDER BY
  symbol_size desc

Design Time!

Since all of the cartographic thinking has been written into the data for this map, the amount of styling that we have to do is minimal. We'll use the field symbol_size to assign symbol sizes, use the colors we have for Obama and Romney, and then make some final tweaks to the overall design.

  • First, let's get the ordering of the data right in the map
  • We want the proportional symbols to draw on top of the county and state lines with the solid base on the bottom
  • Next, open the CartoCSS Editor for the layer
  • First, we'll add our color variables for both candidates, symbolize the points using the symbol_size attribute for marker-width, and then setting marker-allow-overlap to true so all of the symbols draw:
@obama: #2F4886;
@romney:#AD373E;
 
#election_results_2012 {
  marker-width: [symbol_size];
  marker-allow-overlap: true;
}
 
  • Next, we'll use the winner attribute to set the color for each symbol:
@obama: #2F4886;
@romney:#AD373E;

#election_results_2012 {

  marker-width: [symbol_size];
  marker-allow-overlap: true;
 
  [winner='Obama']{
    marker-fill: @obama;
  }
 
  [winner='Romney']{
    marker-fill: @romney;
  }
 
}
  • And, finally, we'll add an outline to the points so the overlapping ones are visible against each other
#election_results_2012 {

  marker-width: [symbol_size];
  marker-allow-overlap: true;
  marker-line-width: 0.5;

  [winner='Obama']{
    marker-fill: @obama;
    marker-line-color: lighten(@obama,25);
  }

  [winner='Romney']{
    marker-fill: @romney;
    marker-line-color: lighten(@romney,25);
  }
}

Popup configuration

With a bit of basic HTML and the use of Mustache templates for displaying values from the database, we can create hovers for each of the symbols to give the underlying vote percentage.

To change the template, go to the Infowindow tray, click on "Hover" at the top of the tray. First select all of the fields by toggling the last switch on the bottom of the list.

Next click on the </> tag in the upper right to customize the HTML. Replace the HTML there with the following:

<div class="cartodb-tooltip-content-wrapper dark">
  <div class="cartodb-tooltip-content">
      <p><b>{{county}}, {{state}}</b></p>
    <p>Romney (R): {{pct_rom}}%</p>
    <p>Obama (D): {{pct_obm}}%</p>
  </div>
</div>

Proportional map

Check the details of SQLs and symbology on this CDBFiddle

Part 6: Bonus Section

Put all of your maps into a nifty template:

Part 7: Bonus Section 2

In our maps Alaska and Hawaii get too far from the main map. That's pretty common in many country maps so we'll see here how to move those parts to get them closer to the main map to make them more readable.

What we will do is a bit of PostGIS fiddling here in order to detect the geometries on certain bounding boxes and then translate them to a more appropriate place.

Duplicate your base map and set this query as the base layer:

SELECT
  CASE
  -- Hawaii
  WHEN
    the_geom_webmercator && ST_MakeEnvelope(-18050757,1967794,-16907259,2621484,3857)
  THEN
      ST_Translate(
        ST_Transform(the_geom, 5070),
        4.5e6,-1.5e6)
  -- Alaska
  WHEN
    the_geom_webmercator && ST_MakeEnvelope(-20878927,6540563,-13629027,11491237,3857)
  THEN
     ST_Translate(
        ST_Transform(the_geom, 5070),
        -.5e6,-4.5e6)
  -- Rest of the geometries
  ELSE
    ST_Transform(the_geom, 5070)
  END AS the_geom_webmercator,
  feature
FROM
  state_county_boundaries

instead of the original:

SELECT
  ST_Transform(the_geom, 5070) AS
    the_geom_webmercator,
  feature
FROM
  state_county_boundaries

In our query what we do is checking if the geometry bounding box is intersecting a rectangle made by the ST_MakeEnvelope. To get those coordinates boxes you can use a tool like bboxfinder.com. Once a geometry is trapped by the condition then use the ST_Translate to move it in the X and Y axes. Those axes increments can be guessed just by try and error right on the editor, feel free to change them to find a position for Alaska and Hawaii that suits better your map on your web application.

If you apply the change carefully to all the layers you can get something like this map.

map with insets

Check the details of SQLs and symbology on this CDBFiddle

var sync_center = true;
var a = 0;
var jsons = [
{viz: 'https://jsanzacademy1.cartodb.com/api/v2/viz/8f64fc9e-921a-11e5-9f75-0e3a376473ab/viz.json'},
{viz: 'https://jsanzacademy1.cartodb.com/api/v2/viz/f413da8c-929e-11e5-bb08-0ea31932ec1d/viz.json'},
{viz: 'https://jsanzacademy1.cartodb.com/api/v2/viz/18cdea4c-92a0-11e5-b04a-0ecfd53eb7d3/viz.json'}
];
var map;
var center = {'lat':15.222246513227375,'lng':-2.01025390625};
var center2 = [5,2];
var zoom = 5;
var zoom2 = 5;
function loadMaps(a){
if(map){center = map.getCenter(); zoom = map.getZoom()}
$('#map').empty();
cartodb.createVis('map', jsons[a].viz,{
center_lat: center.lat,
center_lon: center.lng,
zoom: zoom,
shareable:false,
search:false,
layer_selector:false,
zoomControl:true,
loaderControl: true
});
}
function main() {
// Put the thumbnails
cartodb.Image(jsons[0].viz)
.center(center2)
.zoom(zoom2)
.getUrl(function(err, url) {
var img = new Image();
$('#map1').css('background-image', 'url(' + url + ')');
})
cartodb.Image(jsons[1].viz)
.center(center2)
.zoom(zoom2)
.getUrl(function(err, url) {
var img = new Image();
$('#map2').css('background-image', 'url(' + url + ')');
})
cartodb.Image(jsons[2].viz)
.center(center2)
.zoom(zoom2)
.getUrl(function(err, url) {
var img = new Image();
$('#map3').css('background-image', 'url(' + url + ')');
})
//loadMaps events
$('#map1').click(function(){
loadMaps(0);
});
$('#map2').click(function(){
loadMaps(1);
});
$('#map3').click(function(){
loadMaps(2);
});
// Click on first button
$('#map3').click();
}
$('.Thumbnails > li').click(function() {
$('.Thumbnails > li').removeClass('selected');
$(this).addClass('selected');
});
window.onload = main;
<!DOCTYPE html>
<html>
<head>
<title>Election Mapping Bonanza</title>
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
<link rel="shortcut icon" href="http://cartodb.com/assets/favicon.ico" />
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,300,700|Open+Sans+Condensed:300,700' rel='stylesheet' type='text/css'>
<style>
ul {
margin: 0;
padding: 0;
}
li {
margin: 0;
padding: 0;
list-style-type: none;
text-shadow: 0 0 10px #eee;
}
li > p {
float: right;
}
html, body {
height: 100%;
width: 100%;
padding: 0;
margin: 0;
}
#map {
position: absolute;
left: 380px;
top: 0;
right: 0;
bottom: 0;
padding: 0;
margin: 0;
}
.top_map{
z-index: 1000;
}
.bottom_map {
display: none;
z-index: 0;
}
#map_selector {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 300px;
padding: 0;
margin: 0;
z-index: 1000;
overflow: hidden;
padding: 40px;
background: #f2f2f2;
}
#Thumbnails ul {
padding: 0; margin: 0;
list-style-type: none;
height: 100%;
width: 100%;
}
#Thumbnails li {
float: left;
font-family: "Helvetica", Arial;
font-size: 13px;
color: #444;
cursor: auto;
width: 33.33%;
height: 150px;
cursor: pointer;
}
.map {
width: 100%; height: 100%;
}
.Header h1 {
font: 700 30px 'Open Sans Condensed';
color: #333;
padding: 0 0 20px 0;
border-bottom: 1px solid #ccc;
margin: 0 0 30px 0;
}
.ramp-inner {
height: 20px;
/* Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#1e5799+0,2f4886+0,2f4886+50,2f4886+50,2989d8+51,2989d8+51,ad373e+51 */
background: #1e5799; /* Old browsers */
background: -moz-linear-gradient(left, #1e5799 0%, #2f4886 0%, #2f4886 50%, #2f4886 50%, #2989d8 51%, #2989d8 51%, #ad373e 51%); /* FF3.6-15 */
background: -webkit-linear-gradient(left, #1e5799 0%,#2f4886 0%,#2f4886 50%,#2f4886 50%,#2989d8 51%,#2989d8 51%,#ad373e 51%); /* Chrome10-25,Safari5.1-6 */
background: linear-gradient(to right, #1e5799 0%,#2f4886 0%,#2f4886 50%,#2f4886 50%,#2989d8 51%,#2989d8 51%,#ad373e 51%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#1e5799', endColorstr='#ad373e',GradientType=1 ); /* IE6-9 */
border-radius: 50px;
}
.ramp {
margin-bottom: 30px;
}
.ramp-info {
width: auto;
overflow: hidden;
margin-bottom: 12px;
}
.ramp-info li {
float: left;
width: 20%;
text-align: left;
margin: 0;
font: 300 20px/18px 'Open Sans Condensed', sans-serif;
text-transform: uppercase;
}
.cartodb-overlay {
display: none !Important;
}
.Thumbnails > li {
opacity: .5;
margin-bottom: 30px;
position: relative;
cursor: pointer;
}
.Thumbnails > li p {
font: 300 14px/15px 'Open Sans Condensed', sans-serif;
margin: 0;
position: absolute;
bottom: 10px;
left: 10px;
}
.Thumbnails > li.selected {
opacity: 1;
}
.mapx {
height: 100px;
position: relative;
overflow: hidden;
}
.selected .mapx {
box-shadow: 0 0 8px rgba(0, 0, 0, .2);
}
.mapx:after {
content:"";
position: absolute;
display: block;
pointer-events: none;
top: 0;
right: 0;
bottom: 0;
left: 0;
border: 1px solid rgba(0, 0, 0, .3);
border-radius: 2px;
}
.logo {
position: absolute;
left: 40px;
bottom: 40px;
}
</style>
<link rel="stylesheet" href="http://libs.cartocdn.com/cartodb.js/v3/3.15/themes/css/cartodb.css" />
</head>
<body>
<div id="map" class=" top_map"></div>
<div id="map_selector" class="">
<div class="Header">
<h1>Election Results</h1>
<div class="ramp">
<ul class="ramp-info">
<li>Obama</li>
<li style="float: right;">Romney</li>
</ul>
<div class="ramp-inner"></div>
</div>
<img class="logo" src="https://cartodb-libs.global.ssl.fastly.net/cartodb.com/static/logos_full_cartodb_light.png" height="30" />
</div>
<ul class="Thumbnails">
<li id='img1' class="selected">
<div id="map1" class="mapx"></div>
<p>Base map</p>
</li>
<li id='img2'>
<div id="map2" class="mapx"></div>
<p>Winner by County</p>
</li>
<li id='img3'>
<div id="map3" class="mapx"></div>
<p>Proportional Sym</p>
</li>
</ul>
</div>
<!-- include cartodb.js library -->
<script src="http://libs.cartocdn.com/cartodb.js/v3/3.15/cartodb.js"></script>
<script src="app.js"></script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment