4 My features - GiovanniKaaijk/frontend-data GitHub Wiki

My features

Rendering my world map

First I wanted to render a world map in my app. To achieve this, I went looking for examples of D3 maps, I analyzed the code of these examples until I understood it, then I started projecting my own world map.

I used to following to render my worldmap:

  • Topojson
  • geoPath
  • World-atlas
const rendermap = function (d3, topojson) {
    const worldMap = d3.geoNaturalEarth1(); //comes in different styles on d3
    const pathCreator = d3.geoPath().projection(worldMap);

    svg.append('path')
        .attr('class', 'sphere')
        .attr('d', pathCreator({type: 'Sphere'}));

    d3.json('https://unpkg.com/[email protected]/world/110m.json')
        .then(json => {
        console.log(json, json.objects.countries);
            const countries = topojson.feature(json, json.objects.countries);
            svg.selectAll('path')
            .data(countries.features)
                .enter()
                    .append('path')
                    .attr('d', pathCreator)
                    .attr('class', 'country')
        });
}(d3, topojson);

Filtering my data

Many of the results included place names instead of countries. To replace these place names with country names, I started looking for a file that contains all capitals of all countries. I found a JSON file while searching. This file had the following format:

{country: "Afghanistan", city: "Kabul"}
{country: "Albania", city: "Tirana"}
{country: "Algeria", city: "Alger"}
etc.

After fetching this file, I went to see if a result matches a country from these data. If this was the case, I replace the city name with the country name. I do this in the following way:

// Resultaten uit de query
results.forEach((result) => {
// Als het resultaat niet overeenkomt met een land uit de array met landen.
   if(!countryArray.includes(result.placeName.value)){
// Loop over alle steden om te kijken of een stad gelijk is aan het resultaat
         cities.forEach((city) => {
         if(city.city == result.placeName.value){
// Vervang het resultaat met het land van de stad
            result.placeName.value = city.country;
         }
      });
    }
});

Counting the objects per country

After this I wanted to see how many objects were found per country so that I could make a heat map of it. To keep track of how many objects come from a certain country I have written a loop that goes over all objects, with each object he checks which country the object comes from and then with this country a count is kept of how many objects there are in that country.

I managed to do this with the following code:

results.forEach(result => {
// Als het resultaat overeen komt met een land uit de array
   if(countryArray.includes(result.placeName.value)) {
         dataCount.forEach((counter) => {
// Zoek het land dat bij het resultaat hoort
         if(counter.properties.name == result.placeName.value){
// Tel 1 op bij de count van dit land
               counter.properties.count = counter.properties.count += 1;
// Als de counter van dit land hoger is dan de hoogste count van een land tot nu toe
               if(counter.properties.count > highestCount) {
// highestCount wordt vervangen door de huidige count, deze wordt later gebruikt voor het domain van de color scale in D3
                   highestCount = counter.properties.count;
               }
         }
     });
  }
})

Filling the heatmap

let scaleColor = d3.scaleSqrt()
        .domain([0, (state.highestCount)])
        .range(['#ffffff', 'red']);
g.selectAll('path')
    .data(state.dataCount) 
    .enter()
    .append('path')
        .attr('d', pathCreator)
        ...
    .transition()
        .duration(300)
        .style('fill', (d) => scaleColor(d.properties.count))

First I am creating all paths of the countries, later on at .style I run a function where the data of the element has been passed into the parameters. In the function I return the color created by the scaleColor function.

Then my map looked as desired, I now wanted to add zooming and highlighting to the map.

Zooming and highlighting

I used D3 zoom to be able to zoom on my map. D3 zoom is a function that ensures that you can zoom in on your SVG element. I managed to do this with the following code:

svg.call(zoom.on('zoom', () => {
    g.attr('transform', d3.event.transform);
}))

After this I wanted to make sure that the user could highlight countries. I have added that a user can hover over a country, this makes the border of the country thicker and therefore clearer. Furthermore, a user can click on a country and then the name of the country appears, so that the user can see which countries he is viewing.

Hover:

.style('stroke-opacity', 0.2)
.on('mouseover', function() {
    d3.select(this)
      .style('stroke-opacity', 1)
})
.on('mouseout', function() {
    d3.select(this)
      .style('stroke-opacity', 0.2)
}

Tooltip:

// zorgt ervoor dat de tooltip verdwijnt wanneer je niet meer over hetzelfde land hovert
.on('mouseout', function() {
   tooltip.style("visibility", "hidden")
})
// laat de tooltip verschijnen met de land titel samen met het aantal objecten gevonden.
.on("click", (d) => { tooltip.style("visibility", "visible").text(d.properties.name + ' = ' + d.properties.count)})
// dit houdt de positie van de muis bij.
.on("mousemove", () => { tooltip.style("top", (event.pageY-40)+"px").style("left",(event.pageX-35)+"px")})

Timeline

After my static visualization was done, I wanted to make an interactive timeline. I made it so that if you click on a certain year, those year values were put into the query and updates the data when the query has run. I used the following codes to create this timeline:

// First I search all elements in the timeline, then I calculate the slider width
const timeLine = () => {
    let nodes = document.querySelectorAll('.timeline p')
    let currentWidth = document.querySelector('.timeline').offsetWidth / nodes.length
    state.nodeWidth = currentWidth
    currentSlider.style.width = state.nodeWidth+'px'
    nodes.forEach(element => {
// pushToArray keeps track of all the timeline elements
        pushToArray(element)
// Bind changeQuery to every element
        element.addEventListener('click', changeQuery)
    })
}

Now, when a user clicks on an element in the timeline, changeQuery will be fired. changeQuery:

// changeQuery changes the position of the slider in the timeline, then the textContent is taken to create an object containing the year values
let index = state.uniqueNodes.indexOf(this.textContent)
state.currentTime = index
currentSlider.style.left = state.nodeWidth * index + 'px';
let content = state.uniqueNodes[index]
content = content.split("-");
state.timeFilter = {
   firstValue: content[0],
   secondValue: content[1]
}
updateData()

async function updateData() {
// Fetch the new data with the new year values
   let data = await prepareData(state.timeFilter.firstValue, state.timeFilter.secondValue)
// Count the results for the heatmap, returns an object containing the counts + the highest count
   let tracker = countTracker(data, state.dataCount, state.countryArray)
   state.dataCount = tracker.dataCount;
   state.highestCount = tracker.highestCount;
// Reset the scale because the highestCount has changed
   let scaleColor = d3.scaleSqrt()
        .domain([0, (state.highestCount)])
        .range(['#ffffff', 'red']);
// Select all elements to update the data 
   svg.selectAll('g')
        .data(state.dataCount)
        .enter()
        .selectAll('.country')
        .transition()
        .duration(300)
        .style('fill', (d) => scaleColor(d.properties.count))
}

Colonial history

To display the colonial history of a country, I created a function to be called when clicked on an object:

g.selectAll('path')
    .data(state.dataCount)
    .enter()
    .append('path')
        .attr('d', pathCreator)
         ...
        .on("click", (d) => { 
              historicMoments(d, state.timeFilter.firstValue, state.timeFilter.secondValue);
         })

When the function has been fired, a CSV containing all data will be read by D3.

export async function historicMoments(object, firsttimevalue, secondtimevalue){
  await d3.csv('koloniale-data.csv').then((data) => {
    renderData(data, object, firsttimevalue, secondtimevalue)
  });
}

function renderData(data, object, firsttime, secondtime) {
  data.forEach(event => {
// If the country name is equal to the name of the current row in the CSV, the CSV row is pushed to an array
    event.CurrentNameOfTerritory === country ? eventArray.push(event) : null
  })
  eventArray.forEach(event => {
    event.y = y
// Change date format from xxxx -xxxx to xxxx - xxxx
    event.PeriodOfTime = fixDate(event.PeriodOfTime)
    y += 20
  })
// Render the events in a column
  displayData(eventArray)
}