Yahoo Weather forecast visualisation in D3.js

Yahoo Weather App
Data visualisation from: Yahoo Weather app

First of all, I’m D3 noob. The is the first time ever I’m creating something with this tool, so bear with me if you notice any errors or better ways of doing it.

On my last apply for a job, I was asked to make a data visualisation from some raw data extracted from their database which represents some basic summary and usage details of their software. I saw it as an opportunity to give a try to D3.js after seeing so many beautiful data visualisations. I completed the task I was given and did it pretty well. But after that I decided to create something more interesting of my choice and dig a little deeper inside D3.js and SVG. So I got an idea to replicate a data visualisation of weather forecast from my favourite weather app: Yahoo Weather . This app is built so well and rappresents data in such an elegant way that no other app can come even close in my opinion.

Understanding how to prepare data and which shapes to use for presenting this visualisation and create it almost identical like the one in the app was an interesting exercise. Which also gave me an idea of how powerful this tool it is. I’m not going in details here, let the code speak for its self.

Here is the CodePen preview of the final code:

See the Pen Yahoo Weather forecast visualization in D3.js by Bojan Vidanovic (@bojan_vidanovic) on CodePen.

Source code:

// SVG dimensions
var margin = {
  top: 15,
  right: 15,
  bottom: 20,
  left: 15
};
var width = 1200 - margin.left - margin.right;
var height = 140 - margin.top - margin.bottom;

// Parse the time
var parseDate = d3.timeParse('%H %M %d %m %Y');
var timeFormat = d3.timeFormat('%H:%M');

var svg = d3.select("#chart")
  .attr("width", width + margin.left + margin.right)
  .attr("height", height + margin.top + margin.bottom)
  .append("g")
  .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

// Get the data
d3.json("https://gist.githubusercontent.com/b0jan/4bfcdf766867bd65c6b07882c01abd9f/raw/8ce8482f9e2335d15c5b1a3bdd7573c3cc105318/forecast.json", function(error, data) {
  if (error) throw error;

  // Hourly forecast data
  var data = data.hourly_forecast;

  // Format the data
  for (var i = 0, temp_counter = 1, icon_counter = 1, len = data.length; i < len; i++) {

    // Parse time
    var timeBase = data[i].FCTTIME;
    var hour = timeBase.hour_padded;
    var minutes = timeBase.min;
    var day = timeBase.mday_padded;
    var month = timeBase.mon_padded;
    var year = timeBase.year;
    var date = [hour, minutes, day, month, year].join(' ');
    data[i].time = parseDate(date);

    // POP value to Int
    data[i].pop = +data[i].pop;

    // Group temperature
    if (i < len - 1) {
      if (data[i].temp.metric === data[i+1].temp.metric){
        temp_counter++;
      } else {
        var moveHere = i - Math.round(--temp_counter / 2);
        data[moveHere].temp_read = data[i].temp.metric;
        temp_counter = 1;
      }
    } else {
      if (data[i].temp.metric === data[i-1].temp.metric){
        var moveHere = i - Math.round(--temp_counter / 2);
        data[moveHere].temp_read = data[i].temp.metric;
        temp_counter = 1;

      } else {
        data[i].temp_read = data[i].temp.metric;
      }
    }

    // Group weather icons
    if (i < len - 1) {
      if (data[i].icon_url === data[i+1].icon_url){
        if(data[i+2]){
          icon_counter++;
        }
      } else {
        if(icon_counter === 1){
          data[i].graph_icon = data[i].icon_url;
          data[i+1].icon_limit = true;
        } else {
          var moveHere = i - Math.round(icon_counter / 2);
          data[moveHere].graph_icon = data[i].icon_url;
          data[i].icon_limit = true;
          icon_counter = 1;
        }
      }
    } else {
      if (data[i].icon_url === data[i-1].icon_url){
        var moveHere = i - Math.round(icon_counter / 2) - 1;
        data[moveHere].graph_icon = data[i].icon_url;
      } else {
        data[i].graph_icon = data[i].icon_url;
        icon_counter = 1
      }
    }
  };

  // Set scales
  var xScale = d3.scaleTime()
    .domain(d3.extent(data, (d) => d.time))
    .range([0, width]);

  var yScale = d3.scaleLinear()
    .domain([0, d3.max(data, (d) => d.temp.metric)])
    .range([60, height - 30]);

  // Bottom scale
  svg.append("g")
    .attr("transform", "translate(0," + height + ")")
    .call(d3.axisBottom(xScale).tickFormat(timeFormat));

  // Draw temperature line
  svg.append("path")
    .datum(data)
    .style('stroke', '#ffa500')
    .style('stroke-width', '3')
    .style('fill', 'none')
    .attr("d", d3.line()
      .curve(d3.curveCardinal)
      .x((d) => xScale(d.time))
      .y((d) => height - yScale(d.temp.metric))
    );

  // Fill temperature area
  svg.append("path")
    .datum(data)
    .attr("fill", "#fffbc1")
    .attr("stroke-width", "0")
    .attr("opacity", ".5")
    .attr("d", d3.area()
      .curve(d3.curveCardinal)
      .x((d) => xScale(d.time))
      .y0(height)
      .y1((d) => height - yScale(d.temp.metric))
     );

  // Temperature change point and value
  var tempPoint = svg.selectAll(".temp-point")
      .data(data)
      .enter()
        .filter((d) => {
          if(d.time) return d.temp_read
        });

  tempPoint.append("circle")
      .attr("fill", "#ffa500")
      .attr("stroke", "#ffa500")
      .attr("stroke-width", "2")
      .attr("cx", (d) => {return xScale(d.time)})
      .attr("cy", (d) => {return height - yScale(d.temp_read)})
      .attr("r", 3);

  tempPoint.append("text")
      .attr('x', (d) => xScale(d.time))
      .attr('y', (d) => height - yScale(d.temp_read))
      .attr('dy', "-10px")
      .style("text-anchor", "middle")
      .text((d) => d.temp_read + "°");


  // Define POP Y scale
  var popY = d3.scaleLinear()
      .domain([0, d3.max(data, (d) => d.pop)])
      .range([0, 20]);

  // Draw POP line
  svg.append("path")
      .datum(data)
      .attr("stroke", "#000")
      .attr("stroke-width", ".3")
      .attr("fill", "none")
      .attr("d", d3.line()
        .curve(d3.curveStep)
        .x((d) => xScale(d.time))
        .y((d) => height - popY(d.pop))
      );

    // Paint POP Area
    svg.append("path")
        .datum(data)
        .attr("class", "pop-area")
        .attr("fill", "#3590df")
        .attr("stroke-width", "0")
        .attr("d", d3.area()
          .curve(d3.curveStep)
          .x((d) => xScale(d.time))
          .y0(height)
          .y1((d) => height - popY(d.pop))
        );

 // POP value
  var pop = svg.selectAll('.pop-value')
    .data(data)
    .enter()
      .append("text")
      .attr('class', 'pop-value')
      .attr('font-size', '8px')
      .attr('x', (d) => xScale(d.time))
      .attr('y', (d) => height - popY(d.pop))
      .attr('dy', "-5px")
      .style("text-anchor", "middle")
      .text((d) => {
        if(d.pop != 0) return d.pop + "%"
      });

  // Forecast icon
  svg.selectAll("image")
    .data(data)
    .enter()
    .filter((d) => {
      if(d.graph_icon) return d.time;
    })
    .append('image')
    .attr('xlink:href', (d) => d.graph_icon)
    .attr('height', '19')
    .attr('width', '20')
    .attr("x", (d) => xScale(d.time) + 7)
    .attr('y', 55);

  // Separate forecast icons
  svg.selectAll(".divider-line")
    .data(data)
    .enter()
      .filter((d) => { if(d.icon_limit) return d.time; })
      .append("line")
      .style("stroke-dasharray","3,3")
      .style("stroke", "grey")
      .attr('class', 'divider-line')
      .attr("x1", (d) => xScale(d.time))
      .attr("y1", height)
      .attr("x2", (d) => xScale(d.time))
      .attr("y2", (d) => height - yScale(d.temp.metric) + 2);
})

Most recent entries

Comments