Home > Mobile >  How to use attr fill with scalesqrt using data from another json file?
How to use attr fill with scalesqrt using data from another json file?

Time:12-09

I'm new to d3 and I'm trying to color each US state based on the total energy consumption value. When I use :

d3.selectAll("path")
  .attr("fill", "blue");

I can successfully change the color to blue, so I think I have the right foundation. But when I try to apply the colorgradient function that I have defined myself, it doesn't work anymore. Most of the examples I have seen online use the same json file to create the map and to assign color values. But I have two json files I'm working with--one that contains the US state path shape and the other with meta data for each state. This is what I have:

const data = await d3.json("states.json");
const energy_data = await d3.csv("Energy Census and Economic Data US 2010-2014.csv");

console.log(energy_data);

const states = topojson.feature(data, data.objects.usStates).features

// draw the states
svg.selectAll(".state")
    .data(states)
    .enter().append("path")
    .attr("class", "state")
    .attr("d", path);

const min = d3.min(energy_data, d => parseInt(d.TotalC2010));
const max = d3.max(energy_data, d => parseInt(d.TotalC2010));

const colorgradient = d3.scaleSqrt()
    .domain([min, max])
    .range(["green", "blue"]);

d3.selectAll("path")
    .data(energy_data)
    .enter()
    .attr("fill", d => colorgradient(d.TotalC2010));

Any advice?

Thanks in advance!

EDIT: I finally got it to work, thanks to Andrew Reid.

const state_data = await d3.json("states.json");
const energy_data_raw = await d3.csv("Energy Census and Economic Data US 2010-2014.csv");

const energy_data = new Map(energy_data_raw.map(d => [d.StateCodes, d]))

const states = topojson.feature(state_data, state_data.objects.usStates).features

const min = d3.min(energy_data_raw, d => parseInt(d.TotalC2010));
const max = d3.max(energy_data_raw, d => parseInt(d.TotalC2010));

let colorgradient = d3.scaleSqrt()
    .domain([min, max])
    .range(["green", "blue"]);

svg.selectAll(".state")
    .data(states)
    .enter()
    .append("path")
    .attr("class", "state")
    .attr("d", path)
    .attr("fill", d => colorgradient(energy_data.get(d.properties.STATE_ABBR).TotalC2010))

CodePudding user response:

Problem

This code here:

d3.selectAll("path")
   .data(energy_data)
   .enter()
   .attr("fill", d => colorgradient(d.TotalC2010));

Shouldn't do anything at all.

First, assuming your geographic data and your energy data have the same number of items, you are selecting the existing paths (d3.selectAll("path")).

Then you are assigning those existing paths new data (.data(energy_data)) matching each existing path - in order of their index.

Next you create what is likely an empty enter selection. The enter selection creates an element for every item in the data array that does not have a corresponding element (matched by index here): if you have more items in your data array than elements, you'll enter new elements. Otherwise, you'll have an empty selection because you do no need to enter any new elements to represent the data in energy_data.

Lastly you style an empty enter selection (normally you'd use .append() to specify what type of element you'd want to append, otherwise, the enter selection is just a placeholder. As this is an empty selection anyways, nothing is done.

Possible Solution

I'm going through this solution as it looks like what you are trying to do, though it is not what I would recommend.

It appears as though you are trying to assign new data to an existing selection - something that is absolutely possible. In this approach you'd use the geographic data to draw the features, then assign a new dataset to each feature and modify the features based on this new data.

Data arrays ordered the same

If our data is in the same order in both geojson and csv, then we can simply use:

selection.data(states)
  .enter()
  .append("path")
  .attr("d", path)
  .data(energy_data)
  .attr("fill", ...)

Because .data() by default binds items in the data array to elements in the selection by matching their indices, each feature is updated with the correct data in energy_data only when the two data arrays are ordered the same. This is an obvious limitation, but one that can be overcome.

Data arrays ordered differently

If the arrays are not ordered the same, we need to have a way to match existing features with the new data set. By default the .data() method assigns data to existing elements by index. But we can use the second parameter of .data() to assign a unique identifier using a key function.

For this case I'm assuming our identifier for both states and energy_data resides at d.properties.id.

When we enter our paths, we don't need the key function, there is no data to join to existing elements.

When we update our paths with the energy_data data, we want to use a key function to ensure we update each element with the correct new data. The key function is evaluated on each existing element's datum first, and then on each item in the new data array. Where a match in key is found, the matching new datum will replace the old.eg:

 svg.selectAll("path") 
    .data(energy_data, function(d) { return d.properties.id; })
    .attr("fill",...

Here's a quick example with contrived data:

let data = [
  { value: 4, properties: {id: "A" }},
  { value: 6, properties: {id: "B" }},
  { value: 2, properties: {id: "C" }}

]

let color = d3.scaleLinear()
  .domain([1,6]).range(["red","yellow"]);

let geojson = {
  "type":"FeatureCollection",
  "features": [
       { 
         "type": "Feature",
         "properties": { id: "C"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 0, 0 ],
                [ 100, 0 ],
                [ 100,100 ],
                [ 0, 100 ],
                [ 0, 0 ]
              ]
            ]
          }
       },
       { 
         "type": "Feature",
         "properties": { id: "B"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 100, 0 ],
                [ 200, 0 ],
                [ 200, 100 ],
                [ 100, 100 ],
                [ 100, 0 ]
              ]
            ]
          }
        },
        { 
         "type": "Feature",
         "properties": { id: "A"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 200, 0 ],
                [ 300, 0 ],
                [ 300,100 ],
                [ 200, 100 ],
                [ 200, 0 ]
              ]
            ]
          } 
        }
      ]
    }
    
let svg = d3.select("body")
  .append("svg")
  .attr("width", 300);
  
svg.selectAll("path")
  .data(geojson.features)
  .enter()
  .append("path")
  .attr("d", d3.geoPath(null));
 
svg.selectAll("path")   // Note: You can just chain .data() to .attr() omitting this line.
  .data(data, d=>d.properties.id)
  .attr("fill", d=>color(d.value));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.1.0/d3.min.js"></script>

Data arrays ordered differently with different key accessors

However, if the location or name of the identifier is different than before, we need to change the key function.

For this case, I'm assuming that the states identifier is located at d.properties.id. For energy_data, I'm assuming that the identifier resides at d.id, a more common parsed DSV location.

As noted, the key function is evaluated for existing element's data and then new data. this means we need a key function that works for both datasets, which means we need a slightly more complicated key function to compare items from both datasets, for example:

 .data(energy_data, function(d) {
     if(d.properties) 
        return d.properties.id; // get the key from items in `states` 
     else  
        return d.id;            // get the key from items in `energy_data`
 })
 .attr("fill",...

The key function now will be able to have the new datum replace the old ensuring that the correct feature has the correct data.

Assuming all your identifiers match properly (and are strings) you'll have assigned new data to the existing features.

The downside of this approach is you've lost the original data - if you want to do semantic zooming, check different properties of the geographic data, or revisit the data in the geojson, you need to rebind the original data. Selecting the paths takes time as well, and it assumes there are no other paths that might be mistakenly selected.

Here's a quick example:

let csv = d3.csvParse(d3.select("pre").remove().text());

let color = d3.scaleLinear()
  .domain([1,6]).range(["red","yellow"]);

let geojson = {
  "type":"FeatureCollection",
  "features": [
       { 
         "type": "Feature",
         "properties": { id: "C"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 0, 0 ],
                [ 100, 0 ],
                [ 100,100 ],
                [ 0, 100 ],
                [ 0, 0 ]
              ]
            ]
          }
       },
       { 
         "type": "Feature",
         "properties": { id: "B"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 100, 0 ],
                [ 200, 0 ],
                [ 200, 100 ],
                [ 100, 100 ],
                [ 100, 0 ]
              ]
            ]
          }
        },
        { 
         "type": "Feature",
         "properties": { id: "A"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 200, 0 ],
                [ 300, 0 ],
                [ 300,100 ],
                [ 200, 100 ],
                [ 200, 0 ]
              ]
            ]
          } 
        }
      ]
    }
    
let svg = d3.select("body")
  .append("svg")
  .attr("width", 300);
  
svg.selectAll("path")
  .data(geojson.features)
  .enter()
  .append("path")
  .attr("d", d3.geoPath(null));
  
svg.selectAll("path") // Note: You can just chain .data() to the .attr() omitting this line.
  .data(csv, d=>d.properties?d.properties.id:d.id)
  .attr("fill", d=>color(d.value));
  
  
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.1.0/d3.min.js"></script>
<pre>id,value
A,4
B,6
C,2</pre>

Recommended Approach

To join the spatial data and non spatial data, I suggest using a javascript map. This allows you do look up values in your non spatial data using the shared identifier:

let map = new Map(energy_data.map(function(d) { return [d.id, d] }))

We can look up any item in energy_data now with map.get("someIdentifier")

Which we can use as follows:

.attr("fill", d=> colorgradient(map.get(d.properties.id).TotalC2010))

This way our spatial features retain their spatial data, but we can easily access the nonspatial data using the common identifier and the javascript map.

Here's a quick example using the same contrived geojson and DSV data as above:

let csv = d3.csvParse(d3.select("pre").remove().text());
let map = new Map(csv.map(function(d) { return [d.id, d] }))

let color = d3.scaleLinear()
  .domain([1,6]).range(["red","yellow"]);

let geojson = {
  "type":"FeatureCollection",
  "features": [
       { 
         "type": "Feature",
         "properties": { id: "C"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 0, 0 ],
                [ 100, 0 ],
                [ 100,100 ],
                [ 0, 100 ],
                [ 0, 0 ]
              ]
            ]
          }
       },
       { 
         "type": "Feature",
         "properties": { id: "B"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 100, 0 ],
                [ 200, 0 ],
                [ 200, 100 ],
                [ 100, 100 ],
                [ 100, 0 ]
              ]
            ]
          }
        },
        { 
         "type": "Feature",
         "properties": { id: "A"},
         "geometry": {
            "type": "Polygon",
            "coordinates": [
              [
                [ 200, 0 ],
                [ 300, 0 ],
                [ 300,100 ],
                [ 200, 100 ],
                [ 200, 0 ]
              ]
            ]
          } 
        }
      ]
    }
    
let svg = d3.select("body")
  .append("svg")
  .attr("width", 300);
  
svg.selectAll("path")
  .data(geojson.features)
  .enter()
  .append("path")
  .attr("d", d3.geoPath(null))
  .attr("fill", d=> color(map.get(d.properties.id).value));
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.1.0/d3.min.js"></script>
<pre>id,value
A,4
B,6
C,2</pre>

Other Approaches

A third option would be to combine the data arrays - iterating through the geojson and adding values contained in energy_data to each feature manually so that you have only one data array containing everything you need to draw and style the visualization.

  • Related