09 October, 2016
by nitaku

Weather Wheel III

_(This example contains server-side code. You need to run it from our WebVis lab rather than bl.ocks.org in order to use it.)_

This example lets you compare one entire year of weather data with another one using two weather wheels. You can choose which year to display, and which weather station (by choosing a country and then a city).

You can zoom in either views by using your mouse wheel or touch gestures.

Each day of the year is assigned a circular slice of the diagram, proceeding clockwise. For each day, several measures are depicted:

The design has been heavily determined by reverse engineering this beautiful work by Raureif. Data is requested live from the Weather Underground Historical APIs.

window.AppView = class AppView extends View
  constructor: (conf) ->
    super(conf)
    
    q1 = new Query
      airports_db: conf.airports_db
    q2 = new Query
      airports_db: conf.airports_db
    
    new WeatherPanel
      query: q1
      parent: this
      
    new WeatherPanel
      query: q2
      parent: this
      
    q1.set
      year: 2015
      country: 'Italy'
      icao: 'LIRP' # Pisa
    
    q2.set
      year: 2015
      country: 'Japan'
      icao: 'RJTT' # Tokyo
    
// Generated by CoffeeScript 1.10.0
(function() {
  var AppView,
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  window.AppView = AppView = (function(superClass) {
    extend(AppView, superClass);

    function AppView(conf) {
      var q1, q2;
      AppView.__super__.constructor.call(this, conf);
      q1 = new Query({
        airports_db: conf.airports_db
      });
      q2 = new Query({
        airports_db: conf.airports_db
      });
      new WeatherPanel({
        query: q1,
        parent: this
      });
      new WeatherPanel({
        query: q2,
        parent: this
      });
      q1.set({
        year: 2015,
        country: 'Italy',
        icao: 'LIRP'
      });
      q2.set({
        year: 2015,
        country: 'Japan',
        icao: 'RJTT'
      });
    }

    return AppView;

  })(View);

}).call(this);
window.Query = observable class Query
  constructor: (conf) ->
    @init
      events: ['change', 'change_country']
      
    @airports_db = conf.airports_db
    
    
  get_years: () -> d3.range(2000,new Date().getFullYear()+1)
  
  get_year: () -> @selected_year
  
  set_year: (year) ->
    @selected_year = year
    
    @trigger 'change'
  
  get_countries: () -> @airports_db.countries.map (d) -> d.key
  
  set_country: (name) ->
    @selected_country = name
    
    @trigger 'change_country'
    
    # also select the first airport
    @set_airport @airports_db.by_country[@selected_country][0].icao
    
  set_airport: (icao) ->
    @selected_airport = @airports_db.index[icao]
    
    @trigger 'change'
    
  get_all: () -> @airports_db.by_country[@selected_country]
  
  get_airport: () -> @selected_airport
  
  get_country: () -> @selected_country
  
  set: (conf) ->
    @selected_year = conf.year
    @selected_country = conf.country
    @selected_airport = @airports_db.index[conf.icao]
    
    @trigger 'change_country'
    @trigger 'change'
// Generated by CoffeeScript 1.10.0
(function() {
  var Query;

  window.Query = observable(Query = (function() {
    function Query(conf) {
      this.init({
        events: ['change', 'change_country']
      });
      this.airports_db = conf.airports_db;
    }

    Query.prototype.get_years = function() {
      return d3.range(2000, new Date().getFullYear() + 1);
    };

    Query.prototype.get_year = function() {
      return this.selected_year;
    };

    Query.prototype.set_year = function(year) {
      this.selected_year = year;
      return this.trigger('change');
    };

    Query.prototype.get_countries = function() {
      return this.airports_db.countries.map(function(d) {
        return d.key;
      });
    };

    Query.prototype.set_country = function(name) {
      this.selected_country = name;
      this.trigger('change_country');
      return this.set_airport(this.airports_db.by_country[this.selected_country][0].icao);
    };

    Query.prototype.set_airport = function(icao) {
      this.selected_airport = this.airports_db.index[icao];
      return this.trigger('change');
    };

    Query.prototype.get_all = function() {
      return this.airports_db.by_country[this.selected_country];
    };

    Query.prototype.get_airport = function() {
      return this.selected_airport;
    };

    Query.prototype.get_country = function() {
      return this.selected_country;
    };

    Query.prototype.set = function(conf) {
      this.selected_year = conf.year;
      this.selected_country = conf.country;
      this.selected_airport = this.airports_db.index[conf.icao];
      this.trigger('change_country');
      return this.trigger('change');
    };

    return Query;

  })());

}).call(this);
window.QueryView = observer class QueryView extends View
  constructor: (conf) ->
    super(conf)
    @init()
    
    @query = conf.query
    
    # Year select
    @select_year_el = @d3el.append 'select'
    year_options = @select_year_el.selectAll 'option'
      .data @query.get_years().sort(), (d) -> d
    
    year_options.enter().append 'option'
      .text (d) -> d
      .attrs
        value: (d) -> d
        
    @select_year_el.on 'change', () =>
      @query.set_year @select_year_el.node().options[@select_year_el.node().selectedIndex].value
    
    # Country select
    @select_country_el = @d3el.append 'select'
    country_options = @select_country_el.selectAll 'option'
      .data @query.get_countries().sort(), (d) -> d
    
    country_options.enter().append 'option'
      .text (d) -> d
      .attrs
        value: (d) -> d
        
    @select_country_el.on 'change', () =>
      @query.set_country @select_country_el.node().options[@select_country_el.node().selectedIndex].value
    
    # Airport select
    @select_airport_el = @d3el.append 'select'
    
    @select_airport_el.on 'change', () =>
      @query.set_airport @select_airport_el.node().options[@select_airport_el.node().selectedIndex].value
    
    # Listeners
    @listen_to @query, 'change_country', () =>
      @select_country_option @query.get_country()
      @redraw()
    
    @listen_to @query, 'change', () =>
      @select_year_option @query.get_year()
      @select_airport_option @query.get_airport().icao
    
    
  select_year_option: (year) ->
    @select_year_el.select "option[value='#{year}']"
      .property 'selected', true
      
  select_country_option: (name) ->
    @select_country_el.select "option[value='#{name}']"
      .property 'selected', true
      
  select_airport_option: (icao) ->
    @select_airport_el.select "option[value='#{icao}']"
      .property 'selected', true
      
  redraw: () ->
    airport_options = @select_airport_el.selectAll 'option'
      .data @query.get_all().sort( (a,b) -> d3.ascending(a.city, b.city) ), (d) -> d.icao
      
    airport_options.enter().append 'option'
      .text (d) -> "#{d.city} (#{d.icao})"
      .attrs
        value: (d) -> d.icao
        
    airport_options.exit()
      .remove()
      
// Generated by CoffeeScript 1.10.0
(function() {
  var QueryView,
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  window.QueryView = observer(QueryView = (function(superClass) {
    extend(QueryView, superClass);

    function QueryView(conf) {
      var country_options, year_options;
      QueryView.__super__.constructor.call(this, conf);
      this.init();
      this.query = conf.query;
      this.select_year_el = this.d3el.append('select');
      year_options = this.select_year_el.selectAll('option').data(this.query.get_years().sort(), function(d) {
        return d;
      });
      year_options.enter().append('option').text(function(d) {
        return d;
      }).attrs({
        value: function(d) {
          return d;
        }
      });
      this.select_year_el.on('change', (function(_this) {
        return function() {
          return _this.query.set_year(_this.select_year_el.node().options[_this.select_year_el.node().selectedIndex].value);
        };
      })(this));
      this.select_country_el = this.d3el.append('select');
      country_options = this.select_country_el.selectAll('option').data(this.query.get_countries().sort(), function(d) {
        return d;
      });
      country_options.enter().append('option').text(function(d) {
        return d;
      }).attrs({
        value: function(d) {
          return d;
        }
      });
      this.select_country_el.on('change', (function(_this) {
        return function() {
          return _this.query.set_country(_this.select_country_el.node().options[_this.select_country_el.node().selectedIndex].value);
        };
      })(this));
      this.select_airport_el = this.d3el.append('select');
      this.select_airport_el.on('change', (function(_this) {
        return function() {
          return _this.query.set_airport(_this.select_airport_el.node().options[_this.select_airport_el.node().selectedIndex].value);
        };
      })(this));
      this.listen_to(this.query, 'change_country', (function(_this) {
        return function() {
          _this.select_country_option(_this.query.get_country());
          return _this.redraw();
        };
      })(this));
      this.listen_to(this.query, 'change', (function(_this) {
        return function() {
          _this.select_year_option(_this.query.get_year());
          return _this.select_airport_option(_this.query.get_airport().icao);
        };
      })(this));
    }

    QueryView.prototype.select_year_option = function(year) {
      return this.select_year_el.select("option[value='" + year + "']").property('selected', true);
    };

    QueryView.prototype.select_country_option = function(name) {
      return this.select_country_el.select("option[value='" + name + "']").property('selected', true);
    };

    QueryView.prototype.select_airport_option = function(icao) {
      return this.select_airport_el.select("option[value='" + icao + "']").property('selected', true);
    };

    QueryView.prototype.redraw = function() {
      var airport_options;
      airport_options = this.select_airport_el.selectAll('option').data(this.query.get_all().sort(function(a, b) {
        return d3.ascending(a.city, b.city);
      }), function(d) {
        return d.icao;
      });
      airport_options.enter().append('option').text(function(d) {
        return d.city + " (" + d.icao + ")";
      }).attrs({
        value: function(d) {
          return d.icao;
        }
      });
      return airport_options.exit().remove();
    };

    return QueryView;

  })(View));

}).call(this);
window.Weather = observable class Weather
  constructor: (conf) ->
    @init
      events: ['change']
      
  query: (icao, year) ->
    @icao = icao
    @year = year
    
    d3.csv "wu_get_history.php?station=#{icao}&year=#{year}", (days) =>
      @days = days
      
      @days.forEach (d) =>
        d.t = d[@days.columns[0]]
        d.date = new Date(d.t)
        d.day = d.date.getDOY()
        d.MinTemperatureC = +d.MinTemperatureC
        d.MaxTemperatureC = +d.MaxTemperatureC
        d.MeanTemperatureC = +d.MeanTemperatureC
        d.Precipitationmm = +d.Precipitationmm or 0
        d.CloudCover = Math.max 0, +d.CloudCover
        d['MeanWindSpeedKm/h'] = +d['MeanWindSpeedKm/h']
        d.WindDirDegrees = +d.WindDirDegrees
        
      @trigger 'change'

`
// code by Joe Orost
// http://stackoverflow.com/questions/8619879/javascript-calculate-the-day-of-the-year-1-366

Date.prototype.isLeapYear = function() {
    var year = this.getFullYear();
    if((year & 3) != 0) return false;
    return ((year % 100) != 0 || (year % 400) == 0);
};

// Get Day of Year
Date.prototype.getDOY = function() {
    var dayCount = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
    var mn = this.getMonth();
    var dn = this.getDate();
    var dayOfYear = dayCount[mn] + dn;
    if(mn > 1 && this.isLeapYear()) dayOfYear++;
    return dayOfYear;
};
`
// Generated by CoffeeScript 1.10.0
(function() {
  var Weather;

  window.Weather = observable(Weather = (function() {
    function Weather(conf) {
      this.init({
        events: ['change']
      });
    }

    Weather.prototype.query = function(icao, year) {
      this.icao = icao;
      this.year = year;
      return d3.csv("wu_get_history.php?station=" + icao + "&year=" + year, (function(_this) {
        return function(days) {
          _this.days = days;
          _this.days.forEach(function(d) {
            d.t = d[_this.days.columns[0]];
            d.date = new Date(d.t);
            d.day = d.date.getDOY();
            d.MinTemperatureC = +d.MinTemperatureC;
            d.MaxTemperatureC = +d.MaxTemperatureC;
            d.MeanTemperatureC = +d.MeanTemperatureC;
            d.Precipitationmm = +d.Precipitationmm || 0;
            d.CloudCover = Math.max(0, +d.CloudCover);
            d['MeanWindSpeedKm/h'] = +d['MeanWindSpeedKm/h'];
            return d.WindDirDegrees = +d.WindDirDegrees;
          });
          return _this.trigger('change');
        };
      })(this));
    };

    return Weather;

  })());

  
// code by Joe Orost
// http://stackoverflow.com/questions/8619879/javascript-calculate-the-day-of-the-year-1-366

Date.prototype.isLeapYear = function() {
    var year = this.getFullYear();
    if((year & 3) != 0) return false;
    return ((year % 100) != 0 || (year % 400) == 0);
};

// Get Day of Year
Date.prototype.getDOY = function() {
    var dayCount = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
    var mn = this.getMonth();
    var dn = this.getDate();
    var dayOfYear = dayCount[mn] + dn;
    if(mn > 1 && this.isLeapYear()) dayOfYear++;
    return dayOfYear;
};
;

}).call(this);
window.WeatherPanel = observer class WeatherPanel extends View
  constructor: (conf) ->
    super(conf)
    @init()
    
    @query = conf.query
    @weather = new Weather
    
    @listen_to @query, 'change', () =>
      @weather.query @query.get_airport().icao, @query.get_year()
    
    new QueryView
      query: @query
      parent: this
    
    new WeatherWheel
      weather: @weather
      parent: this
// Generated by CoffeeScript 1.10.0
(function() {
  var WeatherPanel,
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  window.WeatherPanel = observer(WeatherPanel = (function(superClass) {
    extend(WeatherPanel, superClass);

    function WeatherPanel(conf) {
      WeatherPanel.__super__.constructor.call(this, conf);
      this.init();
      this.query = conf.query;
      this.weather = new Weather;
      this.listen_to(this.query, 'change', (function(_this) {
        return function() {
          return _this.weather.query(_this.query.get_airport().icao, _this.query.get_year());
        };
      })(this));
      new QueryView({
        query: this.query,
        parent: this
      });
      new WeatherWheel({
        weather: this.weather,
        parent: this
      });
    }

    return WeatherPanel;

  })(View));

}).call(this);
window.WeatherWheel = observer class WeatherWheel extends View
  constructor: (conf) ->
    super(conf)
    @init()
    
    @weather = conf.weather
    
    @listen_to @weather, 'change', () => @redraw()
    
    scale = 230
    @svg = @d3el.append 'svg'
      .attrs
        viewBox: "#{-scale/2} #{-scale/2} #{scale} #{scale}"
        
    @zoomable_layer = @svg.append 'g'

    zoom = d3.zoom()
      .scaleExtent([-Infinity,Infinity])
      .on 'zoom', () =>
        @zoomable_layer
          .attrs
            transform: d3.event.transform

    @svg.call zoom
    
    
    # Fixed scales
    
    @temp2radius = d3.scaleLinear()
      .domain [-40,40]
      .range [10, 70]
      
    @temp2color = d3.scaleLinear()
      .domain([-20,0,20,40])
      .range([d3.hcl(290,70,15),d3.hcl(230,70,45),d3.hcl(80,70,75),d3.hcl(10,70,45)])
      .interpolate(d3.interpolateHcl)
      
    @prec2radius = d3.scaleLinear()
      .domain [0,40]
      .range [80, 70]
      
    @cloud2radius = d3.scaleLinear()
      .domain [0,8]
      .range [80, 86]
      
    @wind2dradius = d3.scaleLinear()
      .domain [0,100]
      .range [0,60]
      
    
    # References
    
    @zoomable_layer.append 'circle'
      .attrs
        class: 'ref_line temp_line'
        r: @temp2radius(-20)
        
    @zoomable_layer.append 'circle'
      .attrs
        class: 'ref_line temp_line emph'
        r: @temp2radius(0)
        
    @zoomable_layer.append 'circle'
      .attrs
        class: 'ref_line temp_line'
        r: @temp2radius(20)
        
    
    @zoomable_layer.append 'circle'
      .attrs
        class: 'ref_line prec_line'
        r: @prec2radius(20)
        
    @zoomable_layer.append 'circle'
      .attrs
        class: 'ref_line prec_line'
        r: @prec2radius(40)
        
        
    @zoomable_layer.append 'circle'
      .attrs
        class: 'ref_line wind_line'
        r: @wind2dradius(20) + 87 # FIXME magic number - base for wind
    
    
    @zoomable_layer.append 'path'
      .attrs
        class: 'ref_line year emph'
        d: "M#{0} #{-scale*0.1} L #{0} #{-scale*0.5}"
    
  redraw: () ->
    EPSILON = 0.01
    ANIMATION_DURATION = 1500
    
    # check if leap year
    ndays = if ((@weather.year % 4 is 0) and (@weather.year % 100 isnt 0)) or (@weather.year % 400 is 0) then 366 else 365
    
    day2radians = d3.scaleLinear()
      .domain [1, ndays+1]
      .range [0, 2*Math.PI]
      
    arc_generator = d3.arc()
    
    
    # Temperature
    
    temp_bars = @zoomable_layer.selectAll '.temp_bar'
      .data @weather.days, (d) -> d.day
      
    enter_temp_bars = temp_bars.enter().append 'path'
      .attrs
        class: 'temp_bar bar'
          
    enter_temp_bars.append 'title'
          
    all_temp_bars = enter_temp_bars.merge(temp_bars)
    
    all_temp_bars.select 'title'
      .text (d) -> "#{d3.timeFormat('%Y, %B %e')(d.date)}\nTemperature:\n  Maximum: #{d.MaxTemperatureC} °C\n  Mean: #{d.MeanTemperatureC} °C\n  Minimum: #{d.MinTemperatureC} °C"
    
    all_temp_bars
      .transition().duration(ANIMATION_DURATION)
      .attrs
        d: (d) => arc_generator
          startAngle: day2radians d.day
          endAngle: day2radians (d.day+1)
          innerRadius: @temp2radius(d.MinTemperatureC)
          outerRadius: @temp2radius(d.MaxTemperatureC+EPSILON) # this is needed to avoid interpolation errors
        fill: (d) => @temp2color(d.MeanTemperatureC)
          
    temp_bars.exit()
      .remove()
      
      
    # Precipitation
    
    prec_bars = @zoomable_layer.selectAll '.prec_bar'
      .data @weather.days, (d) -> d.day
      
    enter_prec_bars = prec_bars.enter().append 'path'
      .attrs
        class: 'prec_bar bar'
          
    enter_prec_bars.append 'title'
    
    all_prec_bars = enter_prec_bars.merge(prec_bars)
    
    all_prec_bars.select 'title'
      .text (d) -> "#{d3.timeFormat('%Y, %B %e')(d.date)}\nPrecipitation: #{d.Precipitationmm} mm"
          
    all_prec_bars
      .transition().duration(ANIMATION_DURATION)
      .attrs
        d: (d) => arc_generator
          startAngle: day2radians d.day
          endAngle: day2radians (d.day+1)
          innerRadius: @prec2radius(d.Precipitationmm)
          outerRadius: @prec2radius(-EPSILON) # this is needed to avoid interpolation errors
          
    prec_bars.exit()
      .remove()
      
    
    # Cloud cover
    
    cloud_bars = @zoomable_layer.selectAll '.cloud_bar'
      .data @weather.days, (d) -> d.day
      
    enter_cloud_bars = cloud_bars.enter().append 'path'
      .attrs
        class: 'cloud_bar bar'
          
    enter_cloud_bars.append 'title'
    
    all_cloud_bars = enter_cloud_bars.merge(cloud_bars)
    
    all_cloud_bars.select 'title'
      .text (d) -> "#{d3.timeFormat('%Y, %B %e')(d.date)}\nCloud cover: #{d.CloudCover}/8"
          
    all_cloud_bars
      .transition().duration(ANIMATION_DURATION)
      .attrs
        d: (d) => arc_generator
          startAngle: day2radians d.day
          endAngle: day2radians (d.day+1)
          innerRadius: @cloud2radius(-EPSILON) # this is needed to avoid interpolation errors
          outerRadius: @cloud2radius(d.CloudCover)
          
    cloud_bars.exit()
      .remove()
      
    
    # Wind
    
    wind_bars = @zoomable_layer.selectAll '.wind_bar'
      .data @weather.days, (d) -> d.day
      
    enter_wind_bars = wind_bars.enter().append 'path'
      .attrs
        class: 'wind_bar bar'
          
    enter_wind_bars.append 'title'
    
    all_wind_bars = enter_wind_bars.merge(wind_bars)
    
    all_wind_bars.select 'title'
      .text (d) -> "#{d3.timeFormat('%Y, %B %e')(d.date)}\nMean wind speed: #{d['MeanWindSpeedKm/h']} Km/h\nWind direction: #{d.WindDirDegrees} °"
          
    all_wind_bars
      .transition().duration(ANIMATION_DURATION)
      .attrs
        d: (d) =>
          theta = day2radians((d.day+0.5)) - Math.PI/2
          rho = 87
          x = rho*Math.cos(theta)
          y = rho*Math.sin(theta)
          
          r = @wind2dradius(d['MeanWindSpeedKm/h'])
          dx = r*Math.cos(theta)
          dy = r*Math.sin(theta)
          
          a = 2*Math.PI*(d.WindDirDegrees-90)/360
          dax = 2*Math.cos(a)
          day = 2*Math.sin(a)
          
          return "M#{x+dax} #{y+day} l#{-dax} #{-day} l#{dx} #{dy}"
#         stroke: (d) -> wind2color d.WindDirDegrees
        stroke: 'teal'
  
    wind_bars.exit()
      .remove()
// Generated by CoffeeScript 1.10.0
(function() {
  var WeatherWheel,
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  window.WeatherWheel = observer(WeatherWheel = (function(superClass) {
    extend(WeatherWheel, superClass);

    function WeatherWheel(conf) {
      var scale, zoom;
      WeatherWheel.__super__.constructor.call(this, conf);
      this.init();
      this.weather = conf.weather;
      this.listen_to(this.weather, 'change', (function(_this) {
        return function() {
          return _this.redraw();
        };
      })(this));
      scale = 230;
      this.svg = this.d3el.append('svg').attrs({
        viewBox: (-scale / 2) + " " + (-scale / 2) + " " + scale + " " + scale
      });
      this.zoomable_layer = this.svg.append('g');
      zoom = d3.zoom().scaleExtent([-Infinity, Infinity]).on('zoom', (function(_this) {
        return function() {
          return _this.zoomable_layer.attrs({
            transform: d3.event.transform
          });
        };
      })(this));
      this.svg.call(zoom);
      this.temp2radius = d3.scaleLinear().domain([-40, 40]).range([10, 70]);
      this.temp2color = d3.scaleLinear().domain([-20, 0, 20, 40]).range([d3.hcl(290, 70, 15), d3.hcl(230, 70, 45), d3.hcl(80, 70, 75), d3.hcl(10, 70, 45)]).interpolate(d3.interpolateHcl);
      this.prec2radius = d3.scaleLinear().domain([0, 40]).range([80, 70]);
      this.cloud2radius = d3.scaleLinear().domain([0, 8]).range([80, 86]);
      this.wind2dradius = d3.scaleLinear().domain([0, 100]).range([0, 60]);
      this.zoomable_layer.append('circle').attrs({
        "class": 'ref_line temp_line',
        r: this.temp2radius(-20)
      });
      this.zoomable_layer.append('circle').attrs({
        "class": 'ref_line temp_line emph',
        r: this.temp2radius(0)
      });
      this.zoomable_layer.append('circle').attrs({
        "class": 'ref_line temp_line',
        r: this.temp2radius(20)
      });
      this.zoomable_layer.append('circle').attrs({
        "class": 'ref_line prec_line',
        r: this.prec2radius(20)
      });
      this.zoomable_layer.append('circle').attrs({
        "class": 'ref_line prec_line',
        r: this.prec2radius(40)
      });
      this.zoomable_layer.append('circle').attrs({
        "class": 'ref_line wind_line',
        r: this.wind2dradius(20) + 87
      });
      this.zoomable_layer.append('path').attrs({
        "class": 'ref_line year emph',
        d: "M" + 0. + " " + (-scale * 0.1) + " L " + 0. + " " + (-scale * 0.5)
      });
    }

    WeatherWheel.prototype.redraw = function() {
      var ANIMATION_DURATION, EPSILON, all_cloud_bars, all_prec_bars, all_temp_bars, all_wind_bars, arc_generator, cloud_bars, day2radians, enter_cloud_bars, enter_prec_bars, enter_temp_bars, enter_wind_bars, ndays, prec_bars, temp_bars, wind_bars;
      EPSILON = 0.01;
      ANIMATION_DURATION = 1500;
      ndays = ((this.weather.year % 4 === 0) && (this.weather.year % 100 !== 0)) || (this.weather.year % 400 === 0) ? 366 : 365;
      day2radians = d3.scaleLinear().domain([1, ndays + 1]).range([0, 2 * Math.PI]);
      arc_generator = d3.arc();
      temp_bars = this.zoomable_layer.selectAll('.temp_bar').data(this.weather.days, function(d) {
        return d.day;
      });
      enter_temp_bars = temp_bars.enter().append('path').attrs({
        "class": 'temp_bar bar'
      });
      enter_temp_bars.append('title');
      all_temp_bars = enter_temp_bars.merge(temp_bars);
      all_temp_bars.select('title').text(function(d) {
        return (d3.timeFormat('%Y, %B %e')(d.date)) + "\nTemperature:\n  Maximum: " + d.MaxTemperatureC + " °C\n  Mean: " + d.MeanTemperatureC + " °C\n  Minimum: " + d.MinTemperatureC + " °C";
      });
      all_temp_bars.transition().duration(ANIMATION_DURATION).attrs({
        d: (function(_this) {
          return function(d) {
            return arc_generator({
              startAngle: day2radians(d.day),
              endAngle: day2radians(d.day + 1),
              innerRadius: _this.temp2radius(d.MinTemperatureC),
              outerRadius: _this.temp2radius(d.MaxTemperatureC + EPSILON)
            });
          };
        })(this),
        fill: (function(_this) {
          return function(d) {
            return _this.temp2color(d.MeanTemperatureC);
          };
        })(this)
      });
      temp_bars.exit().remove();
      prec_bars = this.zoomable_layer.selectAll('.prec_bar').data(this.weather.days, function(d) {
        return d.day;
      });
      enter_prec_bars = prec_bars.enter().append('path').attrs({
        "class": 'prec_bar bar'
      });
      enter_prec_bars.append('title');
      all_prec_bars = enter_prec_bars.merge(prec_bars);
      all_prec_bars.select('title').text(function(d) {
        return (d3.timeFormat('%Y, %B %e')(d.date)) + "\nPrecipitation: " + d.Precipitationmm + " mm";
      });
      all_prec_bars.transition().duration(ANIMATION_DURATION).attrs({
        d: (function(_this) {
          return function(d) {
            return arc_generator({
              startAngle: day2radians(d.day),
              endAngle: day2radians(d.day + 1),
              innerRadius: _this.prec2radius(d.Precipitationmm),
              outerRadius: _this.prec2radius(-EPSILON)
            });
          };
        })(this)
      });
      prec_bars.exit().remove();
      cloud_bars = this.zoomable_layer.selectAll('.cloud_bar').data(this.weather.days, function(d) {
        return d.day;
      });
      enter_cloud_bars = cloud_bars.enter().append('path').attrs({
        "class": 'cloud_bar bar'
      });
      enter_cloud_bars.append('title');
      all_cloud_bars = enter_cloud_bars.merge(cloud_bars);
      all_cloud_bars.select('title').text(function(d) {
        return (d3.timeFormat('%Y, %B %e')(d.date)) + "\nCloud cover: " + d.CloudCover + "/8";
      });
      all_cloud_bars.transition().duration(ANIMATION_DURATION).attrs({
        d: (function(_this) {
          return function(d) {
            return arc_generator({
              startAngle: day2radians(d.day),
              endAngle: day2radians(d.day + 1),
              innerRadius: _this.cloud2radius(-EPSILON),
              outerRadius: _this.cloud2radius(d.CloudCover)
            });
          };
        })(this)
      });
      cloud_bars.exit().remove();
      wind_bars = this.zoomable_layer.selectAll('.wind_bar').data(this.weather.days, function(d) {
        return d.day;
      });
      enter_wind_bars = wind_bars.enter().append('path').attrs({
        "class": 'wind_bar bar'
      });
      enter_wind_bars.append('title');
      all_wind_bars = enter_wind_bars.merge(wind_bars);
      all_wind_bars.select('title').text(function(d) {
        return (d3.timeFormat('%Y, %B %e')(d.date)) + "\nMean wind speed: " + d['MeanWindSpeedKm/h'] + " Km/h\nWind direction: " + d.WindDirDegrees + " °";
      });
      all_wind_bars.transition().duration(ANIMATION_DURATION).attrs({
        d: (function(_this) {
          return function(d) {
            var a, dax, day, dx, dy, r, rho, theta, x, y;
            theta = day2radians(d.day + 0.5) - Math.PI / 2;
            rho = 87;
            x = rho * Math.cos(theta);
            y = rho * Math.sin(theta);
            r = _this.wind2dradius(d['MeanWindSpeedKm/h']);
            dx = r * Math.cos(theta);
            dy = r * Math.sin(theta);
            a = 2 * Math.PI * (d.WindDirDegrees - 90) / 360;
            dax = 2 * Math.cos(a);
            day = 2 * Math.sin(a);
            return "M" + (x + dax) + " " + (y + day) + " l" + (-dax) + " " + (-day) + " l" + dx + " " + dy;
          };
        })(this),
        stroke: 'teal'
      });
      return wind_bars.exit().remove();
    };

    return WeatherWheel;

  })(View));

}).call(this);
File not shown (100K+ bytes).
// Generated by CoffeeScript 1.10.0
(function() {
  var View, setup_init,
    slice = [].slice;

  setup_init = function(c, init) {
    if (c.prototype.inits == null) {
      c.prototype.inits = [];
    }
    c.prototype.inits.push(init);
    return c.prototype.init = function(conf) {
      var i, len, m, ref, results;
      ref = this.inits;
      results = [];
      for (i = 0, len = ref.length; i < len; i++) {
        m = ref[i];
        results.push(m.call(this, conf));
      }
      return results;
    };
  };

  window.observable = function(c) {
    setup_init(c, function(config) {
      this._dispatcher = d3.dispatch.apply(d3, config.events);
      return this._next_id = 0;
    });
    c.prototype.on = function(event_type_ns, callback) {
      var event_type, event_type_full, namespace, splitted_event_type_ns;
      splitted_event_type_ns = event_type_ns.split('.');
      event_type = splitted_event_type_ns[0];
      if (splitted_event_type_ns.length > 1) {
        namespace = splitted_event_type_ns[1];
      } else {
        namespace = this._next_id;
        this._next_id += 1;
      }
      event_type_full = event_type + '.' + namespace;
      this._dispatcher.on(event_type_full, callback);
      return event_type_full;
    };
    c.prototype.trigger = function() {
      var args, event_type;
      event_type = arguments[0], args = 2 <= arguments.length ? slice.call(arguments, 1) : [];
      this._dispatcher.apply(event_type, this, args);
      return this;
    };
    return c;
  };

  window.observer = function(c) {
    setup_init(c, function() {
      return this._bindings = [];
    });
    c.prototype.listen_to = function(observed, event, cb) {
      return this._bindings.push({
        observed: observed,
        event_type: observed.on(event, cb)
      });
    };
    c.prototype.stop_listening = function() {
      return this._bindings.forEach((function(_this) {
        return function(l) {
          return l.observed.on(l.event_type, null);
        };
      })(this));
    };
    return c;
  };

  window.View = View = (function() {
    function View(conf) {
      if (conf.tag == null) {
        conf.tag = 'div';
      }
      this.el = document.createElement(conf.tag);
      this.d3el = d3.select(this.el);
      this.d3el.classed(this.constructor.name, true);
      if (conf.parent != null) {
        this.append_to(conf.parent, conf.prepend);
      }
    }

    View.prototype.append_to = function(parent, prepend) {
      var p_el;
      if (parent.el != null) {
        p_el = parent.el;
      } else {
        if (parent.node != null) {
          p_el = parent.node();
        } else {
          p_el = d3.select(parent).node();
        }
      }
      if (prepend) {
        return p_el.insertBefore(this.el, p_el.firstChild);
      } else {
        return p_el.appendChild(this.el);
      }
    };

    View.prototype.compute_size = function() {
      this.width = this.el.getBoundingClientRect().width;
      return this.height = this.el.getBoundingClientRect().height;
    };

    return View;

  })();

}).call(this);
d3.csv 'airports.csv', (airports) ->
  # build the airports database in memory
  airports_db = {}

  airports_db.list = airports
    # ICAO-only airports
    .filter (d) -> d.icao isnt '\\N' and d.icao isnt ''

  airports_db.index = {}
  airports_db.list.forEach (d) ->
    airports_db.index[d.icao] = d

  airports_db.countries = d3.nest()
    .key (d) -> d.country
    .entries airports_db.list

  airports_db.by_country = {}
  airports_db.countries.forEach (d) ->
    airports_db.by_country[d.key] = d.values


  new AppView
    airports_db: airports_db
    parent: 'body'
body, html {
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
}

.AppView {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: row;
}

.AppView > * {
  width: 0;
  flex-grow: 1;
}
.AppView > *:not(:last-child) {
  border-right: 1px solid #DDD;
}

.WeatherPanel {
  display: flex;
  flex-direction: column;
}

.WeatherWheel {
  height: 0;
  flex-grow: 1;
}

.WeatherWheel svg {
  width: 100%;
  height: 100%;
}

.WeatherWheel .bar {
  opacity: 0.8;
}
.WeatherWheel .bar:hover {
  opacity: 1;
}
.WeatherWheel .temp_bar {
  stroke-width: 0.1;
  stroke: white;
}
.WeatherWheel .prec_bar {
  fill: steelblue;
}
.WeatherWheel .cloud_bar {
  fill: #AAA;
}
.WeatherWheel .wind_bar {
  stroke-width: 0.4;
  opacity: 0.4;
  fill: none;
}

.WeatherWheel .ref_line {
  fill: none;
  stroke-width: 0.3;
  stroke: #555;
  vector-effect: non-scaling-stroke;
  opacity: 0.5;
}
.WeatherWheel .ref_line.emph {
  stroke-width: 0.6;
}
.WeatherWheel .prec_line {
  stroke: steelblue;
}
.WeatherWheel .wind_line {
  stroke: teal;
  stroke-dasharray: 3 3;
}

.QueryView {
  padding: 4px;
  width: 100%;
  box-sizing: border-box;
}
.QueryView select {
  padding: 4px;
  width: 100%;
  font-size: 16px;
}
.QueryView select:first-child {
  margin-bottom: 4px;
}
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Weather wheel III</title>
  <link type="text/css" href="index.css" rel="stylesheet"/>
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <script src="https://d3js.org/d3-selection-multi.v0.4.min.js"></script>
  <script src="eye.js"></script>  
  
  <script src="Query.js"></script>
  <script src="Weather.js"></script>
  
  <script src="AppView.js"></script>
  <script src="WeatherPanel.js"></script>
  <script src="QueryView.js"></script>
  <script src="WeatherWheel.js"></script>
</head>
<body>
  <script src="index.js"></script>
</body>
</html>
// Generated by CoffeeScript 1.10.0
(function() {
  d3.csv('airports.csv', function(airports) {
    var airports_db;
    airports_db = {};
    airports_db.list = airports.filter(function(d) {
      return d.icao !== '\\N' && d.icao !== '';
    });
    airports_db.index = {};
    airports_db.list.forEach(function(d) {
      return airports_db.index[d.icao] = d;
    });
    airports_db.countries = d3.nest().key(function(d) {
      return d.country;
    }).entries(airports_db.list);
    airports_db.by_country = {};
    airports_db.countries.forEach(function(d) {
      return airports_db.by_country[d.key] = d.values;
    });
    return new AppView({
      airports_db: airports_db,
      parent: 'body'
    });
  });

}).call(this);
File not shown (binary encoding).
<?php
  $airport = $_GET['station'];
  $year = $_GET['year'];
  
  $url = "http://www.wunderground.com/history/airport/$airport/$year/1/1/CustomHistory.html?dayend=31&monthend=12&yearend=$year&req_city=&req_state=&req_statename=&reqdb.zip=&reqdb.magic=&reqdb.wmo=&MR=1&format=1";
  
  $html = file_get_contents($url);
  $csv = preg_replace('/^\n/', '', $html);
  $lines = explode('<br />', $csv);
  $lines[0] = str_replace(' ', '', $lines[0]);
  $csv = implode('', $lines);

  header('Content-Type: application/csv');
  echo $csv;
?>