PageSourceSearch

https://kenya-sdg.github.io/assets/js/sdg.js?v=20251125095147

js kenya-sdg.github.io collected 2026-10-03 09:27:18 UTC 243,096 bytes, 6,707 lines download raw bytes

1if ('serviceWorker' in navigator) {
2    window.addEventListener('load', () => {
3        navigator.serviceWorker.register('/service-worker.js');
4    });
5}
6/**
7 * This function returns a javascript object containing autotrack.js properties.
8 *
9 * These properties can be added to an element with jQuery: $(element).attr(props)
10 *
11 * See _includes/autotrack.html for parameter descriptions.
12 */
13opensdg.autotrack = function(preset, category, action, label) {
14  var presets = {};var params = {
15    category: category,
16    action: action,
17    label: label
18  };
19  if (presets[preset]) {
20    params = presets[preset];
21  }
22  var obj = {
23    'data-on': 'click'
24  };
25  if (params.category) {
26    obj['data-event-category'] = params.category;
27  }
28  if (params.action) {
29    obj['data-event-action'] = params.action;
30  }
31  if (params.label) {
32    obj['data-event-label'] = params.label;
33  }
34
35  return obj;
36};
37/**
38 * TODO:
39 * Integrate with high-contrast switcher.
40 */
41(function($) {
42
43  if (typeof L === 'undefined') {
44    return;
45  }
46
47  // Create the defaults once
48  var defaults = {
49
50    // Options for using tile imagery with leaflet.
51    tileURL: '[replace me]',
52    tileOptions: {
53      id: '[relace me]',
54      accessToken: '[replace me]',
55      attribution: '[replace me]',
56    },
57    // Zoom limits.
58    minZoom: 5,
59    maxZoom: 10,
60    // Visual/choropleth considerations.
61    colorRange: chroma.brewer.BuGn,
62    noValueColor: '#f0f0f0',
63    styleNormal: {
64      weight: 1,
65      opacity: 1,
66      color: '#888888',
67      fillOpacity: 0.7
68    },
69    styleHighlighted: {
70      weight: 1,
71      opacity: 1,
72      color: '#111111',
73      fillOpacity: 0.7
74    },
75    styleStatic: {
76      weight: 2,
77      opacity: 1,
78      fillOpacity: 0,
79      color: '#172d44',
80      dashArray: '5,5',
81    },
82  };
83
84  // Defaults for each map layer.
85  var mapLayerDefaults = {
86    min_zoom: 0,
87    max_zoom: 10,
88    subfolder: 'regions',
89    label: 'indicator.map',
90    staticBorders: false,
91  };
92
93  function Plugin(element, options) {
94
95    this.element = element;
96
97    // Support colorRange map option in string format.
98    if (typeof options.mapOptions.colorRange === 'string') {
99      var colorRangeParts = options.mapOptions.colorRange.split('.'),
100          colorRange = window,
101          overrideColorRange = true;
102      for (var i = 0; i < colorRangeParts.length; i++) {
103        var colorRangePart = colorRangeParts[i];
104        if (typeof colorRange[colorRangePart] !== 'undefined') {
105          colorRange = colorRange[colorRangePart];
106        }
107        else {
108          overrideColorRange = false;
109          break;
110        }
111      }
112      if (overrideColorRange && typeof colorRange === 'function') {
113        var indicatorId = options.indicatorId.replace('indicator_', ''),
114            indicatorIdParts = indicatorId.split('-'),
115            goalId = (indicatorIdParts.length > 0) ? indicatorIdParts[0] : null,
116            indicatorIdDots = indicatorIdParts.join('.');
117        colorRange = colorRange(indicatorIdDots, goalId);
118      }
119      options.mapOptions.colorRange = (overrideColorRange) ? colorRange : defaults.colorRange;
120    }
121
122    this.options = $.extend(true, {}, defaults, options.mapOptions);
123    this.mapLayers = [];
124    this.indicatorId = options.indicatorId;
125    this._precision = options.precision;
126    this.precisionItems = options.precisionItems;
127    this._decimalSeparator = options.decimalSeparator;
128    this.currentDisaggregation = 0;
129    this.dataSchema = options.dataSchema;
130    this.viewHelpers = options.viewHelpers;
131    this.modelHelpers = options.modelHelpers;
132    this.chartTitles = options.chartTitles;
133    this.proxy = options.proxy;
134    this.proxySerieses = options.proxySerieses;
135    this.startValues = options.startValues;
136    this.configObsAttributes = [];
137    this.allObservationAttributes = options.allObservationAttributes;
138    this._browserDecimalSeparator = this.viewHelpers.getBrowserDecimalSeparator();
139
140    // Require at least one geoLayer.
141    if (!options.mapLayers || !options.mapLayers.length) {
142      console.log('Map disabled - please add "map_layers" in site configuration.');
143      return;
144    }
145
146    // Apply geoLayer defaults.
147    for (var i = 0; i < options.mapLayers.length; i++) {
148      this.mapLayers[i] = $.extend(true, {}, mapLayerDefaults, options.mapLayers[i]);
149    }
150
151    // Sort the map layers according to zoom levels.
152    this.mapLayers.sort(function(a, b) {
153      if (a.min_zoom === b.min_zoom) {
154        return a.max_zoom - b.max_zoom;
155      }
156      return a.min_zoom - b.min_zoom;
157    });
158
159    this._defaults = defaults;
160    this._name = 'sdgMap';
161
162    this.init();
163  }
164
165  Plugin.prototype = {
166
167    // Update title.
168    updateTitle: function() {
169      if (!this.modelHelpers) {
170        return;
171      }
172      var currentSeries = this.disaggregationControls.getCurrentSeries(),
173          currentUnit = this.disaggregationControls.getCurrentUnit(),
174          newTitle = null;
175      if (this.modelHelpers.GRAPH_TITLE_FROM_SERIES) {
176        newTitle = currentSeries;
177      }
178      else {
179        var currentTitle = $('#map-heading').text();
180        newTitle = this.modelHelpers.getChartTitle(currentTitle, this.chartTitles, currentUnit, currentSeries);
181      }
182      if (newTitle) {
183        if (this.proxy === 'proxy' || this.proxySerieses.includes(currentSeries)) {
184            newTitle += ' ' + this.viewHelpers.PROXY_PILL;
185        }
186        $('#map-heading').html(newTitle);
187      }
188    },
189
190    // Update footer fields.
191    updateFooterFields: function() {
192      if (!this.viewHelpers) {
193        return;
194      }
195      var currentSeries = this.disaggregationControls.getCurrentSeries(),
196          currentUnit = this.disaggregationControls.getCurrentUnit();
197      this.viewHelpers.updateSeriesAndUnitElements(currentSeries, currentUnit);
198      this.viewHelpers.updateUnitElements(currentUnit);
199    },
200
201    // Update precision.
202    updatePrecision: function() {
203      if (!this.modelHelpers) {
204        return;
205      }
206      var currentSeries = this.disaggregationControls.getCurrentSeries(),
207          currentUnit = this.disaggregationControls.getCurrentUnit();
208      this._precision = this.modelHelpers.getPrecision(this.precisionItems, currentUnit, currentSeries);
209    },
210
211    // Zoom to a feature.
212    zoomToFeature: function(layer) {
213      this.map.fitBounds(layer.getBounds());
214    },
215
216    // Build content for a tooltip.
217    getTooltipContent: function(feature) {
218      var tooltipContent = feature.properties.name;
219      var tooltipData = this.getData(feature.properties);
220      var plugin = this;
221      if (typeof tooltipData === 'number') {
222        tooltipContent += ': ' + this.alterData(tooltipData);
223      }
224      if (feature.properties.observation_attributes) {
225        var obsAtts = feature.properties.observation_attributes[plugin.currentDisaggregation][plugin.currentYear],
226            footnoteNumbers = [];
227        if (obsAtts) {
228          Object.keys(obsAtts).forEach(function(field) {
229            if (obsAtts[field]) {
230              var hashKey = field + '|' + obsAtts[field];
231              var footnoteNumber = plugin.allObservationAttributes[hashKey].footnoteNumber;
232              footnoteNumbers.push(plugin.viewHelpers.getObservationAttributeFootnoteSymbol(footnoteNumber));
233            }
234          });
235          if (footnoteNumbers.length > 0) {
236            tooltipContent += ' ' + footnoteNumbers.join(' ');
237          }
238        }
239      }
240
241      return tooltipContent;
242    },
243
244    // Update a tooltip.
245    updateTooltip: function(layer) {
246      if (layer.getTooltip()) {
247        var tooltipContent = this.getTooltipContent(layer.feature);
248        layer.setTooltipContent(tooltipContent);
249      }
250    },
251
252    // Create tooltip.
253    createTooltip: function(layer) {
254      if (!layer.getTooltip()) {
255        var tooltipContent = this.getTooltipContent(layer.feature);
256        layer.bindTooltip(tooltipContent, {
257          permanent: true,
258        }).addTo(this.map);
259      }
260    },
261
262    // Select a feature.
263    highlightFeature: function(layer) {
264      // Abort if the layer is not on the map.
265      if (!this.map.hasLayer(layer)) {
266        return;
267      }
268      // Update the style.
269      layer.setStyle(this.options.styleHighlighted);
270      // Add a tooltip if not already there.
271      this.createTooltip(layer);
272      if (!L.Browser.ie && !L.Browser.opera && !L.Browser.edge) {
273        layer.bringToFront();
274      }
275      this.updateStaticLayers();
276    },
277
278    // Unselect a feature.
279    unhighlightFeature: function(layer) {
280
281      // Reset the feature's style.
282      layer.setStyle(this.options.styleNormal);
283
284      // Remove the tooltip if necessary.
285      if (layer.getTooltip()) {
286        layer.unbindTooltip();
287      }
288
289      // Make sure other selections are still highlighted.
290      var plugin = this;
291      this.selectionLegend.selections.forEach(function(selection) {
292        plugin.highlightFeature(selection);
293      });
294    },
295
296    // Get all of the GeoJSON layers.
297    getAllLayers: function() {
298      return L.featureGroup(this.dynamicLayers.layers);
299    },
300
301    // Get only the visible GeoJSON layers.
302    getVisibleLayers: function() {
303      // Unfortunately relies on an internal of the ZoomShowHide library.
304      return this.dynamicLayers._layerGroup;
305    },
306
307    updateStaticLayers: function() {
308      // Make sure the static borders are always visible.
309      this.staticLayers._layerGroup.eachLayer(function(layer) {
310        layer.bringToFront();
311      });
312    },
313
314    // Update the colors of the Features on the map.
315    updateColors: function() {
316      var plugin = this;
317      this.getAllLayers().eachLayer(function(layer) {
318        layer.setStyle(function(feature) {
319          return {
320            fillColor: plugin.getColor(feature.properties),
321          }
322        });
323      });
324    },
325
326    // Update the tooltips of the selected Features on the map.
327    updateTooltips: function() {
328      var plugin = this;
329      this.selectionLegend.selections.forEach(function(selection) {
330        plugin.updateTooltip(selection);
331      });
332    },
333
334    // Alter data before displaying it.
335    alterData: function(value) {
336      opensdg.dataDisplayAlterations.forEach(function(callback) {
337        value = callback(value);
338      });
339      if (typeof value !== 'number') {
340        if (this._precision || this._precision === 0) {
341          value = Number.parseFloat(value).toFixed(this._precision);
342        }
343        if (this._decimalSeparator) {
344          value = value.toString().replace('.', this._decimalSeparator);
345        }
346      }
347      else {
348        var localeOpts = {};
349        if (this._precision || this._precision === 0) {
350            localeOpts.minimumFractionDigits = this._precision;
351            localeOpts.maximumFractionDigits = this._precision;
352        }
353        value = value.toLocaleString(opensdg.language_numbers, localeOpts);
354        // Still use the custom decimal separator if it is there.
355        if (this._decimalSeparator) {
356          value = value.toString().replace(this._browserDecimalSeparator, this._decimalSeparator);
357        }
358      }
359      return value;
360    },
361
362    // Get the data from a feature's properties, according to the current year.
363    getData: function(props) {
364      var ret = false;
365      if (props.values && props.values.length && this.currentDisaggregation < props.values.length) {
366        var value = props.values[this.currentDisaggregation][this.currentYear];
367        if (typeof value === 'number') {
368          ret = opensdg.dataRounding(value, { indicatorId: this.indicatorId });
369        }
370      }
371      return ret;
372    },
373
374    // Choose a color for a GeoJSON feature.
375    getColor: function(props) {
376      var data = this.getData(props);
377      if (data) {
378        return this.colorScale(data).hex();
379      }
380      else {
381        return this.options.noValueColor;
382      }
383    },
384
385    // Set (or re-set) the choropleth color scale.
386    setColorScale: function() {
387      this.colorScale = chroma.scale(this.options.colorRange)
388        .domain(this.valueRanges[this.currentDisaggregation])
389        .classes(this.options.colorRange.length);
390    },
391
392    // Get the (long) URL of a geojson file, given a particular subfolder.
393    getGeoJsonUrl: function(subfolder) {
394      var fileName = this.indicatorId + '.geojson';
395      return [opensdg.remoteDataBaseUrl, 'geojson', subfolder, fileName].join('/');
396    },
397
398    getYearSlider: function() {
399      var plugin = this,
400          years = plugin.years[plugin.currentDisaggregation];
401      return L.Control.yearSlider({
402        years: years,
403        yearChangeCallback: function(e) {
404          plugin.currentYear = years[e.target._currentTimeIndex];
405          plugin.updateColors();
406          plugin.updateTooltips();
407          plugin.selectionLegend.update();
408        }
409      });
410    },
411
412    replaceYearSlider: function() {
413      var newSlider = this.getYearSlider();
414      var oldSlider = this.yearSlider;
415      this.map.addControl(newSlider);
416      this.map.removeControl(oldSlider);
417      this.yearSlider = newSlider;
418      $(this.yearSlider.getContainer()).insertAfter($(this.disaggregationControls.getContainer()));
419      this.yearSlider._timeDimension.setCurrentTimeIndex(this.yearSlider._timeDimension.getCurrentTimeIndex());
420    },
421
422    // Initialize the map itself.
423    init: function() {
424
425      // Create the map.
426      this.map = L.map(this.element, {
427        minZoom: this.options.minZoom,
428        maxZoom: this.options.maxZoom,
429        zoomControl: false,
430      });
431      this.map.setView([0, 0], 0);
432      this.dynamicLayers = new ZoomShowHide();
433      this.dynamicLayers.addTo(this.map);
434      this.staticLayers = new ZoomShowHide();
435      this.staticLayers.addTo(this.map);
436
437      // Add scale.
438      this.map.addControl(L.control.scale({position: 'bottomright'}));
439
440      // Add tile imagery.
441      if (this.options.tileURL && this.options.tileURL !== 'undefined' && this.options.tileURL != '') {
442        L.tileLayer(this.options.tileURL, this.options.tileOptions).addTo(this.map);
443      }
444
445      // Because after this point, "this" rarely works.
446      var plugin = this;
447
448      // Below we'll be figuring out the min/max values and available years.
449      var minimumValues = [],
450          maximumValues = [],
451          availableYears = [];
452
453      // At this point we need to load the GeoJSON layer/s.
454      var geoURLs = this.mapLayers.map(function(item) {
455        return $.getJSON(plugin.getGeoJsonUrl(item.subfolder));
456      });
457      $.when.apply($, geoURLs).done(function() {
458
459        // Apparently "arguments" can either be an array of responses, or if
460        // there was only one response, the response itself. This behavior is
461        // odd and should be investigated. In the meantime, a workaround is a
462        // blunt check to see if it is a single response.
463        var geoJsons = arguments;
464        // In a response, the second element is a string (like 'success') so
465        // check for that here to identify whether it is a response.
466        if (arguments.length > 1 && typeof arguments[1] === 'string') {
467          // If so, put it into an array, to match the behavior when there are
468          // multiple responses.
469          geoJsons = [geoJsons];
470        }
471
472        // Do a quick loop through to see which layers actually have data.
473        for (var i = 0; i < geoJsons.length; i++) {
474          var layerHasData = true;
475          if (typeof geoJsons[i][0].features === 'undefined') {
476            layerHasData = false;
477          }
478          else if (!plugin.featuresShouldDisplay(geoJsons[i][0].features)) {
479            layerHasData = false;
480          }
481          if (layerHasData === false) {
482            // If a layer has no data, we'll be skipping it.
483            plugin.mapLayers[i].skipLayer = true;
484            // We also need to alter a sibling layer's min_zoom or max_zoom.
485            var hasLayerBefore = i > 0;
486            var hasLayerAfter = i < (geoJsons.length - 1);
487            if (hasLayerBefore) {
488              plugin.mapLayers[i - 1].max_zoom = plugin.mapLayers[i].max_zoom;
489            }
490            else if (hasLayerAfter) {
491              plugin.mapLayers[i + 1].min_zoom = plugin.mapLayers[i].min_zoom;
492            }
493          }
494          else {
495            plugin.mapLayers[i].skipLayer = false;
496          }
497        }
498
499        for (var i = 0; i < geoJsons.length; i++) {
500          if (plugin.mapLayers[i].skipLayer) {
501            continue;
502          }
503          // First add the geoJson as static (non-interactive) borders.
504          if (plugin.mapLayers[i].staticBorders) {
505            var staticLayer = L.geoJson(geoJsons[i][0], {
506              style: plugin.options.styleStatic,
507              interactive: false,
508            });
509            // Static layers should start appear when zooming past their dynamic
510            // layer, and stay visible after that.
511            staticLayer.min_zoom = plugin.mapLayers[i].max_zoom + 1;
512            staticLayer.max_zoom = plugin.options.maxZoom;
513            plugin.staticLayers.addLayer(staticLayer);
514          }
515          // Now go on to add the geoJson again as choropleth dynamic regions.
516          var geoJson = geoJsons[i][0]
517          var layer = L.geoJson(geoJson, {
518            style: plugin.options.styleNormal,
519            onEachFeature: onEachFeature,
520          });
521          // Set the "boundaries" for when this layer should be zoomed out of.
522          layer.min_zoom = plugin.mapLayers[i].min_zoom;
523          layer.max_zoom = plugin.mapLayers[i].max_zoom;
524          // Listen for when this layer gets zoomed in or out of.
525          layer.on('remove', zoomOutHandler);
526          layer.on('add', zoomInHandler);
527          // Save the GeoJSON object for direct access (download) later.
528          layer.geoJsonObject = geoJson;
529          // Add the layer to the ZoomShowHide group.
530          plugin.dynamicLayers.addLayer(layer);
531
532          // Add a download button below the map.
533          var downloadLabel = translations.t(plugin.mapLayers[i].label)
534          var downloadButton = $('<a></a>')
535            .attr('href', plugin.getGeoJsonUrl(plugin.mapLayers[i].subfolder))
536            .attr('download', '')
537            .attr('class', 'btn btn-primary btn-download')
538            .attr('title', translations.indicator.download_geojson_title + ' - ' + downloadLabel)
539            .attr('aria-label', translations.indicator.download_geojson_title + ' - ' + downloadLabel)
540            .text(translations.indicator.download_geojson + ' - ' + downloadLabel);
541          $(plugin.element).parent().append(downloadButton);
542
543          // Keep track of the minimums and maximums.
544          _.each(geoJson.features, function(feature) {
545            if (feature.properties.values && feature.properties.values.length > 0) {
546              for (var valueIndex = 0; valueIndex < feature.properties.values.length; valueIndex++) {
547                var validEntries = _.reject(Object.entries(feature.properties.values[valueIndex]), function(entry) {
548                  return isMapValueInvalid(entry[1]);
549                });
550                var validKeys = validEntries.map(function(entry) {
551                  return entry[0];
552                });
553                var validValues = validEntries.map(function(entry) {
554                  return entry[1];
555                });
556                if (availableYears.length <= valueIndex) {
557                  availableYears.push([]);
558                }
559                availableYears[valueIndex] = availableYears[valueIndex].concat(validKeys);
560                if (minimumValues.length <= valueIndex) {
561                  minimumValues.push([]);
562                  maximumValues.push([]);
563                }
564                minimumValues[valueIndex].push(_.min(validValues));
565                maximumValues[valueIndex].push(_.max(validValues));
566              }
567            }
568          });
569        }
570
571        // Calculate the ranges of values, years and colors.
572        function isMapValueInvalid(val) {
573          return _.isNaN(val) || val === '';
574        }
575
576        plugin.valueRanges = [];
577        for (var valueIndex = 0; valueIndex < minimumValues.length; valueIndex++) {
578          minimumValues[valueIndex] = _.reject(minimumValues[valueIndex], isMapValueInvalid);
579          maximumValues[valueIndex] = _.reject(maximumValues[valueIndex], isMapValueInvalid);
580          plugin.valueRanges[valueIndex] = [_.min(minimumValues[valueIndex]), _.max(maximumValues[valueIndex])];
581        }
582        plugin.setColorScale();
583
584        plugin.years = availableYears.map(function(yearsForIndex) {
585          return _.uniq(yearsForIndex).sort();
586        });
587        //Start the map with the most recent year
588        plugin.currentYear = plugin.years[plugin.currentDisaggregation].slice(-1)[0];
589        plugin.currentYear = plugin.years.slice(-1)[0];
590
591        // And we can now update the colors.
592        plugin.updateColors();
593
594        // Add zoom control.
595        plugin.zoomHome = L.Control.zoomHome({
596          zoomInTitle: translations.indicator.map_zoom_in,
597          zoomOutTitle: translations.indicator.map_zoom_out,
598          zoomHomeTitle: translations.indicator.map_zoom_home,
599        });
600        plugin.map.addControl(plugin.zoomHome);
601
602        // Add full-screen functionality.
603        plugin.map.addControl(new L.Control.FullscreenAccessible({
604          title: {
605              'false': translations.indicator.map_fullscreen,
606              'true': translations.indicator.map_fullscreen_exit,
607          },
608        }));
609
610        // Add the year slider.
611        plugin.yearSlider = plugin.getYearSlider();
612        plugin.map.addControl(plugin.yearSlider);
613
614        // Add the selection legend.
615        plugin.selectionLegend = L.Control.selectionLegend(plugin);
616        plugin.map.addControl(plugin.selectionLegend);
617
618        // Add the disaggregation controls.
619        plugin.disaggregationControls = L.Control.disaggregationControls(plugin);
620        plugin.map.addControl(plugin.disaggregationControls);
621        if (plugin.disaggregationControls.needsMapUpdate) {
622          plugin.disaggregationControls.updateMap();
623        }
624        else {
625          plugin.updateTitle();
626          plugin.updatePrecision();
627        }
628
629        // Add the search feature.
630        plugin.searchControl = new L.Control.SearchAccessible({
631          textPlaceholder: 'Search map',
632          autoCollapseTime: 7000,
633          layer: plugin.getAllLayers(),
634          propertyName: 'name',
635          marker: false,
636          moveToLocation: function(latlng) {
637            plugin.zoomToFeature(latlng.layer);
638            if (!plugin.selectionLegend.isSelected(latlng.layer)) {
639              plugin.highlightFeature(latlng.layer);
640              plugin.selectionLegend.addSelection(latlng.layer);
641            }
642          },
643        });
644        plugin.map.addControl(plugin.searchControl);
645        // The search plugin messes up zoomShowHide, so we have to reset that
646        // with this hacky method. Is there a better way?
647        var zoom = plugin.map.getZoom();
648        plugin.map.setZoom(plugin.options.maxZoom);
649        plugin.map.setZoom(zoom);
650
651        // Hide the loading image.
652        $('.map-loading-image').hide();
653        // Make the map unfocusable.
654        $('#map').removeAttr('tabindex');
655
656        // The list of handlers to apply to each feature on a GeoJson layer.
657        function onEachFeature(feature, layer) {
658          if (plugin.featureShouldDisplay(feature)) {
659            layer.on('click', clickHandler);
660            layer.on('mouseover', mouseoverHandler);
661            layer.on('mouseout', mouseoutHandler);
662          }
663        }
664        // Event handler for click/touch.
665        function clickHandler(e) {
666          var layer = e.target;
667          if (plugin.selectionLegend.isSelected(layer)) {
668            plugin.selectionLegend.removeSelection(layer);
669            plugin.unhighlightFeature(layer);
670          }
671          else {
672            plugin.selectionLegend.addSelection(layer);
673            plugin.highlightFeature(layer);
674            plugin.zoomToFeature(layer);
675          }
676        }
677        // Event handler for mouseover.
678        function mouseoverHandler(e) {
679          var layer = e.target;
680          if (!plugin.selectionLegend.isSelected(layer)) {
681            plugin.highlightFeature(layer);
682          }
683        }
684        // Event handler for mouseout.
685        function mouseoutHandler(e) {
686          var layer = e.target;
687          if (!plugin.selectionLegend.isSelected(layer)) {
688            plugin.unhighlightFeature(layer);
689          }
690        }
691        // Event handler for when a geoJson layer is zoomed out of.
692        function zoomOutHandler(e) {
693          var geoJsonLayer = e.target;
694          // For desktop, we have to make sure that no features remain
695          // highlighted, as they might have been highlighted on mouseover.
696          geoJsonLayer.eachLayer(function(layer) {
697            if (!plugin.selectionLegend.isSelected(layer)) {
698              plugin.unhighlightFeature(layer);
699            }
700          });
701          plugin.updateStaticLayers();
702          if (plugin.disaggregationControls) {
703            plugin.disaggregationControls.update();
704          }
705        }
706        // Event handler for when a geoJson layer is zoomed into.
707        function zoomInHandler(e) {
708          plugin.updateStaticLayers();
709          if (plugin.disaggregationControls) {
710            plugin.disaggregationControls.update();
711          }
712        }
713      });
714
715      // Certain things cannot be done until the map is visible. Because our
716      // map is in a tab which might not be visible, we have to postpone those
717      // things until it becomes visible.
718      if ($('#map').is(':visible')) {
719        finalMapPreparation();
720      }
721      else {
722        $('#tab-mapview').parent().click(finalMapPreparation);
723      }
724      function finalMapPreparation() {
725        // Update the series/unit stuff in case it changed
726        // while on the chart/table.
727        plugin.updateTitle();
728        plugin.updateFooterFields();
729        plugin.updatePrecision();
730        // The year slider does not seem to be correct unless we refresh it here.
731        plugin.yearSlider._timeDimension.setCurrentTimeIndex(plugin.yearSlider._timeDimension.getCurrentTimeIndex());
732        // Delay other things to give time for browser to do stuff.
733        setTimeout(function() {
734          $('#map #loader-container').hide();
735          // Leaflet needs "invalidateSize()" if it was originally rendered in a
736          // hidden element. So we need to do that when the tab is clicked.
737          plugin.map.invalidateSize();
738          // Also zoom in/out as needed.
739          plugin.map.fitBounds(plugin.getVisibleLayers().getBounds());
740          // Set the home button to return to that zoom.
741          plugin.zoomHome.setHomeBounds(plugin.getVisibleLayers().getBounds());
742          // Limit the panning to what we care about.
743          plugin.map.setMaxBounds(plugin.getVisibleLayers().getBounds());
744          // Make sure the info pane is not too wide for the map.
745          var $legendPane = $('.selection-legend.leaflet-control');
746          var widthPadding = 20;
747          var maxWidth = $('#map').width() - widthPadding;
748          if ($legendPane.width() > maxWidth) {
749            $legendPane.width(maxWidth);
750          }
751          // Make sure the map is not too high.
752          var heightPadding = 75;
753          var minHeight = 400;
754          var maxHeight = $(window).height() - heightPadding;
755          if (maxHeight < minHeight) {
756            maxHeight = minHeight;
757          }
758          if ($('#map').height() > maxHeight) {
759            $('#map').height(maxHeight);
760          }
761        }, 500);
762      };
763    },
764
765    featureShouldDisplay: function(feature) {
766      var display = true;
767      display = display && typeof feature.properties.name !== 'undefined';
768      display = display && typeof feature.properties.geocode !== 'undefined';
769      display = display && typeof feature.properties.values !== 'undefined';
770      display = display && typeof feature.properties.disaggregations !== 'undefined';
771      return display;
772    },
773
774    featuresShouldDisplay: function(features) {
775      for (var i = 0; i < features.length; i++) {
776        if (this.featureShouldDisplay(features[i])) {
777          return true;
778        }
779      }
780      return false;
781    }
782  };
783
784  // A really lightweight plugin wrapper around the constructor,
785  // preventing against multiple instantiations
786  $.fn['sdgMap'] = function(options) {
787    return this.each(function() {
788      if (!$.data(this, 'plugin_sdgMap')) {
789        $.data(this, 'plugin_sdgMap', new Plugin(this, options));
790      }
791    });
792  };
793})(jQuery);
794// This "crops" the charts so that empty years are not displayed
795// at the beginning or end of each dataset. This ensures that the
796// chart will fill all the available space.
797Chart.register({
798  id: 'rescaler',
799  beforeInit: function (chart, options) {
800    chart.config.data.allLabels = chart.config.data.labels.slice(0);
801  },
802  afterDatasetsUpdate: function (chart) {
803    _.each(chart.data.datasets, function (ds) {
804      if (!ds.initialised) {
805        ds.initialised = true;
806        ds.allData = ds.data.slice(0);
807      }
808    });
809  },
810  afterUpdate: function (chart) {
811
812    // Ensure this only runs once.
813    if (chart.isScaleUpdate) {
814      chart.isScaleUpdate = false;
815      return;
816    }
817
818    // For each dataset, create an object showing the
819    // index of the minimum value and the index of the
820    // maximum value (not counting empty/null values).
821    var ranges = _.chain(chart.data.datasets).map('allData').map(function (data) {
822      return {
823        min: _.findIndex(data, function(val) { return val !== null }),
824        max: _.findLastIndex(data, function(val) { return val !== null })
825      };
826    }).value();
827
828    // Figure out the overal minimum and maximum
829    // considering all of the datasets.
830    var dataRange = ranges.length ? {
831      min: _.chain(ranges).map('min').min().value(),
832      max: _.chain(ranges).map('max').max().value()
833    } : undefined;
834
835    if (dataRange) {
836      // "Crop" the labels according to the min/max.
837      chart.data.labels = chart.data.allLabels.slice(dataRange.min, dataRange.max + 1);
838
839      // "Crop" the data of each dataset according to the min/max.
840      chart.data.datasets.forEach(function (dataset) {
841        dataset.data = dataset.allData.slice(dataRange.min, dataRange.max + 1);
842      });
843
844      chart.isScaleUpdate = true;
845      chart.update();
846    }
847  }
848});
849function getTextLinesOnCanvas(ctx, text, maxWidth) {
850  var words = text.split(" ");
851  var lines = [];
852  var currentLine = words[0];
853
854  for (var i = 1; i < words.length; i++) {
855      var word = words[i];
856      var width = ctx.measureText(currentLine + " " + word).width;
857      if (width < maxWidth) {
858          currentLine += " " + word;
859      } else {
860          lines.push(currentLine);
861          currentLine = word;
862      }
863  }
864  lines.push(currentLine);
865  return lines;
866}
867
868function isHighContrast(contrast) {
869  if (contrast) {
870      return contrast === 'high';
871  }
872  else {
873      return $('body').hasClass('contrast-high');
874  }
875}
876
877// This plugin displays a message to the user whenever a chart has no data.
878Chart.register({
879  id: 'open-sdg-no-data-message',
880  afterDraw: function(chart) {
881    if (chart.data.datasets.length === 0) {
882
883      var ctx = chart.ctx;
884      var width = chart.width;
885      var height = chart.height;
886
887      chart.clear();
888
889      ctx.save();
890      ctx.textAlign = 'center';
891      ctx.textBaseline = 'middle';
892      ctx.font = "normal 40px 'Open Sans', Helvetica, Arial, sans-serif";
893      ctx.fillStyle = (isHighContrast()) ? 'white' : 'black';
894      var lines = getTextLinesOnCanvas(ctx, translations.indicator.data_not_available, width);
895      var numLines = lines.length;
896      var lineHeight = 50;
897      var xLine = width / 2;
898      var yLine = (height / 2) - ((lineHeight / 2) * numLines);
899      for (var i = 0; i < numLines; i++) {
900        ctx.fillText(lines[i], xLine, yLine);
901        yLine += lineHeight;
902      }
903      ctx.restore();
904
905      $('#selectionsChart').addClass('chart-has-no-data');
906    }
907    else {
908      $('#selectionsChart').removeClass('chart-has-no-data');
909    }
910  }
911});
912// This plugin allows users to cycle through tooltips by keyboard.
913Chart.register({
914    id: 'open-sdg-accessible-charts',
915    afterInit: function(chart) {
916        var plugin = this;
917        plugin.chart = chart;
918        plugin.selectedIndex = -1;
919        plugin.currentDataset = 0;
920        plugin.setMeta();
921
922        if (!$(chart.canvas).data('keyboardNavInitialized')) {
923            $(chart.canvas).data('keyboardNavInitialized', true);
924            plugin.initElements();
925            chart.canvas.addEventListener('keydown', function(e) {
926                if (e.key === 'ArrowRight') {
927                    plugin.activateNext();
928                    e.preventDefault();
929                }
930                else if (e.key === 'ArrowLeft') {
931                    plugin.activatePrev();
932                    e.preventDefault();
933                }
934            });
935            chart.canvas.addEventListener('focus', function() {
936                if (plugin.selectedIndex === -1) {
937                    plugin.activateNext();
938                } else {
939                    plugin.activate();
940                }
941            });
942        }
943    },
944    afterUpdate: function(chart) {
945        this.setMeta();
946    },
947    setMeta: function() {
948        this.meta = this.chart.getDatasetMeta(this.currentDataset);
949    },
950    initElements: function() {
951        $('<span/>')
952            .addClass('sr-only')
953            .attr('id', 'chart-tooltip-status')
954            .attr('role', 'status')
955            .appendTo('#chart');
956        if (window.innerWidth <= 768) {
957            var mobileInstructions = translations.indicator.chart + '. ' + translations.indicator.data_tabular_alternative;
958            $(this.chart.canvas).html('<span class="hide-during-image-download">' + mobileInstructions + '</span>');
959        }
960        else {
961            var keyboardInstructions = translations.indicator.data_keyboard_navigation;
962            $('<span/>')
963                .css('display', 'none')
964                .attr('id', 'chart-keyboard')
965                .text(', ' + keyboardInstructions)
966                .appendTo('#chart');
967            var describedBy = $('#chart canvas').attr('aria-describedby');
968            $(this.chart.canvas)
969                .attr('role', 'application')
970                .attr('aria-describedby', 'chart-keyboard ' + describedBy)
971                .html('<span class="hide-during-image-download">Chart. ' + keyboardInstructions + '</span>')
972        }
973    },
974    activate: function() {
975        var activeElements = [];
976        if (this.chart.config.type === 'line') {
977            // For line charts, we combined all datasets into a single tooltip.
978            var numDatasets = this.chart.data.datasets.length;
979            for (var i = 0; i < numDatasets; i++) {
980                activeElements.push({datasetIndex: i, index: this.selectedIndex});
981            }
982        }
983        else {
984            activeElements.push({datasetIndex: this.currentDataset, index: this.selectedIndex});
985        }
986        this.chart.tooltip.setActiveElements(activeElements);
987        this.chart.render();
988        this.announceTooltips()
989    },
990    isSelectedIndexEmpty: function() {
991        var isEmpty = true;
992        if (this.chart.config.type === 'line') {
993            var numDatasets = this.chart.data.datasets.length;
994            for (var i = 0; i < numDatasets; i++) {
995                var dataset = this.chart.data.datasets[i],
996                    value = dataset.data[this.selectedIndex];
997                if (typeof value !== 'undefined') {
998                    isEmpty = false;
999                }
1000            }
1001        }
1002        else {
1003            var dataset = this.chart.data.datasets[this.currentDataset],
1004                value = dataset.data[this.selectedIndex];
1005            if (typeof value !== 'undefined') {
1006                isEmpty = false;
1007            }
1008        }
1009        return isEmpty;
1010    },
1011    activateNext: function() {
1012        // Abort early if no data.
1013        if (this.chart.data.datasets.length === 0) {
1014            return;
1015        }
1016        this.selectedIndex += 1;
1017        if (this.selectedIndex >= this.meta.data.length) {
1018            this.selectedIndex = 0;
1019            if (this.chart.config.type !== 'line') {
1020                this.nextDataset();
1021            }
1022        }
1023        while (this.isSelectedIndexEmpty()) {
1024            // Skip any empty years.
1025            this.activateNext();
1026            return;
1027        }
1028        this.activate();
1029    },
1030    activatePrev: function() {
1031        // Abort early if no data.
1032        if (this.chart.data.datasets.length === 0) {
1033            return;
1034        }
1035        this.selectedIndex -= 1;
1036        if (this.selectedIndex < 0) {
1037            if (this.chart.config.type !== 'line') {
1038                this.prevDataset();
1039            }
1040            this.selectedIndex = this.meta.data.length - 1;
1041        }
1042        while (this.isSelectedIndexEmpty()) {
1043            // Skip any empty years.
1044            this.activatePrev();
1045            return;
1046        }
1047        this.activate();
1048    },
1049    nextDataset: function() {
1050        var numDatasets = this.chart.data.datasets.length;
1051        this.currentDataset += 1;
1052        if (this.currentDataset >= numDatasets) {
1053            this.currentDataset = 0;
1054        }
1055        this.setMeta();
1056    },
1057    prevDataset: function() {
1058        var numDatasets = this.chart.data.datasets.length;
1059        this.currentDataset -= 1;
1060        if (this.currentDataset < 0) {
1061            this.currentDataset = numDatasets - 1;
1062        }
1063        this.setMeta();
1064    },
1065    announceTooltips: function() {
1066        var tooltips = this.chart.tooltip.getActiveElements();
1067        if (tooltips.length > 0) {
1068            var labels = {};
1069            for (var i = 0; i < tooltips.length; i++) {
1070                var datasetIndex = tooltips[i].datasetIndex,
1071                    pointIndex = tooltips[i].index,
1072                    year = this.chart.data.labels[pointIndex],
1073                    dataset = this.chart.data.datasets[datasetIndex],
1074                    label = dataset.label,
1075                    value = dataset.data[pointIndex],
1076                    observationAttributes = dataset.observationAttributes[pointIndex],
1077                    helpers = this.chart.config._config.indicatorViewHelpers;
1078
1079                if (observationAttributes && observationAttributes.length > 0) {
1080                    label += ', ' + observationAttributes.map(helpers.getObservationAttributeText).join(', ');
1081                }
1082
1083                if (typeof labels[year] === 'undefined') {
1084                    labels[year] = [];
1085                }
1086                labels[year].push(label + ': ' + value);
1087            }
1088            var announcement = '';
1089            Object.keys(labels).forEach(function(year) {
1090                announcement += year + ' ';
1091                labels[year].forEach(function(label) {
1092                    announcement += label + ', ';
1093                });
1094            });
1095            var currentAnnouncement = $('#chart-tooltip-status').text();
1096            if (currentAnnouncement != announcement) {
1097                $('#chart-tooltip-status').text(announcement);
1098            }
1099        }
1100    }
1101});
1102function event(sender) {
1103  this._sender = sender;
1104  this._listeners = [];
1105}
1106
1107event.prototype = {
1108  attach: function (listener) {
1109    this._listeners.push(listener);
1110  },
1111  notify: function (args) {
1112    var index;
1113
1114    for (index = 0; index < this._listeners.length; index += 1) {
1115      this._listeners[index](this._sender, args);
1116    }
1117  }
1118};
1119var accessibilitySwitcher = function () {
1120
1121    function getActiveContrast() {
1122        return $('body').hasClass('contrast-high') ? 'high' : 'default';
1123    }
1124
1125    function setHighContrast() {
1126        $('body')
1127            .removeClass('contrast-default')
1128            .addClass('contrast-high');
1129        var title = translations.header.disable_high_contrast;
1130        var gaAttributes = opensdg.autotrack('switch_contrast', 'Accessibility', 'Change contrast setting', 'default');
1131        $('[data-contrast-switch-to]')
1132            .attr('data-contrast-switch-to', 'default')
1133            .attr('title', title)
1134            .attr('aria-label', title)
1135            .attr(gaAttributes);
1136
1137        imageFix('high');
1138        createCookie('contrast', 'high', 365);
1139    }
1140
1141    function setDefaultContrast() {
1142        $('body')
1143            .removeClass('contrast-high')
1144            .addClass('contrast-default');
1145        var title = translations.header.enable_high_contrast;
1146        var gaAttributes = opensdg.autotrack('switch_contrast', 'Accessibility', 'Change contrast setting', 'high');
1147        $('[data-contrast-switch-to]')
1148            .attr('data-contrast-switch-to', 'high')
1149            .attr('title', title)
1150            .attr('aria-label', title)
1151            .attr(gaAttributes);
1152
1153        imageFix('default');
1154        createCookie('contrast', 'default', 365);
1155
1156    }
1157
1158    $('[data-contrast-switch-to]').click(function () {
1159        var newContrast = $(this).attr('data-contrast-switch-to');
1160        var oldContrast = getActiveContrast();
1161        if (newContrast === oldContrast) {
1162            return;
1163        }
1164        if (newContrast === 'high') {
1165            setHighContrast();
1166            broadcastContrastChange('high', this);
1167        }
1168        else {
1169            setDefaultContrast();
1170            broadcastContrastChange('default', this);
1171        }
1172
1173    });
1174
1175    function createCookie(name, value, days) {
1176        if (days) {
1177            var date = new Date();
1178            date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
1179            var expires = "; expires=" + date.toGMTString();
1180        }
1181        else expires = "";
1182        document.cookie = name + "=" + value + expires + "; path=/";
1183    }
1184
1185    function readCookie(name) {
1186        var nameEQ = name + "=";
1187        var ca = document.cookie.split(';');
1188        for (var i = 0; i < ca.length; i++) {
1189            var c = ca[i];
1190            while (c.charAt(0) == ' ') c = c.substring(1, c.length);
1191            if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
1192        }
1193        return null;
1194    }
1195
1196    function imageFix(contrast) {
1197        var doNotSwitchTheseSuffixes = ['.svg'];
1198        if (contrast == 'high') {
1199            _.each($('img:not([src*=high-contrast])'), function (image) {
1200                var src = $(image).attr('src').toLowerCase();
1201                var switchThisImage = true;
1202                for (var i = 0; i < doNotSwitchTheseSuffixes.length; i++) {
1203                    var suffix = doNotSwitchTheseSuffixes[i];
1204                    if (src.slice(0 - suffix.length) === suffix) {
1205                        switchThisImage = false;
1206                    }
1207                }
1208                if (switchThisImage) {
1209                    $(image).attr('src', $(image).attr('src').replace('img/', 'img/high-contrast/'));
1210                }
1211            });
1212        } else {
1213            // Remove high-contrast
1214            _.each($('img[src*=high-contrast]'), function (goalImage) {
1215                $(goalImage).attr('src', $(goalImage).attr('src').replace('high-contrast/', ''));
1216            })
1217        }
1218    };
1219
1220    function broadcastContrastChange(contrast, elem) {
1221        var event = new CustomEvent('contrastChange', {
1222            bubbles: true,
1223            detail: contrast
1224        });
1225        elem.dispatchEvent(event);
1226    }
1227
1228    window.onunload = function (e) {
1229        var contrast = getActiveContrast();
1230        createCookie('contrast', contrast, 365);
1231    }
1232
1233    var cookie = readCookie('contrast');
1234    var contrast = cookie ? cookie : 'default';
1235    if (contrast === 'high') {
1236        setHighContrast();
1237    }
1238    else {
1239        setDefaultContrast();
1240    }
1241
1242};
1243
1244// Dynamic aria labels on navbar toggle.
1245$(document).ready(function() {
1246    $('#navbarSupportedContent').on('shown.bs.collapse', function() {
1247        $('.navbar-toggler').attr('aria-label', translations.header.hide_menu);
1248    });
1249    $('#navbarSupportedContent').on('hidden.bs.collapse', function() {
1250        $('.navbar-toggler').attr('aria-label', translations.header.show_menu);
1251    });
1252});
1253opensdg.chartColors = function(indicatorId) {
1254  var colorSet = "accessible";
1255  var numberOfColors = 0;
1256  var customColorList = [];
1257
1258  this.goalNumber = parseInt(indicatorId.slice(indicatorId.indexOf('_')+1,indicatorId.indexOf('-')));
1259  this.goalColors = [['e5243b', '891523', 'ef7b89', '2d070b', 'f4a7b0', 'b71c2f', 'ea4f62', '5b0e17', 'fce9eb'],
1260                ['e5b735', '896d1f', 'efd385', '2d240a', 'f4e2ae', 'b7922a', 'eac55d', '5b4915', 'f9f0d6'],
1261                ['4c9f38', '2d5f21', '93c587', '0f1f0b', 'c9e2c3', '3c7f2c', '6fb25f', '1e3f16', 'a7d899'],
1262                ['c5192d', '760f1b', 'dc7581', '270509', 'f3d1d5', '9d1424', 'd04656', '4e0a12', 'e7a3ab'],
1263                ['ff3a21', 'b22817', 'ff7563', '330b06', 'ffd7d2', 'cc2e1a', 'ff614d', '7f1d10', 'ff9c90'],
1264                ['26bde2', '167187', '7cd7ed', '07252d', 'd3f1f9', '1e97b4', '51cae7', '0f4b5a', 'a8e4f3'],
1265                ['fcc30b', '977506', 'fddb6c', '322702', 'fef3ce', 'c99c08', 'fccf3b', '644e04', 'fde79d'],
1266                ['a21942', '610f27', 'c7758d', '610F28', 'ecd1d9', '811434', 'b44667', '400a1a', 'd9a3b3'],
1267                ['fd6925', '973f16', 'fda57c', '321507', 'fee1d3', 'ca541d', 'fd8750', '652a0e', 'fec3a7'],
1268                ['dd1367', '840b3d', 'ea71a3', '2c0314', 'f8cfe0', 'b00f52', 'd5358b', '580729', 'f1a0c2'],
1269                ['fd9d24', '653e0e', 'fed7a7', 'b16d19', 'fdba65', 'b14a1e', 'fd976b', '000000', 'fed2bf'],
1270                ['c9992d', '785b1b', 'dec181', '281e09', 'f4ead5', 'a07a24', 'd3ad56', '503d12', 'e9d6ab'],
1271                ['3f7e44', '254b28', '8bb18e', '0c190d', 'd8e5d9', '326436', '659769', '19321b', 'b2cbb4'],
1272                ['0a97d9', '065a82', '6cc0e8', '021e2b', 'ceeaf7', '0878ad', '3aabe0', '043c56', '9dd5ef'],
1273                ['56c02b', '337319', '99d97f', '112608', 'ddf2d4', '449922', '77cc55', '224c11', 'bbe5aa'],
1274                ['00689d', '00293e', '99c2d7', '00486d', '4c95ba', '126b80', 'cce0eb', '5a9fb0', 'a1c8d2'],
1275                ['19486a', '0a1c2a', '8ca3b4', '16377c', 'd1dae1', '11324a', '466c87', '5b73a3', '0f2656']];
1276  this.colorSets = {'classic':['7e984f', '8d73ca', 'aaa533', 'c65b8a', '4aac8d', 'c95f44'],
1277                  'sdg':['e5243b', 'dda63a', '4c9f38', 'c5192d', 'ff3a21', '26bde2', 'fcc30b', 'a21942', 'fd6925', 'dd1367','fd9d24','bf8b2e','3f7e44','0a97d9','56c02b','00689d','19486a'],
1278                  'goal': this.goalColors[this.goalNumber-1],
1279                  'custom': customColorList,
1280                  'accessible': ['cd7a00', '339966', '9966cc', '8d4d57', 'A33600', '054ce6']};
1281  if(Object.keys(this.colorSets).indexOf(colorSet) == -1 || (colorSet=='custom' && customColorList == null)){
1282    return this.colorSets['accessible'];
1283  }
1284  this.numberOfColors = (numberOfColors>this.colorSets[colorSet].length || numberOfColors == null || numberOfColors == 0) ? this.colorSets[colorSet].length : numberOfColors;
1285  this.colors = this.colorSets[colorSet].slice(0,this.numberOfColors);
1286
1287  return this.colors;
1288
1289};
1290var indicatorModel = function (options) {
1291
1292  var helpers = 
1293(function() {
1294
1295  /**
1296 * Constants to be used in indicatorModel.js and helper functions.
1297 */
1298var UNIT_COLUMN = 'UNIT_MEASURE';
1299var SERIES_COLUMN = 'SERIES';
1300var GEOCODE_COLUMN = 'GeoCode';
1301var YEAR_COLUMN = 'Year';
1302var VALUE_COLUMN = 'Value';
1303// Note this headline color is overridden in indicatorView.js.
1304var HEADLINE_COLOR = '#777777';
1305var GRAPH_TITLE_FROM_SERIES = false;
1306
1307  /**
1308 * Model helper functions with general utility.
1309 */
1310
1311/**
1312 * @param {string} prop Property to get unique values from
1313 * @param {Array} rows
1314 */
1315function getUniqueValuesByProperty(prop, rows) {
1316  var uniques = new Set();
1317  rows.forEach(function(row) {
1318    if (row[prop] != null) {
1319      uniques.add(row[prop])
1320    }
1321  });
1322  return Array.from(uniques);
1323}
1324
1325// Use as a callback to Array.prototype.filter to get unique elements
1326function isElementUniqueInArray(element, index, arr) {
1327  return arr.indexOf(element) === index;
1328}
1329
1330/**
1331 * @param {Array} columns
1332 * @return {boolean}
1333 */
1334function dataHasGeoCodes(columns) {
1335  return columns.includes(GEOCODE_COLUMN);
1336}
1337
1338/**
1339 * @param {Array} rows
1340 * @return {Array} Columns from first row
1341 */
1342function getColumnsFromData(rows) {
1343  return Object.keys(rows.reduce(function(result, obj) {
1344    return Object.assign(result, obj);
1345  }, {}));
1346}
1347
1348/**
1349 * @param {Array} columns
1350 * @return {Array} Columns without non-fields
1351 */
1352function getFieldColumnsFromData(columns) {
1353  var omitColumns = nonFieldColumns();
1354  return columns.filter(function(col) {
1355    return !omitColumns.includes(col);
1356  });
1357}
1358
1359/**
1360 * @return {Array} Data columns that have a special purpose
1361 *
1362 * All other data columns can be considered "field columns".
1363 */
1364function nonFieldColumns() {
1365  var columns = [
1366    YEAR_COLUMN,
1367    VALUE_COLUMN,
1368    UNIT_COLUMN,
1369    GEOCODE_COLUMN,
1370    'Observation status',
1371    'Unit multiplier',
1372    'Unit measure',
1373  ];
1374  var timeSeriesAttributes = [{"field":"COMMENT_TS","label":"footnote"},{"field":"DATA_LAST_UPDATE","label":"metadata_fields.national_data_update_url"}];
1375  if (timeSeriesAttributes && timeSeriesAttributes.length > 0) {
1376    timeSeriesAttributes.forEach(function(tsAttribute) {
1377      columns.push(tsAttribute.field);
1378    });
1379  }
1380  var observationAttributes = [];
1381  if (observationAttributes && observationAttributes.length > 0) {
1382    observationAttributes.forEach(function(oAttribute) {
1383      columns.push(oAttribute.field);
1384    });
1385  }
1386  columns.push(SERIES_COLUMN);
1387  return columns;
1388}
1389
1390/**
1391 * @param {Array} items Objects optionally containing 'unit' and/or 'series'
1392 * @param {String} selectedUnit
1393 * @param {String} selectedSeries
1394 * @return {object|false} The first match given the selected unit/series, or false
1395 */
1396function getMatchByUnitSeries(items, selectedUnit, selectedSeries) {
1397  var matches = getMatchesByUnitSeries(items, selectedUnit, selectedSeries);
1398  return (matches.length > 0) ? matches[0] : false;
1399}
1400
1401/**
1402 * @param {Array} items Objects optionally containing 'unit' and/or 'series'
1403 * @param {String} selectedUnit
1404 * @param {String} selectedSeries
1405 * @return {Array} All matches given the selected unit/series, if any.
1406 */
1407function getMatchesByUnitSeries(items, selectedUnit, selectedSeries) {
1408  if (!items || items.length === 0) {
1409    return [];
1410  }
1411  if (!selectedUnit && !selectedSeries) {
1412    return items;
1413  }
1414  // First pass to find any exact matches.
1415  var matches = items.filter(function(item) {
1416    var seriesMatch = item.series === selectedSeries,
1417        unitMatch = item.unit === selectedUnit;
1418    if (selectedUnit && selectedSeries) {
1419      return seriesMatch && unitMatch;
1420    }
1421    else if (selectedUnit) {
1422      return unitMatch;
1423    }
1424    else if (selectedSeries) {
1425      return seriesMatch;
1426    }
1427  });
1428  // Second pass to find any partial matches with unspecified unit/series.
1429  if (matches.length === 0) {
1430    matches = items.filter(function(item) {
1431      var seriesMatch = item.series === selectedSeries && item.series && !item.unit,
1432          unitMatch = item.unit === selectedUnit && item.unit && !item.series;
1433      if (selectedUnit && selectedSeries) {
1434        return seriesMatch || unitMatch;
1435      }
1436      else if (selectedUnit) {
1437        return unitMatch;
1438      }
1439      else if (selectedSeries) {
1440        return seriesMatch;
1441      }
1442    });
1443  }
1444  // Third pass to catch cases where nothing at all was specified.
1445  if (matches.length === 0) {
1446    matches = items.filter(function(item) {
1447      var nothingSpecified = !item.unit && !item.series;
1448      return nothingSpecified;
1449    });
1450  }
1451  return matches;
1452}
1453
1454  /**
1455 * Model helper functions related to units.
1456 */
1457
1458/**
1459 * @param {Array} rows
1460 * @return {boolean}
1461 */
1462function dataHasUnits(columns) {
1463  return columns.includes(UNIT_COLUMN);
1464}
1465
1466/**
1467 * @param {Array} fieldsUsedByUnit Field names
1468 * @return {boolean}
1469 */
1470function dataHasUnitSpecificFields(fieldsUsedByUnit) {
1471  return !_.every(_.map(fieldsUsedByUnit, 'fields'), function(fields) {
1472    return _.isEqual(_.sortBy(_.map(fieldsUsedByUnit, 'fields')[0]), _.sortBy(fields));
1473  });
1474}
1475
1476/**
1477 * @param {Array} units
1478 * @param {Array} rows
1479 * @return {Array} Field names
1480 */
1481function fieldsUsedByUnit(units, rows, columns) {
1482  var fields = getFieldColumnsFromData(columns);
1483  return units.map(function(unit) {
1484    return {
1485      unit: unit,
1486      fields: fields.filter(function(field) {
1487        return fieldIsUsedInDataWithUnit(field, unit, rows);
1488      }, this),
1489    }
1490  }, this);
1491}
1492
1493/**
1494 * @param {string} field
1495 * @param {string} unit
1496 * @param {Array} rows
1497 */
1498function fieldIsUsedInDataWithUnit(field, unit, rows) {
1499  return rows.some(function(row) {
1500    return row[field] && row[UNIT_COLUMN] === unit;
1501  }, this);
1502}
1503
1504/**
1505 * @param {Array} rows
1506 * @param {string} unit
1507 * @return {Array} Rows
1508 */
1509function getDataByUnit(rows, unit) {
1510  return rows.filter(function(row) {
1511    return row[UNIT_COLUMN] === unit;
1512  }, this);
1513}
1514
1515/**
1516 * @param {Array} rows
1517 * @return {string}
1518 */
1519function getFirstUnitInData(rows) {
1520  return rows.find(function(row) {
1521    return row[UNIT_COLUMN];
1522  }, this)[UNIT_COLUMN];
1523}
1524
1525/**
1526 * @param {Array} startValues Objects containing 'field' and 'value'
1527 * @return {string|boolean} Unit, or false if none were found
1528 */
1529function getUnitFromStartValues(startValues) {
1530  var match = startValues.find(function(startValue) {
1531    return startValue.field === UNIT_COLUMN;
1532  }, this);
1533  return (match) ? match.value : false;
1534}
1535
1536  /**
1537 * Model helper functions related to serieses.
1538 */
1539
1540/**
1541 * @param {Array} columns
1542 * @return {boolean}
1543 */
1544function dataHasSerieses(columns) {
1545  return columns.includes(SERIES_COLUMN);
1546}
1547
1548/**
1549 * @param {Array} fieldsUsedBySeries Field names
1550 * @return {boolean}
1551 */
1552function dataHasSeriesSpecificFields(fieldsUsedBySeries) {
1553  return !_.every(_.map(fieldsUsedBySeries, 'fields'), function(fields) {
1554    return _.isEqual(_.sortBy(_.map(fieldsUsedBySeries, 'fields')[0]), _.sortBy(fields));
1555  });
1556}
1557
1558/**
1559 * @param {Array} serieses
1560 * @param {Array} rows
1561 * @return {Array} Field names
1562 */
1563function fieldsUsedBySeries(serieses, rows, columns) {
1564  var fields = getFieldColumnsFromData(columns);
1565  return serieses.map(function(series) {
1566    return {
1567      series: series,
1568      fields: fields.filter(function(field) {
1569        return fieldIsUsedInDataWithSeries(field, series, rows);
1570      }, this),
1571    }
1572  }, this);
1573}
1574
1575/**
1576 * @param {string} field
1577 * @param {string} series
1578 * @param {Array} rows
1579 */
1580function fieldIsUsedInDataWithSeries(field, series, rows) {
1581  return rows.some(function(row) {
1582    return row[field] && row[SERIES_COLUMN] === series;
1583  }, this);
1584}
1585
1586/**
1587 * @param {Array} rows
1588 * @param {string} series
1589 * @return {Array} Rows
1590 */
1591function getDataBySeries(rows, series) {
1592  return rows.filter(function(row) {
1593    return row[SERIES_COLUMN] === series;
1594  }, this);
1595}
1596
1597/**
1598 * @param {Array} rows
1599 * @return {string}
1600 */
1601function getFirstSeriesInData(rows) {
1602  return rows.find(function(row) {
1603    return row[SERIES_COLUMN];
1604  }, this)[SERIES_COLUMN];
1605}
1606
1607/**
1608 * @param {Array} startValues Objects containing 'field' and 'value'
1609 * @return {string|boolean} Series, or false if none were found
1610 */
1611function getSeriesFromStartValues(startValues) {
1612  var match = startValues.find(function(startValue) {
1613    return startValue.field === SERIES_COLUMN;
1614  }, this);
1615  return (match) ? match.value : false;
1616}
1617
1618  /**
1619 * Model helper functions related to fields and data.
1620 */
1621
1622/**
1623 * @param {Array} rows
1624 * @param {Array} edges
1625 * @return {Array} Field item states
1626 */
1627
1628function getInitialFieldItemStates(rows, edges, columns, dataSchema) {
1629  var fields = getFieldColumnsFromData(columns);
1630  sortFieldNames(fields, dataSchema);
1631  var initial = fields.map(function(field) {
1632    var values = getUniqueValuesByProperty(field, rows);
1633    sortFieldValueNames(field, values, dataSchema);
1634    return {
1635      field: field,
1636      hasData: true,
1637      values: values.map(function(value) {
1638        return {
1639          value: value,
1640          state: 'default',
1641          checked: false,
1642          hasData: true
1643        };
1644      }, this),
1645    };
1646  }, this);
1647
1648  return sortFieldItemStates(initial, edges, dataSchema);
1649}
1650
1651/**
1652 * @param {Array} fieldItemStates
1653 * @param {Array} edges
1654 * return {Array} Sorted field item states
1655 */
1656function sortFieldItemStates(fieldItemStates, edges, dataSchema) {
1657  if (edges.length > 0) {
1658    var froms = getUniqueValuesByProperty('From', edges).sort();
1659    var tos = getUniqueValuesByProperty('To', edges).sort();
1660    var orderedEdges = froms.concat(tos);
1661    var fieldsNotInEdges = fieldItemStates
1662      .map(function(fis) { return fis.field; })
1663      .filter(function(field) { return !orderedEdges.includes(field); });
1664    var customOrder = orderedEdges.concat(fieldsNotInEdges);
1665    sortFieldNames(customOrder, dataSchema);
1666
1667    return _.sortBy(fieldItemStates, function(item) {
1668      return customOrder.indexOf(item.field);
1669    });
1670  }
1671  return fieldItemStates;
1672}
1673
1674/**
1675 * @param {Array} fieldItemStates
1676 * @param {Array} edges
1677 * @param {Array} selectedFields Field items
1678 * @param {Object} validParentsByChild Arrays of parents keyed to children
1679 * @return {Array} Field item states
1680 */
1681function getUpdatedFieldItemStates(fieldItemStates, edges, selectedFields, validParentsByChild) {
1682  var selectedFieldNames = getFieldNames(selectedFields);
1683  getParentFieldNames(edges).forEach(function(parentFieldName) {
1684    if (selectedFieldNames.includes(parentFieldName)) {
1685      var childFieldNames = getChildFieldNamesByParent(edges, parentFieldName);
1686      var selectedParent = selectedFields.find(function(selectedField) {
1687        return selectedField.field === parentFieldName;
1688      }, this);
1689      fieldItemStates.forEach(function(fieldItem) {
1690        if (childFieldNames.includes(fieldItem.field)) {
1691          var fieldHasData = false;
1692          fieldItem.values.forEach(function(childValue) {
1693            var valueHasData = false;
1694            selectedParent.values.forEach(function(parentValue) {
1695              if (validParentsByChild[fieldItem.field][childValue.value].includes(parentValue)) {
1696                valueHasData = true;
1697                fieldHasData = true;
1698              }
1699            }, this);
1700            childValue.hasData = valueHasData;
1701          }, this);
1702          fieldItem.hasData = fieldHasData;
1703        }
1704      }, this);
1705    }
1706  }, this);
1707  return fieldItemStates;
1708}
1709
1710/**
1711 * @param {Array} fieldItems
1712 * @return {Array} Field names
1713 */
1714function getFieldNames(fieldItems) {
1715  return fieldItems.map(function(item) { return item.field; });
1716}
1717
1718/**
1719 * @param {Array} edges
1720 * @return {Array} Names of parent fields
1721 */
1722function getParentFieldNames(edges) {
1723  return edges.map(function(edge) { return edge.From; });
1724}
1725
1726/**
1727 * @param {Array} edges
1728 * @param {string} parent
1729 * @return {Array} Children of parent
1730 */
1731function getChildFieldNamesByParent(edges, parent) {
1732  var children = edges.filter(function(edge) {
1733    return edge.From === parent;
1734  });
1735  return getChildFieldNames(children);
1736}
1737
1738/**
1739 * @param {Array} edges
1740 * @return {Array} Names of child fields
1741 */
1742function getChildFieldNames(edges) {
1743  return edges.map(function(edge) { return edge.To; });
1744}
1745
1746/**
1747 * @param {Array} fieldItemStates
1748 * @param {Array} fieldsByUnit Objects containing 'unit' and 'fields'
1749 * @param {string} selectedUnit
1750 * @param {boolean} dataHasUnitSpecificFields
1751 * @param {Array} fieldsBySeries Objects containing 'series' and 'fields'
1752 * @param {string} selectedSeries
1753 * @param {boolean} dataHasSeriesSpecificFields
1754 * @param {Array} selectedFields Field items
1755 * @param {Array} edges
1756 * @param {string} compositeBreakdownLabel Alternate label for COMPOSITE_BREAKDOWN fields
1757 * @return {Array} Field item states (with additional "label" properties)
1758 */
1759function fieldItemStatesForView(fieldItemStates, fieldsByUnit, selectedUnit, dataHasUnitSpecificFields, fieldsBySeries, selectedSeries, dataHasSeriesSpecificFields, selectedFields, edges, compositeBreakdownLabel) {
1760  var states = fieldItemStates.map(function(item) { return item; });
1761  if (dataHasUnitSpecificFields && dataHasSeriesSpecificFields) {
1762    states = fieldItemStatesForSeries(fieldItemStates, fieldsBySeries, selectedSeries);
1763    states = fieldItemStatesForUnit(states, fieldsByUnit, selectedUnit);
1764  }
1765  else if (dataHasSeriesSpecificFields) {
1766    states = fieldItemStatesForSeries(fieldItemStates, fieldsBySeries, selectedSeries);
1767  }
1768  else if (dataHasUnitSpecificFields) {
1769    states = fieldItemStatesForUnit(fieldItemStates, fieldsByUnit, selectedUnit);
1770  }
1771  // Set all values to checked=false because they are going to be
1772  // conditionally set to true below, if needed.
1773  states.forEach(function(fieldItem) {
1774    fieldItem.values.forEach(function(defaultFieldItemValue) {
1775      defaultFieldItemValue.checked = false;
1776    });
1777  });
1778  if (selectedFields && selectedFields.length > 0) {
1779    states.forEach(function(fieldItem) {
1780      var selectedField = selectedFields.find(function(selectedItem) {
1781        return selectedItem.field === fieldItem.field;
1782      });
1783      if (selectedField) {
1784        selectedField.values.forEach(function(selectedValue) {
1785          var fieldItemValue = fieldItem.values.find(function(valueItem) {
1786            return valueItem.value === selectedValue;
1787          });
1788          if (fieldItemValue) {
1789            fieldItemValue.checked = true;
1790          }
1791        })
1792      }
1793    });
1794  }
1795  sortFieldsForView(states, edges);
1796  return states.map(function(item) {
1797    item.label = item.field;
1798    if (item.field === 'COMPOSITE_BREAKDOWN' && compositeBreakdownLabel !== '') {
1799      item.label = compositeBreakdownLabel;
1800    }
1801    return item;
1802  });
1803}
1804
1805/**
1806 * @param {Array} fieldItemStates
1807 * @param {Array} edges
1808 */
1809function sortFieldsForView(fieldItemStates, edges) {
1810  if (edges.length > 0 && fieldItemStates.length > 0) {
1811
1812    var parents = edges.map(function(edge) { return edge.From; });
1813    var children = edges.map(function(edge) { return edge.To; });
1814    var topLevelParents = [];
1815    parents.forEach(function(parent) {
1816      if (!(children.includes(parent)) && !(topLevelParents.includes(parent))) {
1817        topLevelParents.push(parent);
1818      }
1819    });
1820
1821    var topLevelParentsByChild = {};
1822    children.forEach(function(child) {
1823      var currentParent = edges.find(function(edge) { return edge.To === child; }),
1824          currentChild = child;
1825      while (currentParent) {
1826        currentParent = edges.find(function(edge) { return edge.To === currentChild; });
1827        if (currentParent) {
1828          currentChild = currentParent.From;
1829          topLevelParentsByChild[child] = currentParent.From;
1830        }
1831      }
1832    });
1833    fieldItemStates.forEach(function(fieldItem) {
1834      if (topLevelParents.includes(fieldItem.field) || typeof topLevelParentsByChild[fieldItem.field] === 'undefined') {
1835        fieldItem.topLevelParent = '';
1836      }
1837      else {
1838        fieldItem.topLevelParent = topLevelParentsByChild[fieldItem.field];
1839      }
1840    });
1841
1842    // As an intermediary step, create a hierarchical structure grouped
1843    // by the top-level parent.
1844    var tempHierarchy = [];
1845    var tempHierarchyHash = {};
1846    fieldItemStates.forEach(function(fieldItem) {
1847      if (fieldItem.topLevelParent === '') {
1848        fieldItem.children = [];
1849        tempHierarchyHash[fieldItem.field] = fieldItem;
1850        tempHierarchy.push(fieldItem);
1851      }
1852    });
1853    fieldItemStates.forEach(function(fieldItem) {
1854      if (typeof tempHierarchyHash[fieldItem.topLevelParent] === 'undefined') {
1855        return;
1856      }
1857      if (fieldItem.topLevelParent !== '') {
1858        tempHierarchyHash[fieldItem.topLevelParent].children.push(fieldItem);
1859      }
1860    });
1861
1862    // Now we clear out the field items and add them back as a flat list.
1863    fieldItemStates.length = 0;
1864    tempHierarchy.forEach(function(fieldItem) {
1865      fieldItemStates.push(fieldItem);
1866      fieldItem.children.forEach(function(child) {
1867        fieldItemStates.push(child);
1868      });
1869    });
1870  }
1871}
1872
1873/**
1874 * @param {Array} fieldItemStates
1875 * @param {Array} fieldsByUnit Objects containing 'unit' and 'fields'
1876 * @param {string} selectedUnit
1877 * @return {Array} Field item states
1878 */
1879function fieldItemStatesForUnit(fieldItemStates, fieldsByUnit, selectedUnit) {
1880  var fieldsBySelectedUnit = fieldsByUnit.filter(function(fieldByUnit) {
1881    return fieldByUnit.unit === selectedUnit;
1882  })[0];
1883  return fieldItemStates.filter(function(fis) {
1884    return fieldsBySelectedUnit.fields.includes(fis.field);
1885  });
1886}
1887
1888/**
1889 * @param {Array} fieldItemStates
1890 * @param {Array} fieldsBySeries Objects containing 'series' and 'fields'
1891 * @param {string} selectedSeries
1892 * @return {Array} Field item states
1893 */
1894function fieldItemStatesForSeries(fieldItemStates, fieldsBySeries, selectedSeries) {
1895  var fieldsBySelectedSeries = fieldsBySeries.filter(function(fieldBySeries
1895) {
1896    return fieldBySeries.series === selectedSeries;
1897  })[0];
1898  return fieldItemStates.filter(function(fis) {
1899    return fieldsBySelectedSeries.fields.includes(fis.field);
1900  });
1901}
1902
1903/**
1904 * @param {Array} fieldItems
1905 * @return {Array} Objects representing disaggregation combinations
1906 */
1907function getCombinationData(fieldItems) {
1908
1909  // First get a list of all the single field/value pairs.
1910  var fieldValuePairs = [];
1911  fieldItems.forEach(function(fieldItem) {
1912    fieldItem.values.forEach(function(value) {
1913      var pair = {};
1914      pair[fieldItem.field] = value;
1915      fieldValuePairs.push(pair);
1916    });
1917  });
1918
1919  // Generate all possible subsets of these key/value pairs.
1920  var powerset = [];
1921  // Start off with an empty item.
1922  powerset.push([]);
1923  for (var i = 0; i < fieldValuePairs.length; i++) {
1924    for (var j = 0, len = powerset.length; j < len; j++) {
1925      var candidate = powerset[j].concat(fieldValuePairs[i]);
1926      if (!hasDuplicateField(candidate)) {
1927        powerset.push(candidate);
1928      }
1929    }
1930  }
1931
1932  function hasDuplicateField(pairs) {
1933    var fields = [], i;
1934    for (i = 0; i < pairs.length; i++) {
1935      var field = Object.keys(pairs[i])[0]
1936      if (fields.includes(field)) {
1937        return true;
1938      }
1939      else {
1940        fields.push(field);
1941      }
1942    }
1943    return false;
1944  }
1945
1946  // Remove the empty item.
1947  powerset.shift();
1948
1949  return powerset.map(function(combinations) {
1950    // We want to merge these into a single object.
1951    var combinedSubset = {};
1952    combinations.forEach(function(keyValue) {
1953      Object.assign(combinedSubset, keyValue);
1954    });
1955    return combinedSubset;
1956  });
1957}
1958
1959/**
1960 * @param {Array} startValues Objects containing 'field' and 'value'
1961 * @param {Array} selectableFieldNames
1962 * @return {Array} Field items
1963 */
1964function selectFieldsFromStartValues(startValues, selectableFieldNames) {
1965  if (!startValues) {
1966    return [];
1967  }
1968  var allowedStartValues = startValues.filter(function(startValue) {
1969    var normalField = !nonFieldColumns().includes(startValue.field);
1970    var allowedField = selectableFieldNames.includes(startValue.field)
1971    return normalField && allowedField;
1972  });
1973  var valuesByField = {};
1974  allowedStartValues.forEach(function(startValue) {
1975    if (!(startValue.field in valuesByField)) {
1976      valuesByField[startValue.field] = [];
1977    }
1978    valuesByField[startValue.field].push(startValue.value);
1979  });
1980  return Object.keys(valuesByField).map(function(field) {
1981    return {
1982      field: field,
1983      values: _.uniq(valuesByField[field]),
1984    };
1985  });
1986}
1987
1988/**
1989 * @param {Array} rows
1990 * @param {Array} selectableFieldNames Field names
1991 * @param {string} selectedUnit
1992 * @return {Array} Field items
1993 */
1994function selectMinimumStartingFields(rows, selectableFieldNames, selectedUnit) {
1995  var filteredData = rows;
1996  if (selectedUnit) {
1997    filteredData = filteredData.filter(function(row) {
1998      return row[UNIT_COLUMN] === selectedUnit;
1999    });
2000  }
2001  filteredData = filteredData.filter(function(row) {
2002    return selectableFieldNames.some(function(fieldName) {
2003      return row[fieldName];
2004    });
2005  });
2006  // Sort the data by each field. We go in reverse order so that the
2007  // first field will be highest "priority" in the sort.
2008  selectableFieldNames.reverse().forEach(function(fieldName) {
2009    filteredData = _.sortBy(filteredData, fieldName);
2010  });
2011  // But actually we want the top-priority sort to be the "size" of the
2012  // rows. In other words we want the row with the fewest number of fields.
2013  filteredData = _.sortBy(filteredData, function(row) { return Object.keys(row).length; });
2014
2015  if (filteredData.length === 0) {
2016    return [];
2017  }
2018
2019  // Convert to an array of objects with 'field' and 'values' keys, omitting
2020  // any non-field columns.
2021  return Object.keys(filteredData[0]).filter(function(key) {
2022    return !nonFieldColumns().includes(key);
2023  }).map(function(field) {
2024    return {
2025      field: field,
2026      values: [filteredData[0][field]]
2027    };
2028  });
2029}
2030
2031/**
2032 * @param {Array} edges
2033 * @param {Array} fieldItemStates
2034 * @param {Array} rows
2035 * @return {Object} Arrays of parents keyed to children
2036 *
2037 * @TODO: This function can be a bottleneck in large datasets with a lot of
2038 * disaggregation values. Can this be further optimized?
2039 */
2040function validParentsByChild(edges, fieldItemStates, rows) {
2041  var parentFields = getParentFieldNames(edges);
2042  var childFields = getChildFieldNames(edges);
2043  var validParentsByChild = {};
2044  childFields.forEach(function(childField, fieldIndex) {
2045    var fieldItemState = fieldItemStates.find(function(fis) {
2046      return fis.field === childField;
2047    });
2048    if (typeof fieldItemState === 'undefined') {
2049      return;
2050    }
2051    var childValues = fieldItemState.values.map(function(value) {
2052      return value.value;
2053    });
2054    var parentField = parentFields[fieldIndex];
2055    var childRows = rows.filter(function(row) {
2056      var childNotEmpty = row[childField];
2057      var parentNotEmpty = row[parentField];
2058      return childNotEmpty && parentNotEmpty;
2059    })
2060    validParentsByChild[childField] = {};
2061    childValues.forEach(function(childValue) {
2062      var rowsWithParentValues = childRows.filter(function(row) {
2063        return row[childField] == childValue;
2064      });
2065      validParentsByChild[childField][childValue] = getUniqueValuesByProperty(parentField, rowsWithParentValues);
2066    });
2067  });
2068  return validParentsByChild;
2069}
2070
2071/**
2072 * @param {Array} selectableFields Field names
2073 * @param {Array} edges
2074 * @param {Array} selectedFields Field items
2075 * @return {Array} Field names
2076 */
2077function getAllowedFieldsWithChildren(selectableFields, edges, selectedFields) {
2078  var allowedFields = getInitialAllowedFields(selectableFields, edges);
2079  var selectedFieldNames = getFieldNames(selectedFields);
2080  getParentFieldNames(edges).forEach(function(parentFieldName) {
2081    if (selectedFieldNames.includes(parentFieldName)) {
2082      var childFieldNames = getChildFieldNamesByParent(edges, parentFieldName);
2083      allowedFields = allowedFields.concat(childFieldNames);
2084    }
2085  }, this);
2086  return allowedFields.filter(isElementUniqueInArray);
2087}
2088
2089/**
2090 *
2091 * @param {Array} fieldNames
2092 * @param {Array} edges
2093 * @return {Array} Field names
2094 */
2095function getInitialAllowedFields(fieldNames, edges) {
2096  var children = getChildFieldNames(edges);
2097  return fieldNames.filter(function(field) { return !children.includes(field); });
2098}
2099
2100/**
2101 * @param {Array} selectedFields Field names
2102 * @param {Array} edges
2103 * @return {Array} Selected fields without orphans
2104 */
2105function removeOrphanSelections(selectedFields, edges) {
2106  var selectedFieldNames = selectedFields.map(function(selectedField) {
2107    return selectedField.field;
2108  });
2109  edges.forEach(function(edge) {
2110    if (!selectedFieldNames.includes(edge.From)) {
2111      selectedFields = selectedFields.filter(function(selectedField) {
2112        return selectedField.field !== edge.From;
2113      });
2114    }
2115  });
2116  return selectedFields;
2117}
2118
2119/**
2120 * @param {Array} rows
2121 * @param {Array} selectedFields Field items
2122 * @return {Array} Rows
2123 */
2124function getDataBySelectedFields(rows, selectedFields) {
2125  return rows.filter(function(row) {
2126    return selectedFields.some(function(field) {
2127      return field.values.includes(row[field.field]);
2128    });
2129  });
2130}
2131
2132/**
2133 * @param {Array} rows
2134 * @param {Array} selectedFields Field items
2135 * @return {Array} Rows
2136 */
2137function hasDataBySelectedFields(rows, selectedFields) {
2138  return rows.some(function(row) {
2139    return selectedFields.some(function(field) {
2140      return field.values.includes(row[field.field]);
2141    });
2142  });
2143}
2144
2145/**
2146 * @param {Array} fieldNames
2147 * @param {Object} dataSchema
2148 */
2149function sortFieldNames(fieldNames, dataSchema) {
2150  if (dataSchema && dataSchema.fields) {
2151    var schemaFieldNames = dataSchema.fields.map(function(field) { return field.name; });
2152    // If field names have been translated, we may need to use titles.
2153    if (schemaFieldNames.length > 0 && !(fieldNames.includes(schemaFieldNames[0]))) {
2154      schemaFieldNames = dataSchema.fields.map(function(field) { return field.title; });
2155    }
2156    fieldNames.sort(function(a, b) {
2157      return schemaFieldNames.indexOf(a) - schemaFieldNames.indexOf(b);
2158    });
2159  }
2160  else {
2161    fieldNames.sort();
2162  }
2163}
2164
2165/**
2166 * @param {string} fieldName
2167 * @param {Array} fieldValues
2168 * @param {Object} dataSchema
2169 */
2170function sortFieldValueNames(fieldName, fieldValues, dataSchema) {
2171  if (dataSchema && dataSchema.fields) {
2172    var fieldSchema = dataSchema.fields.find(function(x) { return x.name == fieldName; });
2173    // If field names have been translated, we may need to use titles.
2174    if (!fieldSchema) {
2175      fieldSchema = dataSchema.fields.find(function(x) { return x.title == fieldName; });
2176    }
2177    if (fieldSchema && fieldSchema.constraints && fieldSchema.constraints.enum) {
2178      fieldValues.sort(function(a, b) {
2179        return fieldSchema.constraints.enum.indexOf(a) - fieldSchema.constraints.enum.indexOf(b);
2180      });
2181    }
2182    else {
2183      fieldValues.sort();
2184    }
2185  }
2186  else {
2187    fieldValues.sort();
2188  }
2189}
2190
2191  /**
2192 * Model helper functions related to charts and datasets.
2193 */
2194
2195/**
2196 * @param {string} currentTitle
2197 * @param {Array} allTitles Objects containing 'unit' and 'title'
2198 * @param {String} selectedUnit
2199 * @param {String} selectedSeries
2200 * @return {String} Updated title
2201 */
2202function getChartTitle(currentTitle, allTitles, selectedUnit, selectedSeries) {
2203  var match = getMatchByUnitSeries(allTitles, selectedUnit, selectedSeries);
2204  return (match) ? match.title : currentTitle;
2205}
2206
2207/**
2208 * @param {string} currentType
2209 * @param {Array} allTypes Objects containing 'unit', 'series', and 'type'
2210 * @param {String} selectedUnit
2211 * @param {String} selectedSeries
2212 * @return {String} Updated type
2213 */
2214function getChartType(currentType, allTypes, selectedUnit, selectedSeries) {
2215  if (!currentType) {
2216    currentType = 'line';
2217  }
2218  var match = getMatchByUnitSeries(allTypes, selectedUnit, selectedSeries);
2219  return (match) ? match.type : currentType;
2220}
2221
2222/**
2223 * @param {Array} graphLimits Objects containing 'unit' and 'title'
2224 * @param {String} selectedUnit
2225 * @param {String} selectedSeries
2226 * @return {Object|false} Graph limit object, if any
2227 */
2228function getGraphLimits(graphLimits, selectedUnit, selectedSeries) {
2229  return getMatchByUnitSeries(graphLimits, selectedUnit, selectedSeries);
2230}
2231
2232/**
2233 * @param {Array} graphAnnotations Objects containing 'unit' or 'series' or more
2234 * @param {String} selectedUnit
2235 * @param {String} selectedSeries
2236 * @return {Array} Graph annotations objects, if any
2237 */
2238function getGraphAnnotations(graphAnnotations, selectedUnit, selectedSeries, graphTargetLines, graphSeriesBreaks) {
2239  var annotations = getMatchesByUnitSeries(graphAnnotations, selectedUnit, selectedSeries);
2240  if (graphTargetLines) {
2241    annotations = annotations.concat(getGraphTargetLines(graphTargetLines, selectedUnit, selectedSeries
2241));
2242  }
2243  if (graphSeriesBreaks) {
2244    annotations = annotations.concat(getGraphSeriesBreaks(graphSeriesBreaks, selectedUnit, selectedSeries));
2245  }
2246  return annotations;
2247}
2248
2249/**
2250 * @param {Array} graphTargetLines Objects containing 'unit' or 'series' or more
2251 * @param {String} selectedUnit
2252 * @param {String} selectedSeries
2253 * @return {Array} Graph annotations objects, if any
2254 */
2255function getGraphTargetLines(graphTargetLines, selectedUnit, selectedSeries) {
2256  return getMatchesByUnitSeries(graphTargetLines, selectedUnit, selectedSeries).map(function(targetLine) {
2257    targetLine.preset = 'target_line';
2258    targetLine.label = { content: targetLine.label_content };
2259    return targetLine;
2260  });
2261
2262}
2263
2264/**
2265 * @param {Array} graphSeriesBreaks Objects containing 'unit' or 'series' or more
2266 * @param {String} selectedUnit
2267 * @param {String} selectedSeries
2268 * @return {Array} Graph annotations objects, if any
2269 */
2270function getGraphSeriesBreaks(graphSeriesBreaks, selectedUnit, selectedSeries) {
2271  return getMatchesByUnitSeries(graphSeriesBreaks, selectedUnit, selectedSeries).map(function(seriesBreak) {
2272    seriesBreak.preset = 'series_break';
2273    seriesBreak.label = { content: seriesBreak.label_content };
2274    return seriesBreak;
2275  });
2276}
2277
2278/**
2279 * @param {Array} headline Rows
2280 * @param {Array} rows
2281 * @param {Array} combinations Objects representing disaggregation combinations
2282 * @param {Array} years
2283 * @param {string} defaultLabel
2284 * @param {Array} colors
2285 * @param {Array} selectableFields Field names
2286 * @param {Array} colorAssignments Color/striping assignments for disaggregation combinations
2287 * @return {Array} Datasets suitable for Chart.js
2288 */
2289function getDatasets(headline, data, combinations, years, defaultLabel, colors, selectableFields, colorAssignments, allObservationAttributes) {
2290  var datasets = [], index = 0, dataset, colorIndex, color, background, border, striped, excess, combinationKey, colorAssignment;
2291  var numColors = colors.length,
2292      maxColorAssignments = numColors * 2;
2293
2294  prepareColorAssignments(colorAssignments, maxColorAssignments);
2295  setAllColorAssignmentsReadyForEviction(colorAssignments);
2296
2297  combinations.forEach(function(combination) {
2298    var filteredData = getDataMatchingCombination(data, combination, selectableFields);
2299    if (filteredData.length > 0) {
2300      excess = (index >= maxColorAssignments);
2301      if (excess) {
2302        // This doesn't really matter: excess datasets won't be displayed.
2303        color = getHeadlineColor();
2304        striped = false;
2305      }
2306      else {
2307        combinationKey = JSON.stringify(combination);
2308        colorAssignment = getColorAssignmentByCombination(colorAssignments, combinationKey);
2309        if (colorAssignment !== undefined) {
2310          colorIndex = colorAssignment.colorIndex;
2311          striped = colorAssignment.striped;
2312          colorAssignment.readyForEviction = false;
2313        }
2314        else {
2315          if (colorAssignmentsAreFull(colorAssignments)) {
2316            evictColorAssignment(colorAssignments);
2317          }
2318          var openColorInfo = getOpenColorInfo(colorAssignments, colors);
2319          colorIndex = openColorInfo.colorIndex;
2320          striped = openColorInfo.striped;
2321          colorAssignment = getAvailableColorAssignment(colorAssignments);
2322          assignColor(colorAssignment, combinationKey, colorIndex, striped);
2323        }
2324      }
2325
2326      color = getColor(colorIndex, colors);
2327      background = getBackground(color, striped);
2328      border = getBorderDash(striped);
2329
2330      dataset = makeDataset(years, filteredData, combination, defaultLabel, color, background, border, excess, allObservationAttributes);
2331      datasets.push(dataset);
2332      index++;
2333    }
2334  }, this);
2335
2336  if (headline.length > 0) {
2337    dataset = makeHeadlineDataset(years, headline, defaultLabel, allObservationAttributes);
2338    datasets.unshift(dataset);
2339  }
2340  return datasets;
2341}
2342
2343/**
2344 * @param {Array} colorAssignments
2345 * @param {int} maxColorAssignments
2346 */
2347function prepareColorAssignments(colorAssignments, maxColorAssignments) {
2348  while (colorAssignments.length < maxColorAssignments) {
2349    colorAssignments.push({
2350      combination: null,
2351      colorIndex: null,
2352      striped: false,
2353      readyForEviction: false,
2354    });
2355  }
2356}
2357
2358/**
2359 * @param {Array} colorAssignments
2360 */
2361function setAllColorAssignmentsReadyForEviction(colorAssignments) {
2362  for (var i = 0; i < colorAssignments.length; i++) {
2363    colorAssignments[i].readyForEviction = true;
2364  }
2365}
2366
2367/**
2368 * @param {Array} rows
2369 * @param {Object} combination Key/value representation of a field combo
2370 * @param {Array} selectableFields Field names
2371 * @return {Array} Matching rows
2372 */
2373function getDataMatchingCombination(data, combination, selectableFields) {
2374  return data.filter(function(row) {
2375    return selectableFields.every(function(field) {
2376      return row[field] === combination[field];
2377    });
2378  });
2379}
2380
2381/**
2382 * @param {Array} colorAssignments
2383 * @param {string} combination
2384 * @return {Object|undefined} Color assignment object if found.
2385 */
2386function getColorAssignmentByCombination(colorAssignments, combination) {
2387  return colorAssignments.find(function(assignment) {
2388    return assignment.combination === combination;
2389  });
2390}
2391
2392/**
2393 * @param {Array} colorAssignments
2394 * @return {boolean}
2395 */
2396function colorAssignmentsAreFull(colorAssignments) {
2397  for (var i = 0; i < colorAssignments.length; i++) {
2398    if (colorAssignments[i].combination === null) {
2399      return false;
2400    }
2401  }
2402  return true;
2403}
2404
2405/**
2406 * @param {Array} colorAssignments
2407 */
2408function evictColorAssignment(colorAssignments) {
2409  for (var i = 0; i < colorAssignments.length; i++) {
2410    if (colorAssignments[i].readyForEviction) {
2411      colorAssignments[i].combination = null;
2412      colorAssignments[i].colorIndex = null;
2413      colorAssignments[i].striped = false;
2414      colorAssignments[i].readyForEviction = false;
2415      return;
2416    }
2417  }
2418  throw 'Could not evict color assignment';
2419}
2420
2421/**
2422 * @param {Array} colorAssignments
2423 * @param {Array} colors
2424 * @return {Object} Object with 'colorIndex' and 'striped' properties.
2425 */
2426function getOpenColorInfo(colorAssignments, colors) {
2427  // First look for normal colors, then striped.
2428  var stripedStates = [false, true];
2429  for (var i = 0; i < stripedStates.length; i++) {
2430    var stripedState = stripedStates[i];
2431    var assignedColors = colorAssignments.filter(function(colorAssignment) {
2432      return colorAssignment.striped === stripedState && colorAssignment.colorIndex !== null;
2433    }).map(function(colorAssignment) {
2434      return colorAssignment.colorIndex;
2435    });
2436    if (assignedColors.length < colors.length) {
2437      for (var colorIndex = 0; colorIndex < colors.length; colorIndex++) {
2438        if (!(assignedColors.includes(colorIndex))) {
2439          return {
2440            colorIndex: colorIndex,
2441            striped: stripedState,
2442          }
2443        }
2444      }
2445    }
2446  }
2447  throw 'Could not find open color';
2448}
2449
2450/**
2451 * @param {Array} colorAssignments
2452 * @return {Object|undefined} Color assignment object if found.
2453 */
2454function getAvailableColorAssignment(colorAssignments) {
2455  return colorAssignments.find(function(assignment) {
2456    return assignment.combination === null;
2457  });
2458}
2459
2460/**
2461 * @param {Object} colorAssignment
2462 * @param {string} combination
2463 * @param {int} colorIndex
2464 * @param {boolean} striped
2465 */
2466function assignColor(colorAssignment, combination, colorIndex, striped) {
2467  colorAssignment.combination = combination;
2468  colorAssignment.colorIndex = colorIndex;
2469  colorAssignment.striped = striped;
2470  colorAssignment.readyForEviction = false;
2471}
2472
2473/**
2474 * @param {int} colorIndex
2475 * @param {Array} colors
2476 * @return Color from a list
2477 */
2478function getColor(colorIndex, colors) {
2479  return '#' + colors[colorIndex];
2480}
2481
2482/**
2483 * @param {string} color
2484 * @param {boolean} striped
2485 * @return Background color or pattern
2486 */
2487function getBackground(color, striped) {
2488  return striped ? getStripes(color) : color;
2489}
2490
2491/**
2492 * @param {string} color
2493 * @return Canvas pattern from color
2494 */
2495function getStripes(color) {
2496  if (window.pattern && typeof window.pattern.draw === 'function') {
2497    return window.pattern.draw('diagonal', color);
2498  }
2499  return color;
2500}
2501
2502/**
2503 * @param {boolean} striped
2504 * @return {Array|undefined} An array produces dashed lines on the chart
2505 */
2506function getBorderDash(striped) {
2507  return striped ? [5, 5] : undefined;
2508}
2509
2510/**
2511 * @param {Array} years
2512 * @param {Array} rows
2513 * @param {Object} combination
2514 * @param {string} labelFallback
2515 * @param {string} color
2516 * @param {string} background
2517 * @param {Array} border
2518 * @param {Array} excess
2519 * @return {Object} Dataset object for Chart.js
2520 */
2521function makeDataset(years, rows, combination, labelFallback, color, background, border, excess, allObservationAttributes) {
2522  var dataset = getBaseDataset(),
2523      prepared = prepareDataForDataset(years, rows, allObservationAttributes),
2524      data = prepared.data,
2525      obsAttributes = prepared.observationAttributes;
2526  return Object.assign(dataset, {
2527    label: getCombinationDescription(combination, labelFallback),
2528    disaggregation: combination,
2529    borderColor: color,
2530    backgroundColor: background,
2531    pointBorderColor: color,
2532    pointBackgroundColor: background,
2533    borderDash: border,
2534    borderWidth: 2,
2535    headline: false,
2536    pointStyle: 'circle',
2537    data: data,
2538    excess: excess,
2539    observationAttributes: obsAttributes,
2540  });
2541}
2542
2543/**
2544 * @return {Object} Starting point for a Chart.js dataset
2545 */
2546function getBaseDataset() {
2547  return Object.assign({}, {
2548    fill: false,
2549    pointHoverRadius: 5,
2550    pointHoverBorderWidth: 1,
2551    tension: 0,
2552    spanGaps: true,
2553    maxBarThickness: 150,
2554  });
2555}
2556
2557/**
2558 * @param {Object} combination Key/value representation of a field combo
2559 * @param {string} fallback
2560 * @return {string} Human-readable description of combo
2561 */
2562function getCombinationDescription(combination, fallback) {
2563  var keys = Object.keys(combination);
2564  if (keys.length === 0) {
2565    return fallback;
2566  }
2567  return keys.map(function(key) {
2568    return translations.t(combination[key]);
2569  }).join(', ');
2570}
2571
2572/**
2573 * @param {Array} years
2574 * @param {Array} rows
2575 * @return {Array} Prepared rows
2576 */
2577function prepareDataForDataset(years, rows, allObservationAttributes) {
2578  var ret = {
2579    data: [],
2580    observationAttributes: [],
2581  };
2582  var configObsAttributes = [];
2583  if (configObsAttributes && configObsAttributes.length > 0) {
2584    configObsAttributes = configObsAttributes.map(function(obsAtt) {
2585      return obsAtt.field;
2586    });
2587  }
2588  else {
2589    configObsAttributes = [];
2590  }
2591  years.forEach(function(year) {
2592    var found = rows.find(function (row) {
2593      return row[YEAR_COLUMN] === year;
2594    });
2595    ret.data.push(found ? found[VALUE_COLUMN] : null);
2596
2597    var obsAttributesForRow = [];
2598    if (found) {
2599      configObsAttributes.forEach(function(field) {
2600        if (found[field]) {
2601          var hashKey = field + '|' + found[field];
2602          obsAttributesForRow.push(allObservationAttributes[hashKey]);
2603        }
2604      });
2605    }
2606    ret.observationAttributes.push(obsAttributesForRow);
2607  });
2608  return ret;
2609}
2610
2611/**
2612 * @return {string} Hex number of headline color
2613 *
2614 * TODO: Make this dynamic to support high-contrast.
2615 */
2616function getHeadlineColor() {
2617  return HEADLINE_COLOR;
2618}
2619
2620/**
2621 * @param {Array} years
2622 * @param {Array} rows
2623 * @param {string} label
2624 * @return {Object} Dataset object for Chart.js
2625 */
2626function makeHeadlineDataset(years, rows, label, allObservationAttributes) {
2627  var dataset = getBaseDataset(),
2628      prepared = prepareDataForDataset(years, rows, allObservationAttributes),
2629      data = prepared.data,
2630      obsAttributes = prepared.observationAttributes;
2631  return Object.assign(dataset, {
2632    label: label,
2633    borderColor: getHeadlineColor(),
2634    backgroundColor: getHeadlineColor(),
2635    pointBorderColor: getHeadlineColor(),
2636    pointBackgroundColor: getHeadlineColor(),
2637    borderWidth: 4,
2638    headline: true,
2639    pointStyle: 'rect',
2640    data: data,
2641    observationAttributes: obsAttributes,
2642  });
2643}
2644
2645  /**
2646 * Model helper functions related to tables.
2647 */
2648
2649/**
2650 * @param {Array} datasets
2651 * @param {Array} years
2652 * @return {Object} Object containing 'headings' and 'data'
2653 */
2654function tableDataFromDatasets(datasets, years) {
2655  return {
2656    headings: [YEAR_COLUMN].concat(datasets.map(function(ds) { return ds.label; })),
2657    data: years.map(function(year, index) {
2658      return [year].concat(datasets.map(function(ds) { return ds.data[index]; }));
2659    }),
2660  };
2661}
2662
2663/**
2664 * @param {Array} datasets
2665 * @param {Array} years
2666 * @return {Object} Same as tableDataFromDatasets, except values are arrays of observation attributes
2667 */
2668function observationAttributesTableFromDatasets(datasets, years) {
2669  return {
2670    data: years.map(function(year, index) {
2671      return [null].concat(datasets.map(function(ds) {
2672        return ds.observationAttributes[index] ? ds.observationAttributes[index] : [];
2673      }));
2674    }),
2675  };
2676}
2677
2678/**
2679 * @param {Array} rows
2680 * @param {string} selectedUnit
2681 * @return {Object} Object containing 'title', 'headings', and 'data'
2682 */
2683function getHeadlineTable(rows, selectedUnit) {
2684  return {
2685    title: 'Headline data',
2686    headings: selectedUnit ? [YEAR_COLUMN, UNIT_COLUMN, VALUE_COLUMN] : [YEAR_COLUMN, VALUE_COLUMN],
2687    data: rows.map(function (row) {
2688      return selectedUnit ? [row[YEAR_COLUMN], row[UNIT_COLUMN], row[VALUE_COLUMN]] : [row[YEAR_COLUMN], row[VALUE_COLUMN]];
2689    }),
2690  };
2691}
2692
2693  /**
2694 * Model helper functions related to data and conversion.
2695 */
2696
2697/**
2698 * @param {Object} data Object imported from JSON file
2699 * @param {Array} dropKeys Array of keys to drop from the rows
2700 * @return {Array} Rows
2701 */
2702function convertJsonFormatToRows(data, dropKeys) {
2703  var keys = Object.keys(data);
2704  if (keys.length === 0) {
2705    return [];
2706  }
2707
2708  if (dropKeys && dropKeys.length > 0) {
2709    keys = keys.filter(function(key) {
2710      return !(dropKeys.includes(key));
2711    });
2712  }
2713
2714  return data[keys[0]].map(function(item, index) {
2715    return _.zipObject(keys, keys.map(function(key) {
2716      return data[key][index];
2717    }));
2718  });
2719}
2720
2721/**
2722 * @param {Array} selectableFields Field names
2723 * @param {Array} rows
2724 * @return {Array} Headline rows
2725 */
2726function getHeadline(selectableFields, rows) {
2727  return rows.filter(function (row) {
2728    return selectableFields.every(function(field) {
2729      return !row[field];
2730    });
2731  }).map(function (row) {
2732    // Remove null fields in each row.
2733    return _.pickBy(row, function(val) { return val !== null });
2734  });
2735}
2736
2737/**
2738 * @param {Array} rows
2739 * @return {Array} Prepared rows
2740 */
2741function prepareData(rows, context) {
2742  return rows.map(function(item) {
2743
2744    if (item[VALUE_COLUMN] != 0) {
2745      // For rounding, use a function that can be set on the global opensdg
2746      // object, for easier control: opensdg.dataRounding()
2747      if (typeof opensdg.dataRounding === 'function') {
2748        item.Value = opensdg.dataRounding(item.Value, context);
2749      }
2750    }
2751
2752    // remove any undefined/null values:
2753    Object.keys(item).forEach(function(key) {
2754      if (item[key] === null || typeof item[key] === 'undefined') {
2755        delete item[key];
2756      }
2757    });
2758
2759    return item;
2760  }, this);
2761}
2762
2763/**
2764 * @param {Array} rows
2765 * @param {string} selectedUnit
2766 * @return {Array} Sorted rows
2767 */
2768function sortData(rows, selectedUnit) {
2769  var column = selectedUnit ? UNIT_COLUMN : YEAR_COLUMN;
2770  return _.sortBy(rows, column);
2771}
2772
2773/**
2774 * @param {Array} precisions Objects containing 'unit' and 'title'
2775 * @param {String} selectedUnit
2776 * @param {String} selectedSeries
2777 * @return {int|false} number of decimal places, if any
2778 */
2779function getPrecision(precisions, selectedUnit, selectedSeries) {
2780  var match = getMatchByUnitSeries(precisions, selectedUnit, selectedSeries);
2781  return (match) ? match.decimals : false;
2782}
2783
2784/**
2785 * @param {Object} data Object imported from JSON file
2786 * @return {Array} Rows
2787 */
2788function inputData(data) {
2789  var dropKeys = [];
2790  if (opensdg.ignoredDisaggregations && opensdg.ignoredDisaggregations.length > 0) {
2791    dropKeys = opensdg.ignoredDisaggregations;
2792  }
2793  return convertJsonFormatToRows(data, dropKeys);
2794}
2795
2796/**
2797 * @param {Object} edges Object imported from JSON file
2798 * @return {Array} Rows
2799 */
2800function inputEdges(edges) {
2801  var edgesData = convertJsonFormatToRows(edges);
2802  if (opensdg.ignoredDisaggregations && opensdg.ignoredDisaggregations.length > 0) {
2803    var ignoredDisaggregations = opensdg.ignoredDisaggregations;
2804    edgesData = edgesData.filter(function(edge) {
2805      if (ignoredDisaggregations.includes(edge.To) || ignoredDisaggregations.includes(edge.From)) {
2806        return false;
2807      }
2808      return true;
2809    });
2810  }
2811  var configuredObservationAttributes = [];
2812  if (configuredObservationAttributes && configuredObservationAttributes.length > 0) {
2813    configuredObservationAttributesFlat = configuredObservationAttributes.map(function(att) { return att.field; });
2814    edgesData = edgesData.filter(function(edge) {
2815      if (configuredObservationAttributesFlat.includes(edge.To) || configuredObservationAttributesFlat.includes(edge.From)) {
2816        return false;
2817      }
2818      return true;
2819    });
2820  }
2821
2822  return edgesData;
2823}
2824
2825/**
2826 * @param {Array} rows
2827 * @return {Array} Objects containing 'field' and 'value', to be placed in the footer.
2828 */
2829function getTimeSeriesAttributes(rows) {
2830  if (rows.length === 0) {
2831    return [];
2832  }
2833  var timeSeriesAttributes = [],
2834      possibleAttributes = [{"field":"COMMENT_TS","label":"footnote"},{"field":"DATA_LAST_UPDATE","label":"metadata_fields.national_data_update_url"}],
2835      firstRow = rows[0],
2836      firstRowKeys = Object.keys(firstRow);
2837  possibleAttributes.forEach(function(possibleAttribute) {
2838    var field = possibleAttribute.field;
2839    if (firstRowKeys.includes(field) && firstRow[field]) {
2840      timeSeriesAttributes.push({
2841        field: field,
2842        value: firstRow[field],
2843      });
2844    }
2845  });
2846  return timeSeriesAttributes;
2847}
2848
2849function getAllObservationAttributes(rows) {
2850  if (rows.length === 0) {
2851    return {};
2852  }
2853  var obsAttributeHash = {},
2854      footnoteNumber = 0,
2855      configObsAttributes = [];
2856  if (configObsAttributes && configObsAttributes.length > 0) {
2857    configObsAttributes = configObsAttributes.map(function(obsAtt) {
2858      return obsAtt.field;
2859    });
2860  }
2861  else {
2862    configObsAttributes = [];
2863  }
2864  configObsAttributes.forEach(function(field) {
2865    var attributeValues = Object.keys(_.groupBy(rows, field)).filter(function(value) {
2866      return value !== 'undefined';
2867    });
2868    attributeValues.forEach(function(attributeValue) {
2869      var hashKey = field + '|' + attributeValue;
2870      obsAttributeHash[hashKey] = {
2871        field: field,
2872        value: attributeValue,
2873        footnoteNumber: footnoteNumber,
2874      }
2875      footnoteNumber += 1;
2876    });
2877  });
2878  return obsAttributeHash;
2879}
2880
2881
2882  function deprecated(name) {
2883    return function() {
2884      console.log('The ' + name + ' function has been removed. Please update any overridden files.');
2885    }
2886  }
2887
2888  return {
2889    UNIT_COLUMN: UNIT_COLUMN,
2890    SERIES_COLUMN: SERIES_COLUMN,
2891    GEOCODE_COLUMN: GEOCODE_COLUMN,
2892    YEAR_COLUMN: YEAR_COLUMN,
2893    VALUE_COLUMN: VALUE_COLUMN,
2894    GRAPH_TITLE_FROM_SERIES: GRAPH_TITLE_FROM_SERIES,
2895    convertJsonFormatToRows: convertJsonFormatToRows,
2896    getUniqueValuesByProperty: getUniqueValuesByProperty,
2897    dataHasUnits: dataHasUnits,
2898    dataHasGeoCodes: dataHasGeoCodes,
2899    dataHasSerieses: dataHasSerieses,
2900    getFirstUnitInData: getFirstUnitInData,
2901    getFirstSeriesInData: getFirstSeriesInData,
2902    getDataByUnit: getDataByUnit,
2903    getDataBySeries: getDataBySeries,
2904    getDataBySelectedFields: getDataBySelectedFields,
2905    hasDataBySelectedFields: hasDataBySelectedFields,
2906    getUnitFromStartValues: getUnitFromStartValues,
2907    getSeriesFromStartValues: getSeriesFromStartValues,
2908    selectFieldsFromStartValues: selectFieldsFromStartValues,
2909    selectMinimumStartingFields: selectMinimumStartingFields,
2910    fieldsUsedByUnit: fieldsUsedByUnit,
2911    fieldsUsedBySeries: fieldsUsedBySeries,
2912    dataHasUnitSpecificFields: dataHasUnitSpecificFields,
2913    dataHasSeriesSpecificFields: dataHasSeriesSpecificFields,
2914    getInitialFieldItemStates: getInitialFieldItemStates,
2915    validParentsByChild: validParentsByChild,
2916    getFieldNames: getFieldNames,
2917    getInitialAllowedFields: getInitialAllowedFields,
2918    prepareData: prepareData,
2919    getHeadline: getHeadline,
2920    sortData: sortData,
2921    getHeadlineTable: getHeadlineTable,
2922    removeOrphanSelections: removeOrphanSelections,
2923    getAllowedFieldsWithChildren: getAllowedFieldsWithChildren,
2924    getUpdatedFieldItemStates: getUpdatedFieldItemStates,
2925    fieldItemStatesForView: fieldItemStatesForView,
2926    getChartTitle: getChartTitle,
2927    getChartType: getChartType,
2928    getCombinationData: getCombinationData,
2929    getDatasets: getDatasets,
2930    tableDataFromDatasets: tableDataFromDatasets,
2931    observationAttributesTableFromDatasets: observationAttributesTableFromDatasets,
2932    sortFieldNames: typeof sortFieldNames !== 'undefined' ? sortFieldNames : function() {},
2933    sortFieldValueNames: typeof sortFieldValueNames !== 'undefined' ? sortFieldValueNames : function() {},
2934    getPrecision: getPrecision,
2935    getGraphLimits: getGraphLimits,
2936    getGraphAnnotations: getGraphAnnotations,
2937    getColumnsFromData: getColumnsFromData,
2938    inputEdges: inputEdges,
2939    getTimeSeriesAttributes: getTimeSeriesAttributes,
2940    getAllObservationAttributes: getAllObservationAttributes,
2941    inputData: inputData,
2942  }
2943})();
2944
2945  this.helpers = helpers;
2946
2947  // events:
2948  this.onDataComplete = new event(this);
2949  this.onFieldsComplete = new event(this);
2950  this.onUnitsComplete = new event(this);
2951  this.onUnitsSelectedChanged = new event(this);
2952  this.onSeriesesComplete = new event(this);
2953  this.onSeriesesSelectedChanged = new event(this);
2954  this.onFieldsStatusUpdated = new event(this);
2955  this.onFieldsCleared = new event(this);
2956  this.onSelectionUpdate = new event(this);
2957
2958  // general members:
2959  var that = this;
2960  this.data = helpers.inputData(options.data);
2961  this.edgesData = helpers.inputEdges(options.edgesData);
2962  this.hasHeadline = true;
2963  this.country = options.country;
2964  this.indicatorId = options.indicatorId;
2965  this.shortIndicatorId = options.shortIndicatorId;
2966  this.chartTitle = options.chartTitle,
2967  this.chartTitles = options.chartTitles;
2968  this.graphType = options.graphType;
2969  this.graphTypes = options.graphTypes;
2970  this.measurementUnit = options.measurementUnit;
2971  this.xAxisLabel = options.xAxisLabel;
2972  this.startValues = options.startValues;
2973  this.showData = options.showData;
2974  this.selectedFields = [];
2975  this.allowedFields = [];
2976  this.selectedUnit = undefined;
2977  this.fieldsByUnit = undefined;
2978  this.dataHasUnitSpecificFields = false;
2979  this.selectedSeries = undefined;
2980  this.fieldsBySeries = undefined;
2981  this.dataHasSeriesSpecificFields = false;
2982  this.fieldValueStatuses = [];
2983  this.validParentsByChild = {};
2984  this.hasGeoData = false;
2985  this.showMap = options.showMap;
2986  this.graphLimits = options.graphLimits;
2987  this.stackedDisaggregation = options.stackedDisaggregation;
2988  this.graphAnnotations = options.graphAnnotations;
2989  this.graphTargetLines = options.graphTargetLines;
2990  this.graphSeriesBreaks = options.graphSeriesBreaks;
2991  this.indicatorDownloads = options.indicatorDownloads;
2992  this.compositeBreakdownLabel = options.compositeBreakdownLabel;
2993  this.precision = options.precision;
2994  this.dataSchema = options.dataSchema;
2995  this.proxy = options.proxy;
2996  this.proxySerieses = (this.proxy === 'both') ? options.proxySeries : [];
2997  this.observationAttributes = [];
2998
2999  this.initialiseUnits = function() {
3000    if (this.hasUnits) {
3001      this.units = helpers.getUniqueValuesByProperty(helpers.UNIT_COLUMN, this.data);
3002      helpers.sortFieldValueNames(helpers.UNIT_COLUMN, this.units, this.dataSchema);
3003      this.selectedUnit = this.units[0];
3004      this.fieldsByUnit = helpers.fieldsUsedByUnit(this.units, this.data, this.allColumns);
3005      this.dataHasUnitSpecificFields = helpers.dataHasUnitSpecificFields(this.fieldsByUnit);
3006    }
3007  }
3008
3009  this.refreshSeries = function() {
3010    if (this.hasSerieses) {
3011      if (helpers.GRAPH_TITLE_FROM_SERIES) {
3012        this.chartTitle = this.selectedSeries;
3013      }
3014      this.data = helpers.getDataBySeries(this.allData, this.selectedSeries);
3015      this.years = helpers.getUniqueValuesByProperty(helpers.YEAR_COLUMN, this.data).sort();
3016      this.fieldsBySeries = helpers.fieldsUsedBySeries(this.serieses, this.data, this.allColumns);
3017      this.dataHasSeriesSpecificFields = helpers.dataHasSeriesSpecificFields(this.fieldsBySeries);
3018    }
3019  }
3020
3021  this.initialiseFields = function() {
3022    this.fieldItemStates = helpers.getInitialFieldItemStates(this.data, this.edgesData, this.allColumns, this.dataSchema);
3023    this.validParentsByChild = helpers.validParentsByChild(this.edgesData, this.fieldItemStates, this.data);
3024    this.selectableFields = helpers.getFieldNames(this.fieldItemStates);
3025    this.allowedFields = helpers.getInitialAllowedFields(this.selectableFields, this.edgesData);
3026  }
3027
3028  // Before continuing, we may need to filter by Series, so set up all the Series stuff.
3029  this.allData = helpers.prepareData(this.data, { indicatorId: this.indicatorId });
3030  this.allColumns = helpers.getColumnsFromData(this.allData);
3031  this.hasSerieses = helpers.dataHasSerieses(this.allColumns);
3032  this.serieses = this.hasSerieses ? helpers.getUniqueValuesByProperty(helpers.SERIES_COLUMN, this.allData) : [];
3033  this.hasStartValues = Array.isArray(this.startValues) && this.startValues.length > 0;
3034  if (this.hasSerieses) {
3035    helpers.sortFieldValueNames(helpers.SERIES_COLUMN, this.serieses, this.dataSchema);
3036    this.selectedSeries = this.serieses[0];
3037    if (this.hasStartValues) {
3038      this.selectedSeries = helpers.getSeriesFromStartValues(this.startValues) || this.selectedSeries;
3039    }
3040    this.refreshSeries();
3041  }
3042  else {
3043    this.data = this.allData;
3044    this.years = helpers.getUniqueValuesByProperty(helpers.YEAR_COLUMN, this.data).sort();
3045  }
3046
3047  // calculate some initial values:
3048  this.allObservationAttributes = helpers.getAllObservationAttributes(this.allData);
3049  this.hasGeoData = helpers.dataHasGeoCodes(this.allColumns);
3050  this.hasUnits = helpers.dataHasUnits(this.allColumns);
3051  this.initialiseUnits();
3052  this.initialiseFields();
3053  this.colors = opensdg.chartColors(this.indicatorId);
3054  this.maxDatasetCount = 2 * this.colors.length;
3055  this.colorAssignments = [];
3056
3057  this.clearSelectedFields = function() {
3058    this.selectedFields = [];
3059    this.getData();
3060    this.onFieldsCleared.notify();
3061  };
3062
3063  this.updateFieldStates = function(selectedFields) {
3064    this.selectedFields = helpers.removeOrphanSelections(selectedFields, this.edgesData);
3065    this.allowedFields = helpers.getAllowedFieldsWithChildren(this.selectableFields, this.edgesData, selectedFields);
3066    this.fieldItemStates = helpers.getUpdatedFieldItemStates(this.fieldItemStates, this.edgesData, selectedFields, this.validParentsByChild);
3067    this.onSelectionUpdate.notify({
3068      selectedFields: this.selectedFields,
3069      allowedFields: this.allowedFields
3070    });
3071  }
3072
3073  this.updateSelectedFields = function (selectedFields) {
3074    this.updateFieldStates(selectedFields);
3075    this.getData();
3076  };
3077
3078  this.updateChartTitle = function() {
3079    this.chartTitle = helpers.getChartTitle(this.chartTitle, this.chartTitles, this.selectedUnit, this.selectedSeries);
3080  }
3081
3082  this.updateChartType = function() {
3083    this.graphType = helpers.getChartType(this.graphType, this.graphTypes, this.selectedUnit, this.selectedSeries);
3084  }
3085
3086  this.updateSelectedUnit = function(selectedUnit) {
3087    this.selectedUnit = selectedUnit;
3088    this.getData({
3089      updateFields: this.dataHasUnitSpecificFields
3090    });
3091    this.onUnitsSelectedChanged.notify(selectedUnit);
3092  };
3093
3094  this.updateSelectedSeries = function(selectedSeries) {
3095    // Updating the Series is akin to loading a whole new indicator, so
3096    // here we re-initialise most everything on the page.
3097    this.selectedSeries = selectedSeries;
3098    this.refreshSeries();
3099    this.clearSelectedFields();
3100    this.initialiseUnits();
3101    this.initialiseFields();
3102    this.getData({ updateFields: true, changingSeries: true });
3103    this.onSeriesesSelectedChanged.notify(selectedSeries);
3104  };
3105
3106  this.getData = function(options) {
3107    options = Object.assign({
3108      initial: false,
3109      updateFields: false,
3110      changingSeries: false,
3111    }, options);
3112
3113    var headlineUnfiltered = helpers.getHeadline(this.selectableFields, this.data);
3114    var headline;
3115    if (this.hasUnits && !this.hasSerieses) {
3116      headline = helpers.getDataByUnit(headlineUnfiltered, this.selectedUnit);
3117    }
3118    else if (this.hasSerieses && !this.hasUnits) {
3119      headline = helpers.getDataBySeries(headlineUnfiltered, this.selectedSeries);
3120    }
3121    else if (this.hasSerieses && this.hasUnits) {
3122      headline = helpers.getDataByUnit(headlineUnfiltered, this.selectedUnit);
3123      headline = helpers.getDataBySeries(headline, this.selectedSeries);
3124    }
3125    else {
3126      headline = headlineUnfiltered;
3127    }
3128
3129    // If this is the initial load, check for special cases.
3130    var selectionUpdateNeeded = false;
3131    if (options.initial || options.changingSeries) {
3132      // Decide on a starting unit.
3133      if (this.hasUnits) {
3134        var startingUnit = this.selectedUnit;
3135        if (this.hasStartValues) {
3136          var unitInStartValues = helpers.getUnitFromStartValues(this.startValues);
3137          if (unitInStartValues && this.units.includes(unitInStartValues)) {
3138            startingUnit = unitInStartValues;
3139          }
3140        }
3141        else {
3142          // If our selected unit causes the headline to be empty, change it
3143          // to the first one available that would work.
3144          if (headlineUnfiltered.length > 0 && headline.length === 0) {
3145            startingUnit = helpers.getFirstUnitInData(headlineUnfiltered);
3146          }
3147        }
3148        // Re-query the headline if needed.
3149        if (this.selectedUnit !== startingUnit) {
3150          headline = helpers.getDataByUnit(headlineUnfiltered, startingUnit);
3151        }
3152        this.selectedUnit = startingUnit;
3153      }
3154
3155      // Decide on a starting series.
3156      if (this.hasSerieses && !options.changingSeries) {
3157        var startingSeries = this.selectedSeries;
3158        if (this.hasStartValues) {
3159          var seriesInStartValues = helpers.getSeriesFromStartValues(this.startValues);
3160          if (seriesInStartValues) {
3161            startingSeries = seriesInStartValues;
3162          }
3163        }
3164        else {
3165          // If our selected series causes the headline to be empty, change it
3166          // to the first one available that would work.
3167          if (headlineUnfiltered.length > 0 && headline.length === 0) {
3168            startingSeries = helpers.getFirstSeriesInData(headlineUnfiltered);
3169          }
3170        }
3171        // Re-query the headline if needed.
3172        if (this.selectedSeries !== startingSeries) {
3173          headline = helpers.getDataBySeries(headlineUnfiltered, startingSeries);
3174        }
3175        this.selectedSeries = startingSeries;
3176      }
3177
3178      // Decide on starting field values.
3179      var startingFields = this.selectedFields;
3180      var useMinimumStartingFields = false;
3181      if (this.hasStartValues) {
3182        startingFields = helpers.selectFieldsFromStartValues(this.startValues, this.selectableFields);
3183        // Quick test to see if this would result in zero matches, in cases where
3184        // the series is being changed and the new series would not show data.
3185        if (options.changingSeries && !helpers.hasDataBySelectedFields(this.data, startingFields)) {
3186          useMinimumStartingFields = true;
3187          startingFields = this.selectedFields;
3188        }
3189      }
3190      if (!this.hasStartValues || useMinimumStartingFields) {
3191        if (headline.length === 0) {
3192          startingFields = helpers.selectMinimumStartingFields(this.data, this.selectableFields, this.selectedUnit);
3193        }
3194      }
3195      if (startingFields.length > 0) {
3196        this.selectedFields = startingFields;
3197        selectionUpdateNeeded = true;
3198      }
3199
3200      this.onUnitsComplete.notify({
3201        units: this.units,
3202        selectedUnit: this.selectedUnit
3203      });
3204
3205      this.onSeriesesComplete.notify({
3206        serieses: this.serieses,
3207        selectedSeries: this.selectedSeries,
3208        proxySerieses: this.proxySerieses,
3209      });
3210    }
3211
3212    if (options.initial || options.updateFields) {
3213      this.onFieldsComplete.notify({
3214        fields: helpers.fieldItemStatesForView(
3215          this.fieldItemStates,
3216          this.fieldsByUnit,
3217          this.selectedUnit,
3218          this.dataHasUnitSpecificFields,
3219          this.fieldsBySeries,
3220          this.selectedSeries,
3221          this.dataHasSeriesSpecificFields,
3222          this.selectedFields,
3223          this.edgesData,
3224          this.compositeBreakdownLabel
3225        ),
3226        allowedFields: this.allowedFields,
3227        edges: this.edgesData,
3228        hasGeoData: this.hasGeoData,
3229        startValues: this.startValues,
3230        indicatorId: this.indicatorId,
3231        showMap: this.showMap,
3232        precision: helpers.getPrecision(this.precision, this.selectedUnit, this.selectedSeries),
3233        precisionItems: this.precision,
3234        dataSchema: this.dataSchema,
3235        chartTitles: this.chartTitles,
3236        proxy: this.proxy,
3237        proxySerieses: this.proxySerieses,
3238      });
3239    }
3240
3241    if (selectionUpdateNeeded || options.updateFields) {
3242      this.updateFieldStates(this.selectedFields);
3243    }
3244
3245    var filteredData = helpers.getDataBySelectedFields(this.data, this.selectedFields);
3246    if (this.hasUnits) {
3247      filteredData = helpers.getDataByUnit(filteredData, this.selectedUnit);
3248    }
3249
3250    var timeSeriesAttributes = [];
3251    if (filteredData.length > 0) {
3252      timeSeriesAttributes = helpers.getTimeSeriesAttributes(filteredData);
3253    }
3254    else if (headline.length > 0) {
3255      timeSeriesAttributes = helpers.getTimeSeriesAttributes(headline);
3256    }
3257
3258    filteredData = helpers.sortData(filteredData, this.selectedUnit);
3259    if (headline.length > 0) {
3260      headline = helpers.sortData(headline, this.selectedUnit);
3261    }
3262
3263    var combinations = helpers.getCombinationData(this.selectedFields);
3264    var datasets = helpers.getDatasets(headline, filteredData, combinations, this.years, this.country, this.colors, this.selectableFields, this.colorAssignments, this.allObservationAttributes);
3265    var selectionsTable = helpers.tableDataFromDatasets(datasets, this.years);
3266    var observationAttributesTable = helpers.observationAttributesTableFromDatasets(datasets, this.years);
3267
3268    var datasetCountExceedsMax = false;
3269    // restrict count if it exceeds the limit:
3270    if(datasets.length > this.maxDatasetCount) {
3271      datasetCountExceedsMax = true;
3272    }
3273
3274    this.updateChartTitle();
3275    this.updateChartType();
3276
3277    this.onFieldsStatusUpdated.notify({
3278      data: this.fieldItemStates,
3279      // TODO: Why is selectionStates not used?
3280      selectionStates: []
3281    });
3282
3283    this.onDataComplete.notify({
3284      datasetCountExceedsMax: datasetCountExceedsMax,
3285      datasets: datasets.filter(function(dataset) { return dataset.excess !== true }),
3286      labels: this.years,
3287      headlineTable: helpers.getHeadlineTable(headline, this.selectedUnit),
3288      selectionsTable: selectionsTable,
3289      observationAttributesTable: observationAttributesTable,
3290      indicatorId: this.indicatorId,
3291      shortIndicatorId: this.shortIndicatorId,
3292      selectedUnit: this.selectedUnit,
3293      selectedSeries: this.selectedSeries,
3294      graphLimits: helpers.getGraphLimits(this.graphLimits, this.selectedUnit, this.selectedSeries
3294),
3295      stackedDisaggregation: this.stackedDisaggregation,
3296      graphAnnotations: helpers.getGraphAnnotations(this.graphAnnotations, this.selectedUnit, this.selectedSeries, this.graphTargetLines, this.graphSeriesBreaks),
3297      chartTitle: this.chartTitle,
3298      chartType: this.graphType,
3299      indicatorDownloads: this.indicatorDownloads,
3300      precision: helpers.getPrecision(this.precision, this.selectedUnit, this.selectedSeries),
3301      timeSeriesAttributes: timeSeriesAttributes,
3302      allObservationAttributes: this.allObservationAttributes,
3303      isProxy: this.proxy === 'proxy' || this.proxySerieses.includes(this.selectedSeries),
3304    });
3305  };
3306};
3307
3308indicatorModel.prototype = {
3309  initialise: function () {
3310    this.getData({
3311      initial: true
3312    });
3313  },
3314  getData: function () {
3315    this.getData();
3316  }
3317};
3318var mapView = function () {
3319
3320  "use strict";
3321
3322  this.initialise = function(indicatorId, precision, precisionItems, decimalSeparator, dataSchema, viewHelpers, modelHelpers, chartTitles, startValues, proxy, proxySerieses, allObservationAttributes) {
3323    $('.map').show();
3324    $('#map').sdgMap({
3325      indicatorId: indicatorId,
3326      mapOptions: {"disaggregation_controls":false,"minZoom":5,"maxZoom":10,"tileURL":"","tileOptions":{"id":"","accessToken":"","attribution":""},"colorRange":"opensdg.mapColors.default"},
3327      mapLayers: [],
3328      precision: precision,
3329      precisionItems: precisionItems,
3330      decimalSeparator: decimalSeparator,
3331      dataSchema: dataSchema,
3332      viewHelpers: viewHelpers,
3333      modelHelpers: modelHelpers,
3334      chartTitles: chartTitles,
3335      proxy: proxy,
3336      proxySerieses: proxySerieses,
3337      startValues: startValues,
3338      allObservationAttributes: allObservationAttributes,
3339    });
3340  };
3341};
3342var indicatorView = function (model, options) {
3343
3344    "use strict";
3345
3346    var MODEL = model,
3347        VIEW = this,
3348        OPTIONS = options;
3349
3350    var helpers = 
3351(function() {
3352
3353  var HIDE_SINGLE_SERIES = true;
3354var HIDE_SINGLE_UNIT = true;
3355var PROXY_PILL = '<span aria-describedby="proxy-description" class="proxy-pill">' + translations.t("indicator.proxy") + '</span>';
3356
3357  /**
3358 * @param {Object} args
3359 * @return null
3360 */
3361function initialiseFields(args) {
3362    var fieldsContainValues = args.fields.some(function (field) {
3363        return field.values.length > 0;
3364    });
3365    if (fieldsContainValues) {
3366        var template = _.template($("#item_template").html());
3367
3368        if (!$('button#clear').length) {
3369            $('<button id="clear" disabled="disabled" aria-disabled="true" class="disabled">' + translations.indicator.clear_selections + ' <i class="fa fa-remove"></i></button>').insertBefore('#fields');
3370        }
3371
3372        $('#fields').html(template({
3373            fields: args.fields,
3374            allowedFields: args.allowedFields,
3375            childFields: _.uniq(args.edges.map(function (edge) { return edge.To })),
3376            edges: args.edges
3377        }));
3378
3379        $(OPTIONS.rootElement).removeClass('no-fields');
3380
3381    } else {
3382        $(OPTIONS.rootElement).addClass('no-fields');
3383    }
3384}
3385
3386/**
3387 * @return null
3388 */
3389function updateWithSelectedFields() {
3390    MODEL.updateSelectedFields(_.chain(_.map($('#fields input:checked'), function (fieldValue) {
3391        return {
3392            value: $(fieldValue).val(),
3393            field: $(fieldValue).data('field')
3394        };
3395    })).groupBy('field').map(function (value, key) {
3396        return {
3397            field: key,
3398            values: _.map(value, 'value')
3399        };
3400    }).value());
3401}
3402
3403/**
3404 * @param {Element} fieldGroupElement
3405 * @return null
3406 */
3407function sortFieldGroup(fieldGroupElement) {
3408    var sortLabels = function (a, b) {
3409        var aObj = { hasData: $(a).attr('data-has-data'), text: $(a).text() };
3410        var bObj = { hasData: $(b).attr('data-has-data'), text: $(b).text() };
3411        if (aObj.hasData == bObj.hasData) {
3412            return (aObj.text > bObj.text) ? 1 : -1;
3413        }
3414        return (aObj.hasData < bObj.hasData) ? 1 : -1;
3415    };
3416    fieldGroupElement.find('label')
3417        .sort(sortLabels)
3418        .appendTo(fieldGroupElement.find('#indicatorData .variable-options'));
3419}
3420
3421/**
3422 * @param {Array} tsAttributeValues
3423 *   Array of objects containing 'field' and 'value'.
3424 * @return null
3425 */
3426function updateTimeSeriesAttributes(tsAttributeValues) {
3427    var timeSeriesAttributes = [{"field":"COMMENT_TS","label":"footnote"},{"field":"DATA_LAST_UPDATE","label":"metadata_fields.national_data_update_url"}];
3428    timeSeriesAttributes.forEach(function(tsAttribute) {
3429        var field = tsAttribute.field,
3430            valueMatch = tsAttributeValues.find(function(tsAttributeValue) {
3431                return tsAttributeValue.field === field;
3432            }),
3433            value = (valueMatch) ? valueMatch.value : '',
3434            $labelElement = $('dt[data-ts-attribute="' + field + '"]'),
3435            $valueElement = $('dd[data-ts-attribute="' + field + '"]');
3436
3437        if (!value) {
3438            $labelElement.hide();
3439            $valueElement.hide();
3440        }
3441        else {
3442            $labelElement.show();
3443            $valueElement.show().text(translations.t(value));
3444        }
3445    });
3446}
3447
3448/**
3449 * @param {Array} obsAttributes
3450 *   Array of objects containing 'field' and 'value'.
3451 * @return null
3452 */
3453function updateObservationAttributes(obsAttributes) {
3454    var $listElement = $('.observation-attribute-list');
3455    $listElement.empty();
3456    if (obsAttributes.length === 0) {
3457        $listElement.hide();
3458        return;
3459    }
3460    $listElement.show();
3461    Object.values(obsAttributes).forEach(function(obsAttribute) {
3462        var label = getObservationAttributeText(obsAttribute),
3463            num = getObservationAttributeFootnoteSymbol(obsAttribute.footnoteNumber);
3464        var $listItem = $('<dt id="observation-footnote-title-' + num + '">' + num + '</dt><dd id="observation-footnote-desc-' + num + '">' + label + '</dd>');
3465        $listElement.append($listItem);
3466    });
3467}
3468
3469/**
3470 * Gets the text of an observation attribute for display to the end user.
3471 */
3472function getObservationAttributeText(obsAttribute) {
3473    var configuredObsAttributes = [];
3474    var attributeConfig = _.find(configuredObsAttributes, function(configuredObsAttribute) {
3475        return configuredObsAttribute.field === obsAttribute.field;
3476    });
3477    if (!attributeConfig) {
3478        return '';
3479    }
3480    var label = translations.t(obsAttribute.value);
3481    if (attributeConfig.label) {
3482        label = translations.t(attributeConfig.label) + ': ' + label;
3483    }
3484    return label;
3485}
3486
3487  /**
3488 * @param {Object} args
3489 * @return null
3490 */
3491function initialiseUnits(args) {
3492    var template = _.template($('#units_template').html()),
3493        units = args.units || [],
3494        selectedUnit = args.selectedUnit || null;
3495
3496    $('#units').html(template({
3497        units: units,
3498        selectedUnit: selectedUnit
3499    }));
3500
3501    var noUnits = (units.length < 1);
3502    if (HIDE_SINGLE_UNIT) {
3503        noUnits = (units.length < 2);
3504    }
3505
3506    if (noUnits) {
3507        $(OPTIONS.rootElement).addClass('no-units');
3508    }
3509    else {
3510        $(OPTIONS.rootElement).removeClass('no-units');
3511    }
3512}
3513
3514/**
3515 * @param {String} selectedUnit
3516 * @return null
3517 */
3518 function updateUnitElements(selectedUnit) {
3519    var hasUnit = typeof selectedUnit !== 'undefined';
3520    var fallback = MODEL.measurementUnit;
3521    if (hasUnit || fallback) {
3522        var unitToDisplay = selectedUnit || fallback;
3523        $('.data-controlled-footer-field.unit-from-data').show();
3524        $('dd.data-controlled-footer-field.unit-from-data').text(translations.t(unitToDisplay));
3525    }
3526    else {
3527        $('.data-controlled-footer-field.unit-from-data').hide();
3528    }
3529}
3530
3531  /**
3532 * @param {Object} args
3533 * @return null
3534 */
3535function initialiseSerieses(args) {
3536    var activeSeriesInput = $('#serieses').find(document.activeElement),
3537        seriesWasFocused = (activeSeriesInput.length > 0) ? true : false,
3538        focusedValue = (seriesWasFocused) ? $(activeSeriesInput).val() : null,
3539        templateElement = $('#series_template');
3540    if (templateElement.length > 0) {
3541        var template = _.template(templateElement.html()),
3542            serieses = args.serieses || [],
3543            selectedSeries = args.selectedSeries || null,
3544            proxySerieses = args.proxySerieses || [];
3545        $('#serieses').html(template({
3546            serieses: serieses,
3547            selectedSeries: selectedSeries,
3548            proxySerieses: proxySerieses,
3549            proxyPill: PROXY_PILL,
3550        }));
3551
3552        var noSerieses = (serieses.length < 1);
3553        if (HIDE_SINGLE_SERIES) {
3554            noSerieses = (serieses.length < 2);
3555        }
3556
3557        if (noSerieses) {
3558            $(OPTIONS.rootElement).addClass('no-serieses');
3559        }
3560        else {
3561            $(OPTIONS.rootElement).removeClass('no-serieses');
3562        }
3563    }
3564    // Return focus if necessary.
3565    if (seriesWasFocused) {
3566        $('#serieses :input[value="' + focusedValue + '"]').focus();
3567    }
3568}
3569
3570  /**
3571 * @param {Object} config
3572 * @param {Object} info
3573 * @return null
3574 */
3575function alterChartConfig(config, info) {
3576    opensdg.chartConfigAlterations.forEach(function (callback) {
3577        callback(config, info);
3578    });
3579}
3580
3581/**
3582 * @param {String} chartTitle
3583 * @return null
3584 */
3585function updateChartTitle(chartTitle, isProxy) {
3586    if (typeof chartTitle !== 'undefined') {
3587        if (isProxy) {
3588            chartTitle += ' ' + PROXY_PILL;
3589        }
3590        $('.chart-title').html(chartTitle);
3591    }
3592}
3593
3594/**
3595 * @param {Array} oldDatasets
3596 * @param {Array} newDatasets
3597 * @return null
3598 */
3599function updateIndicatorDataViewStatus(oldDatasets, newDatasets) {
3600    var status = '',
3601        hasData = newDatasets.length > 0,
3602        dataAdded = newDatasets.length > oldDatasets.length,
3603        dataRemoved = newDatasets.length < oldDatasets.length,
3604        getDatasetLabel = function (dataset) { return dataset.label; },
3605        oldLabels = oldDatasets.map(getDatasetLabel),
3606        newLabels = newDatasets.map(getDatasetLabel);
3607
3608    if (!hasData) {
3609        status = translations.indicator.announce_data_not_available;
3610    }
3611    else if (dataAdded) {
3612        status = translations.indicator.announce_data_added;
3613        var addedLabels = [];
3614        newLabels.forEach(function (label) {
3615            if (!oldLabels.includes(label)) {
3616                addedLabels.push(label);
3617            }
3618        });
3619        status += ' ' + addedLabels.join(', ');
3620    }
3621    else if (dataRemoved) {
3622        status = translations.indicator.announce_data_removed;
3623        var removedLabels = [];
3624        oldLabels.forEach(function (label) {
3625            if (!newLabels.includes(label)) {
3626                removedLabels.push(label);
3627            }
3628        });
3629        status += ' ' + removedLabels.join(', ');
3630    }
3631
3632    var current = $('#indicator-data-view-status').text();
3633    if (current != status) {
3634        $('#indicator-data-view-status').text(status);
3635    }
3636}
3637
3638/**
3639 * @param {Array} unit
3640 * @return null
3641 */
3642function updateIndicatorDataUnitStatus(unit) {
3643    var status = translations.indicator.announce_unit_switched + translations.t(unit);
3644    var current = $('#indicator-data-unit-status').text();
3645    if (current != status) {
3646        $('#indicator-data-unit-status').text(status);
3647    }
3648}
3649
3650/**
3651 * @param {Array} series
3652 * @return null
3653 */
3654function updateIndicatorDataSeriesStatus(series) {
3655    var status = translations.indicator.announce_series_switched + translations.t(series);
3656    var current = $('#indicator-data-series-status').text();
3657    if (current != status) {
3658        $('#indicator-data-series-status').text(status);
3659    }
3660}
3661
3662/**
3663 * @param {String} contrast
3664 * @param {Object} chartInfo
3665 * @return null
3666 */
3667function updateHeadlineColor(contrast, chartInfo) {
3668    if (chartInfo.data.datasets.length > 0) {
3669        var firstDataset = chartInfo.data.datasets[0];
3670        var isHeadline = (typeof firstDataset.disaggregation === 'undefined');
3671        if (isHeadline) {
3672            var newColor = getHeadlineColor(contrast);
3673            firstDataset.backgroundColor = newColor;
3674            firstDataset.borderColor = newColor;
3675            firstDataset.pointBackgroundColor = newColor;
3676            firstDataset.pointBorderColor = newColor;
3677        }
3678    }
3679}
3680
3681/**
3682 * @param {String} contrast
3683 * @return {String} The headline color in hex form.
3684 */
3685function getHeadlineColor(contrast) {
3686    return isHighContrast(contrast) ? '#55a6e5' : '#004466';
3687}
3688
3689/**
3690 * @param {String} contrast
3691 * @return {String} The grid color in hex form.
3692 */
3693function getGridColor(contrast) {
3694    return isHighContrast(contrast) ? '#222' : '#ddd';
3695};
3696
3697/**
3698 * @param {String} contrast
3699 * @return {String} The tick color in hex form.
3700 */
3701function getTickColor(contrast) {
3702    return isHighContrast(contrast) ? '#fff' : '#000';
3703}
3704
3705function getChartConfig(chartInfo) {
3706    var chartType = chartInfo.chartType;
3707    if (typeof opensdg.chartTypes[chartType] === 'undefined') {
3708        console.log('Unknown chart type: ' + chartType + '. Falling back to "line".');
3709        chartType = 'line';
3710    }
3711    return opensdg.chartTypes[chartType](chartInfo);
3712}
3713
3714function setPlotEvents(chartInfo) {
3715    window.addEventListener('contrastChange', function (e) {
3716        var gridColor = getGridColor(e.detail);
3717        var tickColor = getTickColor(e.detail);
3718        updateHeadlineColor(e.detail, VIEW._chartInstance);
3719        updateGraphAnnotationColors(e.detail, VIEW._chartInstance);
3720        VIEW._chartInstance.options.scales.y.title.color = tickColor;
3721        VIEW._chartInstance.options.scales.x.title.color = tickColor;
3722        VIEW._chartInstance.options.scales.y.ticks.color = tickColor;
3723        VIEW._chartInstance.options.scales.x.ticks.color = tickColor;
3724        VIEW._chartInstance.options.scales.y.grid.color = function(line) {
3725            return (line.index === 0) ? tickColor : gridColor;
3726        };
3727        VIEW._chartInstance.options.scales.x.grid.color = function(line) {
3728            return (line.index === 0) ? tickColor : 'transparent';
3729        };
3730
3731        VIEW._chartInstance.update();
3732        $(VIEW._legendElement).html(generateChartLegend(VIEW._chartInstance));
3733    });
3734
3735    createDownloadButton(chartInfo.selectionsTable, 'Chart', chartInfo.indicatorId, '#chartSelectionDownload', chartInfo.selectedSeries, chartInfo.selectedUnit);
3736    createSourceButton(chartInfo.shortIndicatorId, '#chartSelectionDownload');
3737    createIndicatorDownloadButtons(chartInfo.indicatorDownloads, chartInfo.shortIndicatorId, '#chartSelectionDownload');
3738
3739    $("#btnSave").click(function () {
3740        var filename = chartInfo.indicatorId + '.png',
3741            element = document.getElementById('chart-canvas'),
3742            height = element.clientHeight + 50,
3743            width = element.clientWidth + 50;
3744        var options = {
3745            // These options fix the height, width, and position.
3746            height: height,
3747            width: width,
3748            windowHeight: height,
3749            windowWidth: width,
3750            x: 0,
3751            y: 0,
3752            scrollX: 0,
3753            scrollY: 0,
3754            scale: 2,
3755            backgroundColor: isHighContrast() ? '#000000' : '#FFFFFF',
3756            // Allow a chance to alter the screenshot's HTML.
3757            onclone: function (clone) {
3758                // Add a body class so that the screenshot style can be custom.
3759                clone.body.classList.add('image-download-in-progress');
3760            },
3761            // Decide which elements to skip.
3762            ignoreElements: function (el) {
3763                // Keep all style, head, and link elements.
3764                var keepTags = ['STYLE', 'HEAD', 'LINK'];
3765                if (keepTags.indexOf(el.tagName) !== -1) {
3766                    return false;
3767                }
3768                // Keep all elements contained by (or containing) the screenshot
3769                // target element.
3770                if (element.contains(el) || el.contains(element)) {
3771                    return false;
3772                }
3773                // Leave out everything else.
3774                return true;
3775            }
3776        };
3777        // First convert the target to a canvas.
3778        html2canvas(element, options).then(function (canvas) {
3779            // Then download that canvas as a PNG file.
3780            canvas.toBlob(function (blob) {
3781                saveAs(blob, filename);
3782            });
3783        });
3784    });
3785}
3786
3787/**
3788 * @param {Object} chartInfo
3789 * @return null
3790 */
3791function createPlot(chartInfo, helpers) {
3792
3793    var chartConfig = getChartConfig(chartInfo);
3794    chartConfig.indicatorViewHelpers = helpers;
3795    alterChartConfig(chartConfig, chartInfo);
3796    if (isHighContrast()) {
3797        updateGraphAnnotationColors('high', chartConfig);
3798        updateHeadlineColor('high', chartConfig);
3799    }
3800    else {
3801        updateHeadlineColor('default', chartConfig);
3802    }
3803    refreshChartLineWrapping(chartConfig);
3804
3805    VIEW._chartInstance = new Chart($(OPTIONS.rootElement).find('canvas'), chartConfig);
3806    $(VIEW._legendElement).html(generateChartLegend(VIEW._chartInstance));
3807};
3808
3809/**
3810 * @param {Object} chartInfo
3811 * @return null
3812 */
3813 function updatePlot(chartInfo) {
3814    // If we have changed type, we will need to destroy and recreate the chart.
3815    // So we can abort here.
3816    var updatedConfig = getChartConfig(chartInfo);
3817    if (updatedConfig.type !== VIEW._chartInstance.config.type) {
3818        VIEW._chartInstance.destroy();
3819        createPlot(chartInfo);
3820        return;
3821    }
3822    updateIndicatorDataViewStatus(VIEW._chartInstance.data.datasets, updatedConfig.data.datasets);
3823    updateHeadlineColor(isHighContrast() ? 'high' : 'default', updatedConfig);
3824
3825    if (chartInfo.selectedUnit) {
3826        updatedConfig.options.scales.y.title.text = translations.t(chartInfo.selectedUnit);
3827    }
3828
3829    alterChartConfig(updatedConfig, chartInfo);
3830    refreshChartLineWrapping(updatedConfig);
3831    VIEW._chartInstance.config.type = updatedConfig.type;
3832    VIEW._chartInstance.data.datasets = updatedConfig.data.datasets;
3833    VIEW._chartInstance.data.labels = updatedConfig.data.labels;
3834    VIEW._chartInstance.options = updatedConfig.options;
3835    updateGraphAnnotationColors(isHighContrast() ? 'high' : 'default', updatedConfig);
3836
3837    // The following is needed in our custom "rescaler" plugin.
3838    VIEW._chartInstance.data.allLabels = VIEW._chartInstance.data.labels.slice(0);
3839
3840    VIEW._chartInstance.update();
3841
3842    $(VIEW._legendElement).html(generateChartLegend(VIEW._chartInstance));
3843    updateChartDownloadButton(chartInfo.selectionsTable, chartInfo.selectedSeries, chartInfo.selectedUnit);
3844};
3845
3846/**
3847 * @param {String} contrast
3848 * @param {Object} chartInfo
3849 * @return null
3850 */
3851function updateGraphAnnotationColors(contrast, chartInfo) {
3852    if (chartInfo.options.plugins.annotation) {
3853        chartInfo.options.plugins.annotation.annotations.forEach(function (annotation) {
3854            if (contrast === 'default') {
3855                $.extend(true, annotation, annotation.defaultContrast);
3856            }
3857            else if (contrast === 'high') {
3858                $.extend(true, annotation, annotation.highContrast);
3859            }
3860        });
3861    }
3862}
3863
3864/**
3865 * @param {Object} chart
3866 * @return {String} The HTML of the chart legend
3867 */
3868function generateChartLegend(chart) {
3869    var text = [];
3870    text.push('<h5 class="sr-only">' + translations.indicator.plot_legend_description + '</h5>');
3871    text.push('<ul id="legend" class="legend-for-' + chart.config.type + '-chart">');
3872    _.each(chart.data.datasets, function (dataset) {
3873        text.push('<li>');
3874        text.push('<span class="swatch' + (dataset.borderDash ? ' dashed' : '') + (dataset.headline ? ' headline' : '') + '" style="background-color: ' + dataset.borderColor + '">');
3875        text.push('<span class="swatch-inner" style="background-color: ' + dataset.borderColor + '"></span>');
3876        text.push('</span>');
3877        text.push(translations.t(dataset.label));
3878        text.push('</li>');
3879    });
3880    text.push('</ul>');
3881    return text.join('');
3882}
3883
3884/**
3885 * @param {Object} chartConfig
3886 */
3887function refreshChartLineWrapping(chartConfig) {
3888    var yAxisLimit = 40,
3889        wrappedYAxis = strToArray(chartConfig.options.scales.y.title.text, yAxisLimit);
3890    chartConfig.options.scales.y.title.text = wrappedYAxis;
3891}
3892
3893/**
3894 * @param {String} str
3895 * @param {Number} limit
3896 * @returns {Array} The string divided into an array for line wrapping.
3897 */
3898function strToArray (str, limit) {
3899    var words = str.split(' '),
3900        aux = [],
3901        concat = [];
3902
3903    for (var i = 0; i < words.length; i++) {
3904        concat.push(words[i]);
3905        var join = concat.join(' ');
3906        if (join.length > limit) {
3907            aux.push(join);
3908            concat = [];
3909        }
3910    }
3911
3912    if (concat.length) {
3913        aux.push(concat.join(' ').trim());
3914    }
3915
3916    return aux;
3917}
3918
3919  opensdg.annotationPresets = {
3920    common: {
3921        // This "common" preset is applied to all annotations automatically.
3922        borderColor: '#949494',
3923        type: 'line',
3924        borderDash: [10, 5],
3925        borderWidth: 1,
3926        label: {
3927            backgroundColor: 'white',
3928            color: 'black',
3929            borderWidth: 1,
3930            borderColor: 'black',
3931        },
3932        // This "highContrast" overrides colors when in high-contrast mode.
3933        highContrast: {
3934            label: {
3935                backgroundColor: 'black',
3936                color: 'white',
3937                borderWidth: 1,
3938                borderColor: 'white',
3939            },
3940        },
3941        // This callback is used to generate a generic description for screenreaders.
3942        // This can be overridden to be a more specific string, eg:
3943        //
3944        //     description: 'Chart annotation showing a 2030 target of 15%'
3945        //
3946        description: function() {
3947            var descriptionParts = [translations.indicator.chart_annotation];
3948            if (this.label && this.label.content) {
3949                descriptionParts.push(translations.t(this.label.content));
3950            }
3951            else {
3952                // If there is no label, just specify whether it is a box or line.
3953                if (this.type == 'line') {
3954                    descriptionParts.push(this.mode + ' line');
3955                }
3956                if (this.type == 'box') {
3957                    descriptionParts.push('box');
3958                }
3959            }
3960            if (typeof this.value !== 'undefined') {
3961                descriptionParts.push(this.value);
3962            }
3963            return descriptionParts.join(': ');
3964        },
3965    },
3966    target_line: {
3967        mode: 'horizontal',
3968        borderWidth: 2,
3969        borderDash: [15, 10],
3970        borderColor: '#757575',
3971        label: {
3972            position: 'end',
3973            content: translations.indicator.annotation_2030_target,
3974        },
3975    },
3976    series_break: {
3977        mode: 'vertical',
3978        borderDash: [2, 2],
3979        label: {
3980            position: 'start',
3981            content: translations.indicator.annotation_series_break,
3982        },
3983    },
3984};
3985
3986opensdg.chartTypes = opensdg.chartTypes || {};
3987opensdg.chartTypes.base = function(info) {
3988
3989    var gridColor = getGridColor();
3990    var tickColor = getTickColor();
3991
3992    var config = {
3993        type: null,
3994        data: {
3995            datasets: info.datasets,
3996            labels: info.labels,
3997        },
3998        options: {
3999            responsive: true,
4000            maintainAspectRatio: false,
4001            spanGaps: true,
4002            scrollX: true,
4003            scrollCollapse: true,
4004            sScrollXInner: '150%',
4005            scales: {
4006                x: {
4007                    grid: {
4008                        color: function(line) {
4009                            return (line.index === 0) ? tickColor : 'transparent';
4010                        },
4011                    },
4012                    ticks: {
4013                        color: tickColor,
4014                    },
4015                    title: {
4016                        display: MODEL.xAxisLabel ? true : false,
4017                        text: MODEL.xAxisLabel,
4018                        color: tickColor,
4019                        font: {
4020                            size: 14,
4021                            family: "'Open Sans', Helvetica, Arial, sans-serif",
4022                        },
4023                    },
4024                },
4025                y: {
4026                    grid: {
4027                        color: function(line) {
4028                            return (line.index === 0) ? tickColor : gridColor;
4029                        },
4030                        drawBorder: false,
4031                    },
4032                    suggestedMin: 0,
4033                    ticks: {
4034                        color: tickColor,
4035                        callback: function (value) {
4036                            return alterDataDisplay(value, undefined, 'chart y-axis tick');
4037                        },
4038                    },
4039                    title: {
4040                        display: MODEL.selectedUnit ? translations.t(MODEL.selectedUnit) : MODEL.measurementUnit,
4041                        text: MODEL.selectedUnit ? translations.t(MODEL.selectedUnit) : MODEL.measurementUnit,
4042                        color: tickColor,
4043                        font: {
4044                            size: 14,
4045                            family: "'Open Sans', Helvetica, Arial, sans-serif",
4046                        },
4047                    }
4048                }
4049            },
4050            plugins: {
4051                scaler: {},
4052                title: {
4053                    display: false
4054                },
4055                legend: {
4056                    display: false,
4057                },
4058                tooltip: {
4059                    usePointStyle: true,
4060                    backgroundColor: 'rgba(0,0,0,0.7)',
4061                    callbacks: {
4062                        label: function (tooltipItem) {
4063                            return translations.t(tooltipItem.dataset.label) + ': ' + alterDataDisplay(tooltipItem.raw, tooltipItem.dataset, 'chart tooltip', tooltipItem);
4064                        },
4065                        afterBody: function () {
4066                            var unit = MODEL.selectedUnit ? translations.t(MODEL.selectedUnit) : MODEL.measurementUnit;
4067                            if (typeof unit !== 'undefined' && unit !== '') {
4068                                return '\n' + translations.indicator.unit + ': ' + unit;
4069                            }
4070                        },
4071                    },
4072                },
4073            },
4074        }
4075    };
4076
4077    if (info.graphLimits && Object.keys(info.graphLimits).length > 0) {
4078        var overrides = {
4079            options: {
4080                scales: {
4081                    y: {
4082                        min: info.graphLimits.minimum,
4083                        max: info.graphLimits.maximum,
4084                    }
4085                }
4086            }
4087        }
4088        // Add these overrides onto the normal config.
4089        _.merge(config, overrides);
4090    }
4091    else {
4092        // Otherwise remove any min/max that may be there.
4093        try {
4094            delete config.options.scales.y.min;
4095            delete config.options.scales.y.max;
4096        }
4097        catch (e) { }
4098    }
4099
4100    if (info.graphAnnotations && info.graphAnnotations.length > 0) {
4101        // Apply some helpers/fixes to the annotations.
4102        var annotations = info.graphAnnotations.map(function(annotationOverrides) {
4103            var annotation = {};
4104            // Apply the "common" preset.
4105            if (opensdg.annotationPresets.common) {
4106                $.extend(true, annotation, opensdg.annotationPresets.common);
4107            }
4108            // Apply any specified presets.
4109            if (annotationOverrides.preset) {
4110                var preset = annotationOverrides.preset;
4111                if (opensdg.annotationPresets[preset]) {
4112                    $.extend(true, annotation, opensdg.annotationPresets[preset]);
4113                }
4114            }
4115            // Now add any more annotation config.
4116            $.extend(true, annotation, annotationOverrides);
4117            // Default to horizontal lines.
4118            if (!annotation.mode && annotation.type === 'line') {
4119                annotation.mode = 'horizontal';
4120            }
4121            // Provide the obscure scaleID properties on user's behalf.
4122            if (!annotation.scaleID && annotation.type === 'line') {
4123                if (annotation.mode === 'horizontal') {
4124                    annotation.scaleID = 'y';
4125                }
4126                if (annotation.mode === 'vertical') {
4127                    annotation.scaleID = 'x';
4128                }
4129            }
4130            if (!annotation.xScaleID && annotation.type === 'box') {
4131                annotation.xScaleID = 'x';
4132            }
4133            if (!annotation.yScaleID && annotation.type === 'box') {
4134                annotation.yScaleID = 'y';
4135            }
4136            // Provide the "enabled" label property on the user's behalf.
4137            if (annotation.label && annotation.label.content) {
4138                annotation.label.enabled = true;
4139            }
4140            // Translate any label content.
4141            if (annotation.label && annotation.label.content) {
4142                annotation.label.content = translations.t(annotation.label.content);
4143            }
4144            // Fix some keys where there was once a discrepancy between
4145            // Open SDG and Chart.js. Eg, we mistakenly used "fontColor"
4146            // instead of "color".
4147            if (annotation.label && annotation.label.fontColor) {
4148                annotation.label.color = annotation.label.fontColor;
4149            }
4150            if (annotation.highContrast && annotation.highContrast.label) {
4151                if (annotation.highContrast.label.fontColor) {
4152                    annotation.highContrast.label.color = annotation.highContrast.label.fontColor;
4153                }
4154            }
4155            // We also used the wrong values for label position.
4156            if (annotation.label &&
4157                annotation.label.position &&
4158                (annotation.label.position == 'top' ||
4159                 annotation.label.position == 'left')) {
4160                // It should be 'start' instead of 'top' or 'left'.
4161                annotation.label.position = 'start';
4162            }
4163            if (annotation.label &&
4164                annotation.label.position &&
4165                (annotation.label.position == 'bottom' ||
4166                 annotation.label.position == 'right')) {
4167                // It should be 'start' instead of 'top' or 'left'.
4168                annotation.label.position = 'end';
4169            }
4170            // Save some original values for later used when contrast mode is switched.
4171            if (typeof annotation.defaultContrast === 'undefined') {
4172                annotation.defaultContrast = {};
4173                if (annotation.borderColor) {
4174                    annotation.defaultContrast.borderColor = annotation.borderColor;
4175                }
4176                if (annotation.backgroundColor) {
4177                    annotation.defaultContrast.backgroundColor = annotation.backgroundColor;
4178                }
4179                if (annotation.label) {
4180                    annotation.defaultContrast.label = {};
4181                    if (annotation.label.color) {
4182                        annotation.defaultContrast.label.color = annotation.label.color;
4183                    }
4184                    if (annotation.label.backgroundColor) {
4185                        annotation.defaultContrast.label.backgroundColor = annotation.label.backgroundColor;
4186                    }
4187                    if (annotation.label.borderWidth) {
4188                        annotation.defaultContrast.label.borderWidth = annotation.label.borderWidth;
4189                    }
4190                    if (annotation.label.borderColor) {
4191                        annotation.defaultContrast.label.borderColor = annotation.label.borderColor;
4192                    }
4193                }
4194            }
4195            return annotation;
4196        });
4197        if (annotations.length > 0) {
4198            var overrides = {
4199                options: {
4200                    plugins: {
4201                        annotation: {
4202                            drawTime: 'afterDatasetsDraw',
4203                            annotations: annotations
4204                        }
4205                    }
4206                }
4207            };
4208            // Add these overrides onto the normal config.
4209            _.merge(config, overrides);
4210
4211            // Update the chart annotations element.
4212            var descriptions = annotations.map(function(annotation) {
4213                var description = '';
4214                if (annotation.description) {
4215                    if (typeof annotation.description === 'function') {
4216                        description = annotation.description.call(annotation);
4217                    }
4218                    else {
4219                        description = translations.t(annotation.description);
4220                    }
4221                }
4222                return description;
4223            }).filter(function(description) { return description != ''; });
4224
4225            var currentDescription = $('#chart-annotations').text();
4226            var newDescription = descriptions.join('. ');
4227            if (currentDescription != newDescription) {
4228                $('#chart-annotations').text(newDescription);
4229            }
4230        }
4231    }
4232    else if (config.options && config.options.annotation) {
4233        delete config.options.annotation;
4234    }
4235
4236    return config;
4237}
4238
4239  opensdg.chartTypes.line = function(info) {
4240    var config = opensdg.chartTypes.base(info);
4241    var overrides = {
4242        type: 'line',
4243        options: {
4244            plugins: {
4245                tooltip: {
4246                    mode: 'index',
4247                    intersect: false,
4248                },
4249            },
4250        },
4251        plugins: [{
4252            beforeDatasetsDraw: function(chart) {
4253                if (chart.tooltip._active && chart.tooltip._active.length) {
4254                    var activePoint = chart.tooltip._active[0],
4255                        ctx = chart.ctx,
4256                        x = activePoint.element.x,
4257                        topY = chart.scales.y.top,
4258                        bottomY = chart.scales.y.bottom;
4259
4260                    // draw line
4261                    ctx.save();
4262                    ctx.beginPath();
4263                    ctx.moveTo(x, topY);
4264                    ctx.lineTo(x, bottomY);
4265                    ctx.lineWidth = 2;
4266                    ctx.strokeStyle = '#757575';
4267                    ctx.stroke();
4268                    ctx.restore();
4269                }
4270            }
4271        }],
4272    };
4273    // Add these overrides onto the normal config, and return it.
4274    _.merge(config, overrides);
4275    return config;
4276}
4277  opensdg.chartTypes.bar = function (info) {
4278    var config = opensdg.chartTypes.base(info);
4279    var overrides = {
4280        type: 'bar',
4281    };
4282    if (info.stackedDisaggregation) {
4283        overrides.options = {
4284            scales: {
4285                x: { stacked: true },
4286                y: { stacked: true },
4287            }
4288        };
4289        // If we have stackedDisaggregation, we need to group datasets into stacks.
4290        config.data.datasets.forEach(function (dataset) {
4291            var disaggregation = $.extend({}, dataset.disaggregation);
4292            // We're going to "stringify" each combination of disaggregations in order
4293            // to place them in their own "stacks". To place "stacked" disaggregations
4294            // into the same stack, we set them as "samestack" before stringifying.
4295            // Note that the string "samestack" is completely arbitrary.
4296            if (typeof disaggregation[info.stackedDisaggregation] !== 'undefined') {
4297                disaggregation[info.stackedDisaggregation] = 'samestack';
4298            }
4299            // Use the disaggregation as a unique id for each stack.
4300            dataset.stack = JSON.stringify(disaggregation);
4301        });
4302    }
4303    // Manually set the borderWidths to 0 to avoid a weird border effect on the bars.
4304    config.data.datasets.forEach(function(dataset) {
4305        dataset.borderWidth = 0;
4306    });
4307    // Add these overrides onto the normal config, and return it.
4308    _.merge(config, overrides);
4309    return config;
4310}
4311  opensdg.convertBinaryValue = function (value) {
4312    if (typeof value === 'string') {
4313        value = parseInt(value, 10);
4314    }
4315    if (value === 1) {
4316        return translations.indicator.affirmative;
4317    }
4318    else if (value === -1) {
4319        return translations.indicator.negative;
4320    }
4321    return '';
4322}
4323
4324opensdg.chartTypes.binary = function (info) {
4325    var config = opensdg.chartTypes.base(info);
4326    var overrides = {
4327        // Force the "bar" type instead of the "binary" type which Chart.js
4328        // does not recognize.
4329        type: 'bar',
4330        // Assign some callbacks to convert 1/-1 to Yes/No.
4331        options: {
4332            plugins: {
4333                tooltip: {
4334                    callbacks: {
4335                        label: function (tooltipItem) {
4336                            var label = tooltipItem.dataset.label || '';
4337                            label += ': ' + opensdg.convertBinaryValue(tooltipItem.formattedValue);
4338                            return label;
4339                        },
4340                    },
4341                },
4342            },
4343            scales: {
4344                y: {
4345                    // Set the min/max to -1/1 so that the bars will start from the
4346                    // middle and either go up (for 1) or down (for -1).
4347                    min: -1,
4348                    max: 1,
4349                    ticks: {
4350                        callback: opensdg.convertBinaryValue,
4351                    },
4352                },
4353            },
4354        }
4355    }
4356
4357    // Tweak the data so that 0 is treated as -1. This is done so that the bar appears
4358    // to be pointed down.
4359    config.data.datasets = config.data.datasets.map(function(dataset) {
4360        dataset.data = dataset.data.map(function(value) {
4361            if (value === 0) {
4362                return -1;
4363            }
4364            return value;
4365        });
4366        return dataset;
4367    });
4368
4369    // Manually set the borderWidths to 0 to avoid a weird border effect on the bars.
4370    config.data.datasets.forEach(function(dataset) {
4371        dataset.borderWidth = 0;
4372    });
4373
4374    // Add these overrides onto the normal config.
4375    _.merge(config, overrides);
4376    return config;
4377};
4378
4379  /**
4380 * @param {Object} config
4381 * @param {Object} info
4382 * @return null
4383 */
4384function alterTableConfig(config, info) {
4385    opensdg.tableConfigAlterations.forEach(function (callback) {
4386        callback(config, info);
4387    });
4388}
4389
4390/**
4391 * @param {Object} tableData
4392 * @return {String}
4393 */
4394function toCsv(tableData, selectedSeries, selectedUnit) {
4395    var lines = [],
4396        dataHeadings = _.map(tableData.headings, function (heading) { return '"' + translations.t(heading) + '"'; }),
4397        metaHeadings = [];
4398
4399    if (selectedSeries) {
4400        metaHeadings.push(translations.indicator.series);
4401    }
4402    if (selectedUnit) {
4403        metaHeadings.push(translations.indicator.unit);
4404    }
4405    var allHeadings = dataHeadings.concat(metaHeadings);
4406
4407    lines.push(allHeadings.join(','));
4408
4409    _.each(tableData.data, function (dataValues) {
4410        var line = [];
4411
4412        _.each(dataHeadings, function (heading, index) {
4413            line.push(dataValues[index]);
4414        });
4415        if (selectedSeries) {
4416            line.push(JSON.stringify(translations.t(selectedSeries)));
4417        }
4418        if (selectedUnit) {
4419            line.push(JSON.stringify(translations.t(selectedUnit)));
4420        }
4421
4422        lines.push(line.join(','));
4423    });
4424
4425    return lines.join('\n');
4426}
4427
4428/**
4429 * @param {Element} el
4430 * @param {Object} info
4431 * @return null
4432 */
4433function initialiseDataTable(el, info) {
4434    var nonYearColumns = [];
4435    for (var i = 1; i < info.table.headings.length; i++) {
4436        nonYearColumns.push(i);
4437    }
4438
4439    var datatables_options = OPTIONS.datatables_options || {
4440        paging: false,
4441        bInfo: false,
4442        bAutoWidth: false,
4443        searching: false,
4444        responsive: false,
4445        order: [[0, 'asc']],
4446        columnDefs: [
4447            {
4448                targets: nonYearColumns,
4449                createdCell: function (td, cellData, rowData, row, col) {
4450                    var additionalInfo = Object.assign({}, info);
4451                    additionalInfo.row = row;
4452                    additionalInfo.col = col;
4453                    if (info.chartType === 'binary') {
4454                        var cellDataInt = Number(cellData);
4455                        if (cellDataInt === 1) {
4456                            cellData = translations.indicator.affirmative;
4457                        }
4458                        else if (cellDataInt === 0 || cellDataInt === -1) {
4459                            cellData = translations.indicator.negative;
4460                        }
4461                    }
4462                    $(td).text(alterDataDisplay(cellData, rowData, 'table cell', additionalInfo));
4463                },
4464            },
4465        ],
4466    }, table = $(el).find('table');
4467
4468    datatables_options.aaSorting = [];
4469
4470    alterTableConfig(datatables_options, info);
4471    table.DataTable(datatables_options);
4472    table.removeAttr('role');
4473    table.find('thead th').removeAttr('rowspan').removeAttr('colspan').removeAttr('aria-label');
4474    setDataTableWidth(table);
4475};
4476
4477/**
4478 * @param {Object} chartInfo
4479 * @return null
4480 */
4481function createSelectionsTable(chartInfo) {
4482    createTable(chartInfo.selectionsTable, chartInfo.indicatorId, '#selectionsTable', chartInfo.isProxy, chartInfo.observationAttributesTable, chartInfo.chartType);
4483    $('#tableSelectionDownload').empty();
4484    createTableTargetLines(chartInfo.graphAnnotations);
4485    createDownloadButton(chartInfo.selectionsTable, 'Table', chartInfo.indicatorId, '#tableSelectionDownload', chartInfo.selectedSeries, chartInfo.selectedUnit);
4486    createSourceButton(chartInfo.shortIndicatorId, '#tableSelectionDownload');
4487    createIndicatorDownloadButtons(chartInfo.indicatorDownloads, chartInfo.shortIndicatorId, '#tableSelectionDownload');
4488};
4489
4490/**
4491 * @param {Array} graphAnnotations
4492 * @return null
4493 */
4494function createTableTargetLines(graphAnnotations) {
4495    var targetLines = graphAnnotations.filter(function (a) { return a.preset === 'target_line'; });
4496    var $targetLines = $('#tableTargetLines');
4497    $targetLines.empty();
4498    targetLines.forEach(function (targetLine) {
4499        var targetLineLabel = targetLine.label.content;
4500        if (!targetLineLabel) {
4501            targetLineLabel = opensdg.annotationPresets.target_line.label.content;
4502        }
4503        $targetLines.append('<dt>' + targetLineLabel + '</dt><dd>' + alterDataDisplay(targetLine.value, targetLine, 'target line') + '</dd>');
4504    });
4505    if (targetLines.length === 0) {
4506        $targetLines.hide();
4507    }
4508    else {
4509        $targetLines.show();
4510    }
4511}
4512
4513/**
4514 * @param {Object} table
4515 * @return bool
4516 */
4517function tableHasData(table) {
4518    for (var i = 0; i < table.data.length; i++) {
4519        if (table.data[i].length > 1) {
4520            return true;
4521        }
4522    }
4523    return false;
4524}
4525
4526/**
4527 * @param {Object} table
4528 * @param {String} indicatorId
4529 * @param {Element} el
4530 * @param {bool} isProxy
4531 * @param {Object} observationAttributesTable
4532 * @param {String} chartType
4533 * @return null
4534 */
4535function createTable(table, indicatorId, el, isProxy, observationAttributesTable, chartType) {
4536
4537    var table_class = OPTIONS.table_class || 'table table-hover';
4538
4539    // clear:
4540    $(el).html('');
4541
4542    if (table && tableHasData(table)) {
4543        var currentTable = $('<table />').attr({
4544            'class': table_class,
4545            'width': '100%'
4546        });
4547
4548        var tableTitle = MODEL.chartTitle;
4549        if (isProxy) {
4550            tableTitle += ' ' + PROXY_PILL;
4551        }
4552        currentTable.append('<caption>' + tableTitle + '</caption>');
4553
4554        var table_head = '<thead><tr>';
4555
4556        var getHeading = function (heading, index) {
4557            var arrows = '<span class="sort"><i class="fa fa-sort"></i><i class="fa fa-sort-down"></i><i class="fa fa-sort-up"></i></span>';
4558            var button = '<span tabindex="0" role="button" aria-describedby="column-sort-info">' + translations.t(heading) + '</span>';
4559            return button + arrows;
4560        };
4561
4562        table.headings.forEach(function (heading, index) {
4563            table_head += '<th' + (!index ? '' : ' class="table-value"') + ' scope="col">' + getHeading(heading, index) + '</th>';
4564        });
4565
4566        table_head += '</tr></thead>';
4567        currentTable.append(table_head);
4568        currentTable.append('<tbody></tbody>');
4569
4570        table.data.forEach(function (data) {
4571            var row_html = '<tr>';
4572            table.headings.forEach(function (heading, index) {
4573                // For accessibility set the Year column to a "row" scope th.
4574                var isYear = (index == 0);
4575                var cell_prefix = (isYear) ? '<th scope="row"' : '<td';
4576                var cell_suffix = (isYear) ? '</th>' : '</td>';
4577                row_html += cell_prefix + (isYear ? '' : ' class="table-value"') + '>' + (data[index] !== null && data[index] !== undefined ? data[index] : '-') + cell_suffix;
4578            });
4579            row_html += '</tr>';
4580            currentTable.find('tbody').append(row_html);
4581        });
4582
4583        $(el).append(currentTable);
4584
4585        // initialise data table and provide some info for alterations.
4586        var alterationInfo = {
4587            table: table,
4588            indicatorId: indicatorId,
4589            observationAttributesTable: observationAttributesTable,
4590            chartType: chartType,
4591        };
4592        initialiseDataTable(el, alterationInfo);
4593
4594        $(el).removeClass('table-has-no-data');
4595        $('#selectionTableFooter').show();
4596
4597        $(el).find('th')
4598            .removeAttr('tabindex')
4599            .click(function () {
4600                var sortDirection = $(this).attr('aria-sort');
4601                $(this).find('span[role="button"]').attr('aria-sort', sortDirection);
4602            });
4603
4604        let tableWrapper = document.querySelector('.dataTables_wrapper');
4605        if (tableWrapper) {
4606            tableWrapper.addEventListener('scroll', function(e) {
4607                if (tableWrapper.scrollLeft > 0) {
4608                    tableWrapper.classList.add('scrolled-x');
4609                }
4610                else {
4611                    tableWrapper.classList.remove('scrolled-x');
4612                }
4613                if (tableWrapper.scrollTop > 0) {
4614                    tableWrapper.classList.add('scrolled-y');
4615                }
4616                else {
4617                    tableWrapper.classList.remove('scrolled-y');
4618                }
4619            });
4620        }
4621    } else {
4622        $(el).append($('<h3 />').text(translations.indicator.data_not_available));
4623        $(el).addClass('table-has-no-data');
4624        $('#selectionTableFooter').hide();
4625    }
4626}
4627
4628/**
4629 * @param {Object} table
4630 * @return null
4631 */
4632function setDataTableWidth(table) {
4633    table.find('thead th').each(function () {
4634        var textLength = $(this).text().length;
4635        for (var loop = 0; loop < VIEW._tableColumnDefs.length; loop++) {
4636            var def = VIEW._tableColumnDefs[loop];
4637            if (textLength < def.maxCharCount) {
4638                if (!def.width) {
4639                    $(this).css('white-space', 'nowrap');
4640                } else {
4641                    $(this).css('width', def.width + 'px');
4642                    $(this).data('width', def.width);
4643                }
4644                break;
4645            }
4646        }
4647    });
4648
4649    table.removeAttr('style width');
4650
4651    var totalWidth = 0;
4652    table.find('thead th').each(function () {
4653        if ($(this).data('width')) {
4654            totalWidth += $(this).data('width');
4655        } else {
4656            totalWidth += $(this).width();
4657        }
4658    });
4659
4660    // ascertain whether the table should be width 100% or explicit width:
4661    var containerWidth = table.closest('.dataTables_wrapper').width();
4662
4663    if (totalWidth > containerWidth && containerWidth > 0) {
4664        table.css('width', totalWidth + 'px');
4665    } else {
4666        table.css('width', '100%');
4667    }
4668}
4669
4670/**
4671 * @param {Object} table
4672 * @return null
4673 */
4674function updateChartDownloadButton(table, selectedSeries, selectedUnit) {
4675    if (typeof VIEW._chartDownloadButton !== 'undefined') {
4676        var tableCsv = toCsv(table, selectedSeries, selectedUnit);
4677        var blob = new Blob([tableCsv], {
4678            type: 'text/csv'
4679        });
4680        var fileName = VIEW._chartDownloadButton.attr('download');
4681        if (window.navigator && window.navigator.msSaveBlob) {
4682            // Special behavior for IE.
4683            VIEW._chartDownloadButton.off('click.openSdgDownload')
4684            VIEW._chartDownloadButton.on('click.openSdgDownload', function (event) {
4685                window.navigator.msSaveBlob(blob, fileName);
4686            });
4687        }
4688        else {
4689            VIEW._chartDownloadButton
4690                .attr('href', URL.createObjectURL(blob))
4691                .data('csvdata', tableCsv);
4692        }
4693    }
4694}
4695
4696  /**
4697 * @param {null|undefined|Float|String} value
4698 * @param {Object} info
4699 * @param {Object} context
4700 * @param {Object} additionalInfo
4701 * @return {null|undefined|Float|String}
4702 */
4703function alterDataDisplay(value, info, context, additionalInfo) {
4704    // If value is empty, we will not alter it.
4705    if (value == null || value == undefined) {
4706        return value;
4707    }
4708    // Before passing to user-defined dataDisplayAlterations, let's
4709    // do our best to ensure that it starts out as a number.
4710    var altered = value;
4711    if (typeof altered !== 'number') {
4712        altered = Number(value);
4713    }
4714    // If that gave us a non-number, return original.
4715    if (isNaN(altered)) {
4716        return value;
4717    }
4718    // Now go ahead with user-defined alterations.
4719    opensdg.dataDisplayAlterations.forEach(function (callback) {
4720        altered = callback(altered, info, context);
4721    });
4722    // If the returned value is not a number, use the legacy logic for
4723    // precision and decimal separator.
4724    if (typeof altered !== 'number') {
4725        // Now apply our custom precision control if needed.
4726        if (VIEW._precision || VIEW._precision === 0) {
4727            altered = Number.parseFloat(altered).toFixed(VIEW._precision);
4728        }
4729        // Now apply our custom decimal separator if needed.
4730        if (OPTIONS.decimalSeparator) {
4731            altered = altered.toString().replace('.', OPTIONS.decimalSeparator);
4732        }
4733    }
4734    // Otherwise if we have a number, use toLocaleString instead.
4735    else {
4736        var localeOpts = {};
4737        if (VIEW._precision || VIEW._precision === 0) {
4738            localeOpts.minimumFractionDigits = VIEW._precision;
4739            localeOpts.maximumFractionDigits = VIEW._precision;
4740        }
4741        altered = altered.toLocaleString(opensdg.language_numbers, localeOpts);
4742        // Still use the custom decimal separator if it is there.
4743        if (OPTIONS.decimalSeparator) {
4744            altered = altered.toString().replace(VIEW._browserDecimalSeparator, OPTIONS.decimalSeparator);
4745        }
4746    }
4747    // Now let's add any footnotes from observation attributes.
4748    var obsAttributes = [];
4749    if (context === 'chart tooltip') {
4750        var dataIndex = additionalInfo.dataIndex;
4751        obsAttributes = info.observationAttributes[dataIndex];
4752    }
4753    else if (context === 'table cell') {
4754        var row = additionalInfo.row,
4755            col = additionalInfo.col,
4756            obsAttributesTable = additionalInfo.observationAttributesTable;
4757        obsAttributes = obsAttributesTable.data[row][col];
4758    }
4759    if (obsAttributes.length > 0) {
4760        var obsAttributeFootnoteNumbers = obsAttributes.map(function(obsAttribute) {
4761            return getObservationAttributeFootnoteSymbol(obsAttribute.footnoteNumber);
4762        });
4763        altered += ' ' + obsAttributeFootnoteNumbers.join(' ');
4764    }
4765    return altered;
4766}
4767
4768/**
4769 * Convert a number into a string for observation atttribute footnotes.
4770 *
4771 * @param {int} num 
4772 * @returns {string} Number converted into unicode character for footnotes.
4773 */
4774function getObservationAttributeFootnoteSymbol(num) {
4775    return '[' + translations.indicator.note + ' ' + (num + 1) + ']';
4776}
4777
4778/**
4779 * Figure out what the browser will be using for the decimal separator.
4780 *
4781 * @returns {string} The decimal separator the browser will use.
4782 */
4783function getBrowserDecimalSeparator() {
4784    var browserDecimal = 1.1;
4785    browserDecimal = browserDecimal.toLocaleString(opensdg.language_numbers).substring(1, 2);
4786    return browserDecimal;
4787}
4788
4789  /**
4790 * @param {String} selectedSeries
4791 * @param {String} selectedUnit
4792 * @return null
4793 */
4794function updateSeriesAndUnitElements(selectedSeries, selectedUnit) {
4795    var hasSeries = typeof selectedSeries !== 'undefined',
4796        hasUnit = typeof selectedUnit !== 'undefined',
4797        hasBoth = hasSeries && hasUnit;
4798    if (hasSeries || hasUnit || hasBoth) {
4799        $('[data-for-series], [data-for-unit]').each(function () {
4800            var elementSeries = $(this).data('for-series'),
4801                elementUnit = $(this).data('for-unit'),
4802                seriesMatches = elementSeries === selectedSeries,
4803                unitMatches = elementUnit === selectedUnit;
4804            if ((hasSeries || hasBoth) && !seriesMatches && elementSeries !== '') {
4805                $(this).hide();
4806            }
4807            else if ((hasUnit || hasBoth) && !unitMatches && elementUnit !== '') {
4808                $(this).hide();
4809            }
4810            else {
4811                $(this).show();
4812            }
4813        });
4814    }
4815}
4816
4817/**
4818 * @param {String} contrast
4819 * @return bool
4820 */
4821function isHighContrast(contrast) {
4822    if (contrast) {
4823        return contrast === 'high';
4824    }
4825    else {
4826        return $('body').hasClass('contrast-high');
4827    }
4828}
4829
4830/**
4831 * @param {Object} table
4832 * @param {String} name
4833 * @param {String} indicatorId
4834 * @param {Element} el
4835 * @return null
4836 */
4837function createDownloadButton(table, name, indicatorId, el, selectedSeries, selectedUnit) {
4838    if (window.Modernizr.blobconstructor) {
4839        var downloadKey = 'download_csv';
4840        if (name == 'Chart') {
4841            downloadKey = 'download_chart';
4842        }
4843        if (name == 'Table') {
4844            downloadKey = 'download_table';
4845        }
4846        var gaLabel = 'Download ' + name + ' CSV: ' + indicatorId.replace('indicator_', '');
4847        var tableCsv = toCsv(table, selectedSeries, selectedUnit);
4848        var fileName = indicatorId + '.csv';
4849        var downloadButton = $('<a />').text(translations.indicator[downloadKey])
4850            .attr(opensdg.autotrack('download_data_current', 'Downloads', 'Download CSV', gaLabel))
4851            .attr({
4852                'download': fileName,
4853                'title': translations.indicator.download_csv_title,
4854                'aria-label': translations.indicator.download_csv_title,
4855                'class': 'btn btn-primary btn-download',
4856                'tabindex': 0,
4857                'role': 'button',
4858            });
4859        var blob = new Blob([tableCsv], {
4860            type: 'text/csv'
4861        });
4862        if (window.navigator && window.navigator.msSaveBlob) {
4863            // Special behavior for IE.
4864            downloadButton.on('click.openSdgDownload', function (event) {
4865                window.navigator.msSaveBlob(blob, fileName);
4866            });
4867        }
4868        else {
4869            downloadButton
4870                .attr('href', URL.createObjectURL(blob))
4871                .data('csvdata', tableCsv);
4872        }
4873        if (name == 'Chart') {
4874            VIEW._chartDownloadButton = downloadButton;
4875        }
4876        $(el).append(downloadButton);
4877    } else {
4878        var headlineId = indicatorId.replace('indicator', 'headline');
4879        var id = indicatorId.replace('indicator_', '');
4880        var gaLabel = 'Download Headline CSV: ' + id;
4881        $(el).append($('<a />').text(translations.indicator.download_headline)
4882            .attr(opensdg.autotrack('download_data_headline', 'Downloads', 'Download CSV', gaLabel))
4883            .attr({
4884                'href': opensdg.remoteDataBaseUrl + '/headline/' + id + '.csv',
4885                'download': headlineId + '.csv',
4886                'title': translations.indicator.download_headline_title,
4887                'aria-label': translations.indicator.download_headline_title,
4888                'class': 'btn btn-primary btn-download',
4889                'tabindex': 0,
4890                'role': 'button',
4891            }));
4892    }
4893}
4894
4895/**
4896 * @param {String} indicatorId
4897 * @param {Element} el
4898 * @return null
4899 */
4900function createSourceButton(indicatorId, el) {
4901    var gaLabel = 'Download Source CSV: ' + indicatorId;
4902    $(el).append($('<a />').text(translations.indicator.download_source)
4903        .attr(opensdg.autotrack('download_data_source', 'Downloads', 'Download CSV', gaLabel))
4904        .attr({
4905            'href': opensdg.remoteDataBaseUrl + '/data/' + indicatorId + '.csv',
4906            'download': indicatorId + '.csv',
4907            'title': translations.indicator.download_source_title,
4908            'aria-label': translations.indicator.download_source_title,
4909            'class': 'btn btn-primary btn-download',
4910            'tabindex': 0,
4911            'role': 'button',
4912        }));
4913}
4914
4915/**
4916 * @param {Object} indicatorDownloads
4917 * @param {String} indicatorId
4918 * @param {Element} el
4919 * @return null
4920 */
4921function createIndicatorDownloadButtons(indicatorDownloads, indicatorId, el) {
4922    if (indicatorDownloads) {
4923        var buttonLabels = Object.keys(indicatorDownloads);
4924        for (var i = 0; i < buttonLabels.length; i++) {
4925            var buttonLabel = buttonLabels[i];
4926            var href = indicatorDownloads[buttonLabel].href;
4927            var buttonLabelTranslated = translations.t(buttonLabel);
4928            var gaLabel = buttonLabel + ': ' + indicatorId;
4929            $(el).append($('<a />').text(buttonLabelTranslated)
4930                .attr(opensdg.autotrack(buttonLabel, 'Downloads', buttonLabel, gaLabel))
4931                .attr({
4932                    'href': opensdg.remoteDataBaseUrl + '/' + href,
4933                    'download': href.split('/').pop(),
4934                    'title': buttonLabelTranslated,
4935                    'class': 'btn btn-primary btn-download',
4936                    'tabindex': 0,
4937                    'role': 'button',
4938                }));
4939        }
4940    }
4941}
4942
4943
4944  return {
4945    HIDE_SINGLE_SERIES: HIDE_SINGLE_SERIES,
4946    HIDE_SINGLE_UNIT: HIDE_SINGLE_UNIT,
4947    PROXY_PILL: PROXY_PILL,
4948    initialiseFields: initialiseFields,
4949    initialiseUnits: initialiseUnits,
4950    initialiseSerieses: initialiseSerieses,
4951    updateIndicatorDataUnitStatus: updateIndicatorDataUnitStatus,
4952    updateIndicatorDataSeriesStatus: updateIndicatorDataSeriesStatus,
4953    alterChartConfig: alterChartConfig,
4954    alterTableConfig: alterTableConfig,
4955    alterDataDisplay: alterDataDisplay,
4956    updateChartTitle: updateChartTitle,
4957    updateWithSelectedFields: updateWithSelectedFields,
4958    updateSeriesAndUnitElements: updateSeriesAndUnitElements,
4959    updateUnitElements: updateUnitElements,
4960    updateTimeSeriesAttributes: updateTimeSeriesAttributes,
4961    updateObservationAttributes: updateObservationAttributes,
4962    updatePlot: updatePlot,
4963    isHighContrast: isHighContrast,
4964    getHeadlineColor: getHeadlineColor,
4965    getGridColor: getGridColor,
4966    getTickColor: getTickColor,
4967    updateHeadlineColor: updateHeadlineColor,
4968    createPlot: createPlot,
4969    setPlotEvents: setPlotEvents,
4970    toCsv: toCsv,
4971    createIndicatorDownloadButtons: createIndicatorDownloadButtons,
4972    createSourceButton: createSourceButton,
4973    createDownloadButton: createDownloadButton,
4974    createSelectionsTable: createSelectionsTable,
4975    sortFieldGroup: sortFieldGroup,
4976    getObservationAttributeFootnoteSymbol: getObservationAttributeFootnoteSymbol,
4977    getObservationAttributeText: getObservationAttributeText,
4978    getBrowserDecimalSeparator: getBrowserDecimalSeparator,
4979  }
4980})();
4981
4982    VIEW.helpers = helpers;
4983
4984    VIEW._chartInstance = undefined;
4985    VIEW._tableColumnDefs = OPTIONS.tableColumnDefs;
4986    VIEW._mapView = undefined;
4987    VIEW._legendElement = OPTIONS.legendElement;
4988    VIEW._precision = undefined;
4989    VIEW._chartInstances = {};
4990    VIEW._browserDecimalSeparator = helpers.getBrowserDecimalSeparator();
4991
4992    var chartHeight = screen.height < OPTIONS.maxChartHeight ? screen.height : OPTIONS.maxChartHeight;
4993    $('.plot-container', OPTIONS.rootElement).css('height', chartHeight + 'px');
4994
4995    $(document).ready(function () {
4996
4997        $(OPTIONS.rootElement).find('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
4998            if ($(e.target).attr('href') == '#tableview') {
4999                setDataTableWidth($(OPTIONS.rootElement).find('#selectionsTable table'));
5000            } else {
5001                $($.fn.dataTable.tables(true)).css('width', '100%');
5002                $($.fn.dataTable.tables(true)).DataTable().columns.adjust().draw();
5003            }
5004        });
5005
5006        // Execute the hide/show functionality for the sidebar, both on
5007        // the currently active tab, and each time a tab is clicked on.
5008        $('.data-view .nav-item.active .nav-link').each(toggleSidebar);
5009        $('.data-view .nav-link').on('click', toggleSidebar);
5010        function toggleSidebar() {
5011            var $sidebar = $('.indicator-sidebar'),
5012                $main = $('.indicator-main'),
5013                hideSidebar = $(this).data('no-disagg'),
5014                mobile = window.matchMedia("screen and (max-width: 990px)");
5015            if (hideSidebar) {
5016                $sidebar.addClass('indicator-sidebar-hidden');
5017                $main.addClass('indicator-main-full');
5018                // On mobile, this can be confusing, so we need to scroll to the tabs.
5019                if (mobile.matches) {
5020                    $([document.documentElement, document.body]).animate({
5021                        scrollTop: $("#indicator-main").offset().top - 40
5022                    }, 400);
5023                }
5024            }
5025            else {
5026                $sidebar.removeClass('indicator-sidebar-hidden');
5027                $main.removeClass('indicator-main-full');
5028                // Make sure the unit/series items are updated, in case
5029                // they were changed while on the map.
5030                helpers.updateChartTitle(VIEW._dataCompleteArgs.chartTitle, VIEW._dataCompleteArgs.isProxy);
5031                helpers.updateSeriesAndUnitElements(VIEW._dataCompleteArgs.selectedSeries, VIEW._dataCompleteArgs.selectedUnit);
5032                helpers.updateUnitElements(VIEW._dataCompleteArgs.selectedUnit);
5033                helpers.updateTimeSeriesAttributes(VIEW._dataCompleteArgs.timeSeriesAttributes);
5034            }
5035        };
5036    });
5037
5038    MODEL.onDataComplete.attach(function (sender, args) {
5039
5040        VIEW._precision = args.precision;
5041
5042        if (MODEL.showData) {
5043            $('#dataset-size-warning')[args.datasetCountExceedsMax ? 'show' : 'hide']();
5044            if (!VIEW._chartInstance) {
5045                helpers.createPlot(args, helpers);
5046                helpers.setPlotEvents(args);
5047            } else {
5048                helpers.updatePlot(args);
5049            }
5050        }
5051
5052        helpers.createSelectionsTable(args);
5053        helpers.updateChartTitle(args.chartTitle, args.isProxy);
5054        helpers.updateSeriesAndUnitElements(args.selectedSeries, args.selectedUnit);
5055        helpers.updateUnitElements(args.selectedUnit);
5056        helpers.updateTimeSeriesAttributes(args.timeSeriesAttributes);
5057        helpers.updateObservationAttributes(args.allObservationAttributes);
5058
5059        VIEW._dataCompleteArgs = args;
5060    });
5061
5062    MODEL.onFieldsComplete.attach(function (sender, args) {
5063
5064        helpers.initialiseFields(args);
5065
5066        if (args.hasGeoData && args.showMap) {
5067            VIEW._mapView = new mapView();
5068            VIEW._mapView.initialise(
5069                args.indicatorId,
5070                args.precision,
5071                args.precisionItems,
5072                OPTIONS.decimalSeparator,
5073                args.dataSchema,
5074                VIEW.helpers,
5075                MODEL.helpers,
5076                args.chartTitles,
5077                args.startValues,
5078                args.proxy,
5079                args.proxySerieses,
5080                MODEL.allObservationAttributes,
5081            );
5082        }
5083    });
5084
5085    MODEL.onUnitsComplete.attach(function (sender, args) {
5086
5087        helpers.initialiseUnits(args);
5088    });
5089
5090    if (MODEL.onSeriesesComplete) {
5091
5092        MODEL.onSeriesesComplete.attach(function (sender, args) {
5093            helpers.initialiseSerieses(args);
5094        });
5095    }
5096
5097    if (MODEL.onUnitsSelectedChanged) {
5098        MODEL.onUnitsSelectedChanged.attach(function (sender, args) {
5099            helpers.updateIndicatorDataUnitStatus(args);
5100        });
5101    }
5102    if (MODEL.onSeriesesSelectedChanged) {
5103        MODEL.onSeriesesSelectedChanged.attach(function (sender, args) {
5104            helpers.updateIndicatorDataSeriesStatus(args);
5105        });
5106    }
5107
5108    MODEL.onFieldsCleared.attach(function (sender, args) {
5109
5110        $(OPTIONS.rootElement).find(':checkbox').prop('checked', false);
5111        $(OPTIONS.rootElement).find('#clear')
5112            .addClass('disabled')
5113            .attr('aria-disabled', 'true')
5114            .attr('disabled', 'disabled');
5115
5116        // reset available/unavailable fields
5117        helpers.updateWithSelectedFields();
5118
5119        $(OPTIONS.rootElement).find('.selected').css('width', '0');
5120    });
5121
5122    MODEL.onSelectionUpdate.attach(function (sender, args) {
5123
5124        if (args.selectedFields.length) {
5125            $(OPTIONS.rootElement).find('#clear')
5126                .removeClass('disabled')
5127                .attr('aria-disabled', 'false')
5128                .removeAttr('disabled');
5129        }
5130        else {
5131            $(OPTIONS.rootElement).find('#clear')
5132                .addClass('disabled')
5133                .attr('aria-disabled', 'true')
5134                .attr('disabled', 'disabled');
5135        }
5136
5137        // loop through the available fields:
5138        $('.variable-selector').each(function (index, element) {
5139            var currentField = $(element).data('field');
5140            var element = $(OPTIONS.rootElement).find('.variable-selector[data-field="' + currentField + '"]');
5141
5142            // is this an allowed field:
5143            if (args.allowedFields.includes(currentField)) {
5144                $(element).removeClass('disallowed');
5145                $(element).find('> button').removeAttr('aria-describedby');
5146            }
5147            else {
5148                $(element).addClass('disallowed');
5149                $(element).find('> button').attr('aria-describedby', 'variable-hint-' + currentField);
5150            }
5151        });
5152    });
5153
5154    MODEL.onFieldsStatusUpdated.attach(function (sender, args) {
5155
5156        _.each(args.data, function (fieldGroup) {
5157            _.each(fieldGroup.values, function (fieldItem) {
5158                var element = $(OPTIONS.rootElement).find(':checkbox[value="' + fieldItem.value + '"][data-field="' + fieldGroup.field + '"]');
5159                element.parent().addClass(fieldItem.state).attr('data-has-data', fieldItem.hasData);
5160            });
5161            // Indicate whether the fieldGroup had any data.
5162            var fieldGroupElement = $(OPTIONS.rootElement).find('.variable-selector[data-field="' + fieldGroup.field + '"]');
5163            fieldGroupElement.attr('data-has-data', fieldGroup.hasData);
5164            var fieldGroupButton = fieldGroupElement.find('> button'),
5165                describedByCurrent = fieldGroupButton.attr('aria-describedby') || '',
5166                noDataHintId = 'no-data-hint-' + fieldGroup.field.replace(/ /g, '-');
5167            if (!fieldGroup.hasData && !describedByCurrent.includes(noDataHintId)) {
5168                fieldGroupButton.attr('aria-describedby', describedByCurrent + ' ' + noDataHintId);
5169            }
5170            else {
5171                fieldGroupButton.attr('aria-describedby', describedByCurrent.replace(noDataHintId, ''));
5172            }
5173
5174            // Re-sort the items.
5175            helpers.sortFieldGroup(fieldGroupElement);
5176        });
5177    });
5178
5179    $(OPTIONS.rootElement).on('click', '#clear', function () {
5180        MODEL.clearSelectedFields();
5181    });
5182
5183    $(OPTIONS.rootElement).on('click', '#fields label', function (e) {
5184
5185        if (!$(this).closest('.variable-selector').hasClass('disallowed')) {
5186            $(this).find(':checkbox').trigger('click');
5187        }
5188
5189        e.preventDefault();
5190        e.stopPropagation();
5191    });
5192
5193    $(OPTIONS.rootElement).on('change', '#units input', function () {
5194        MODEL.updateSelectedUnit($(this).val());
5195    });
5196
5197    $(OPTIONS.rootElement).on('change', '#serieses input', function () {
5198        MODEL.updateSelectedSeries($(this).val());
5199    });
5200
5201    $(OPTIONS.rootElement).on('click', '.variable-options button', function (e) {
5202        var type = $(this).data('type');
5203        var $options = $(this).closest('.variable-options').find(':checkbox');
5204
5205        // The clear button can clear all checkboxes.
5206        if (type == 'clear') {
5207            $options.prop('checked', false);
5208        }
5209        // The select button must only select checkboxes that have data.
5210        if (type == 'select') {
5211            $options.parent().not('[data-has-data=false]').find(':checkbox').prop('checked', true)
5212        }
5213
5214        helpers.updateWithSelectedFields();
5215        e.stopPropagation();
5216    });
5217
5218    $(OPTIONS.rootElement).on('click', ':checkbox', function (e) {
5219
5220        // don't permit disallowed selections:
5221        if ($(this).closest('.variable-selector').hasClass('disallowed')) {
5222            return;
5223        }
5224
5225        helpers.updateWithSelectedFields();
5226        e.stopPropagation();
5227    });
5228
5229    $(OPTIONS.rootElement).on('click', '.variable-selector', function (e) {
5230
5231        var $button = $(e.target).closest('button');
5232        var $options = $(this).find('.variable-options');
5233
5234        if ($options.is(':visible')) {
5235            $options.hide();
5236            $button.attr('aria-expanded', 'false');
5237        }
5238        else {
5239            $options.show();
5240            $button.attr('aria-expanded', 'true');
5241        }
5242
5243        e.stopPropagation();
5244    });
5245};
5246var indicatorController = function (model, view) {
5247  this._model = model;
5248  this._view = view;
5249};
5250
5251indicatorController.prototype = {
5252  initialise: function () {
5253    this._model.initialise();
5254  }
5255};
5256var indicatorInit = function () {
5257    if ($('#indicatorData').length) {
5258        var domData = $('#indicatorData').data();
5259
5260        if (domData.showdata) {
5261
5262            $('.async-loading').each(function (i, obj) {
5263                $(obj).append($('<img />').attr('src', $(obj).data('img')).attr('alt', translations.indicator.loading));
5264            });
5265
5266            var remoteUrl = '/comb/' + domData.id + '.json';
5267            if (opensdg.remoteDataBaseUrl !== '/') {
5268                remoteUrl = opensdg.remoteDataBaseUrl + remoteUrl;
5269            }
5270
5271            $.ajax({
5272                url: remoteUrl,
5273                success: function (res) {
5274
5275                    $('.async-loading').remove();
5276                    $('.async-loaded').show();
5277
5278                    var model = new indicatorModel({
5279                        data: res.data,
5280                        edgesData: res.edges,
5281                        showMap: domData.showmap,
5282                        country: domData.country,
5283                        indicatorId: domData.indicatorid,
5284                        shortIndicatorId: domData.id,
5285                        chartTitle: domData.charttitle,
5286                        chartTitles: domData.charttitles,
5287                        measurementUnit: domData.measurementunit,
5288                        xAxisLabel: domData.xaxislabel,
5289                        showData: domData.showdata,
5290                        graphType: domData.graphtype,
5291                        graphTypes: domData.graphtypes,
5292                        startValues: domData.startvalues,
5293                        graphLimits: domData.graphlimits,
5294                        stackedDisaggregation: domData.stackeddisaggregation,
5295                        graphAnnotations: domData.graphannotations,
5296                        graphTargetLines: domData.graphtargetlines,
5297                        graphSeriesBreaks: domData.graphseriesbreaks,
5298                        indicatorDownloads: domData.indicatordownloads,
5299                        dataSchema: domData.dataschema,
5300                        compositeBreakdownLabel: domData.compositebreakdownlabel,
5301                        precision: domData.precision,
5302                        proxy: domData.proxy,
5303                        proxySeries: domData.proxyseries,
5304                    });
5305                    var view = new indicatorView(model, {
5306                        rootElement: '#indicatorData',
5307                        legendElement: '#plotLegend',
5308                        decimalSeparator: '',
5309                        maxChartHeight: 420,
5310                        tableColumnDefs: [
5311                            { maxCharCount: 25 }, // nowrap
5312                            { maxCharCount: 35, width: 200 },
5313                            { maxCharCount: Infinity, width: 250 }
5314                        ]
5315                    });
5316                    var controller = new indicatorController(model, view);
5317                    controller.initialise();
5318                }
5319            });
5320        }
5321    }
5322};
5323$(document).ready(function() {
5324    $('.nav-tabs').each(function() {
5325        var tabsList = $(this);
5326
5327        // Allow clicking on the <li> to trigger tab click.
5328        tabsList.find('li').click(function(event) {
5329            if (event.target.tagName === 'LI') {
5330                $(event.target).find('> button').click();
5331            }
5332        });
5333    });
5334});
5335$(document).ready(function() {
5336    $('.nav-tabs').each(function() {
5337        var tabsList = $(this);
5338        var tabs = tabsList.find('li > button');
5339        var panes = tabsList.parent().find('.tab-pane');
5340
5341        panes.attr({
5342            'role': 'tabpanel',
5343            'aria-hidden': 'true',
5344            'tabindex': '0',
5345        }).hide();
5346
5347        tabsList.attr({
5348            'role': 'tablist',
5349        });
5350
5351        tabs.each(function(idx) {
5352            var tab = $(this);
5353            var tabId = 'tab-' + tab.attr('data-bs-target').slice(1);
5354            var pane = tabsList.parent().find(tab.attr('data-bs-target'));
5355
5356            tab.attr({
5357                'id': tabId,
5358                'role': 'tab',
5359                'aria-selected': 'false',
5360                'tabindex': '-1',
5361            }).parent().attr('role', 'presentation');
5362
5363            pane.attr('aria-labelledby', tabId);
5364
5365            tab.click(function(e) {
5366                e.preventDefault();
5367
5368                tabsList.find('> li.active')
5369                    .removeClass('active')
5370                    .find('> button')
5371                    .attr({
5372                        'aria-selected': 'false',
5373                        'tabindex': '-1',
5374                    })
5375                    .removeClass('active');
5376
5377                panes.filter(':visible').attr({
5378                    'aria-hidden': 'true',
5379                }).hide();
5380
5381                pane.attr({
5382                    'aria-hidden': 'false',
5383                }).show();
5384
5385                tab.attr({
5386                    'aria-selected': 'true',
5387                    'tabindex': '0',
5388                }).parent().addClass('active');
5389                tab.focus();
5390            });
5391        });
5392
5393        // Show the first tabPanel
5394        panes.first().attr('aria-hidden', 'false').show();
5395
5396        // Set state for the first tabsList li
5397        tabsList.find('li:first').addClass('active').find(' > button').attr({
5398            'aria-selected': 'true',
5399            'tabindex': '0',
5400        });
5401
5402        // Set keydown events on tabList item for navigating tabs
5403        tabsList.delegate('button', 'keydown', function(e) {
5404            var tab = $(this);
5405            switch (e.which) {
5406                case 37:
5407                    if (tab.parent().prev().length != 0) {
5408                        tab.parent().prev().find('> button').click();
5409                        e.preventDefault();
5410                    }
5411                    else {
5412                        tabsList.find('li:last > button').click();
5413                        e.preventDefault();
5414                    }
5415                    break;
5416                case 39:
5417                    if (tab.parent().next().length != 0) {
5418                        tab.parent().next().find('> button').click();
5419                        e.preventDefault();
5420                    }
5421                    else {
5422                        tabsList.find('li:first > button').click();
5423                        e.preventDefault();
5424                    }
5425                    break;
5426            }
5427        });
5428    });
5429});
5430var indicatorSearch = function() {
5431
5432  function sanitizeInput(input) {
5433    if (input === null) {
5434      return null;
5435    }
5436    var doc = new DOMParser().parseFromString(input, 'text/html');
5437    var stripped = doc.body.textContent || "";
5438    var map = {
5439        '&': '&amp;',
5440        '<': '&lt;',
5441        '>': '&gt;',
5442        '"': '&quot;',
5443        "'": '&#x27;',
5444        "/": '&#x2F;',
5445        "`": '&grave;',
5446    };
5447    var reg = /[&<>"'/`]/ig;
5448    return stripped.replace(reg, function(match) {
5449      return map[match];
5450    });
5451  }
5452
5453  var urlParams = new URLSearchParams(window.location.search);
5454  var searchTerms = sanitizeInput(urlParams.get('q'));
5455  if (searchTerms !== null) {
5456    document.getElementById('search-bar-on-page').value = searchTerms;
5457    document.getElementById('search-term').innerHTML = searchTerms;
5458
5459    var searchTermsToUse = searchTerms;
5460    // This is to allow for searching by indicator with dashes.
5461    if (searchTerms.split('-').length == 3 && searchTerms.length < 15) {
5462      // Just a best-guess check to see if the user intended to search for an
5463      // indicator ID.
5464      searchTermsToUse = searchTerms.replace(/-/g, '.');
5465    }
5466
5467    var useLunr = typeof window.lunr !== 'undefined';
5468    if (useLunr && opensdg.language != 'en') {
5469      if (typeof lunr[opensdg.language] === 'undefined') {
5470        useLunr = false;
5471      }
5472    }
5473
5474    // Recognize an indicator id as a special case that does not need Lunr.
5475    var searchWords = searchTermsToUse.split(' '),
5476        indicatorIdParts = searchWords[0].split('.'),
5477        isIndicatorSearch = (searchWords.length === 1 && indicatorIdParts.length >= 3);
5478    if (isIndicatorSearch) {
5479      useLunr = false;
5480    }
5481
5482    var results = [];
5483    var alternativeSearchTerms = [];
5484    var noTermsProvided = (searchTerms === '');
5485
5486    if (useLunr && !noTermsProvided) {
5487      // Engish-specific tweak for words separated only by commas.
5488      if (opensdg.language == 'en') {
5489        lunr.tokenizer.separator = /[\s\-,]+/
5490      }
5491
5492      var searchIndex = lunr(function () {
5493        if (opensdg.language != 'en' && lunr[opensdg.language]) {
5494          this.use(lunr[opensdg.language]);
5495        }
5496        this.use(storeUnstemmed);
5497        this.ref('url');
5498        // Index the expected fields.
5499        this.field('title', getSearchFieldOptions('title'));
5500        this.field('content', getSearchFieldOptions('content'));
5501        this.field('id', getSearchFieldOptions('id'));
5502        // Index any extra fields.
5503        var i;
5504        for (i = 0; i < opensdg.searchIndexExtraFields.length; i++) {
5505          var extraField = opensdg.searchIndexExtraFields[i];
5506          this.field(extraField, getSearchFieldOptions(extraField));
5507        }
5508        // Index all the documents.
5509        for (var ref in opensdg.searchItems) {
5510          this.add(opensdg.searchItems[ref]);
5511        };
5512      });
5513
5514      // Perform the search.
5515      var results = searchIndex.search(searchTermsToUse);
5516
5517      // If we didn't find anything, get progressively "fuzzier" to look for
5518      // alternative search term options.
5519      if (!results.length > 0) {
5520        for (var fuzziness = 1; fuzziness < 5; fuzziness++) {
5521          var fuzzierQuery = getFuzzierQuery(searchTermsToUse, fuzziness);
5522          var alternativeResults = searchIndex.search(fuzzierQuery);
5523          if (alternativeResults.length > 0) {
5524            var matchedTerms = getMatchedTerms(alternativeResults);
5525            if (matchedTerms) {
5526              alternativeSearchTerms = matchedTerms;
5527            }
5528            break;
5529          }
5530        }
5531      }
5532    }
5533    else if (!noTermsProvided) {
5534      // Non-Lunr basic search functionality.
5535      results = _.filter(opensdg.searchItems, function(item) {
5536        var i, match = false;
5537        if (item.title) {
5538          match = match || item.title.indexOf(searchTermsToUse) !== -1;
5539        }
5540        if (item.content) {
5541          match = match || item.content.indexOf(searchTermsToUse) !== -1;
5542        }
5543        for (i = 0; i < opensdg.searchIndexExtraFields.length; i++) {
5544          var extraField = opensdg.searchIndexExtraFields[i];
5545          if (typeof item[extraField] !== 'undefined') {
5546            match = match || item[extraField].indexOf(searchTermsToUse) !== -1;
5547          }
5548        }
5549        return match;
5550      });
5551      // Mimic what Lunr does.
5552      results = _.map(results, function(item) {
5553        return { ref: item.url }
5554      });
5555    }
5556
5557    var resultItems = [];
5558
5559    results.forEach(function(result) {
5560      var doc = opensdg.searchItems[result.ref]
5561      // Truncate the contents.
5562      if (doc.content.length > 400) {
5563        doc.content = doc.content.substring(0, 400) + '...';
5564      }
5565      // Indicate the matches.
5566      doc.content = doc.content.replace(new RegExp('(' + escapeRegExp(searchTerms) + ')', 'gi'), '<span class="match">$1</span>');
5567      doc.title = doc.title.replace(new RegExp('(' + escapeRegExp(searchTerms) + ')', 'gi'), '<span class="match">$1</span>');
5568      resultItems.push(doc);
5569    });
5570
5571    $('.loader').hide();
5572
5573    // Print the results using a template.
5574    var template = _.template(
5575      $("script.results-template").html()
5576    );
5577    $('div.results').html(template({
5578      searchResults: resultItems,
5579      resultsCount: resultItems.length,
5580      didYouMean: (alternativeSearchTerms.length > 0) ? alternativeSearchTerms : false,
5581    }));
5582
5583    // Hide the normal header search.
5584    $('.header-search-bar').hide();
5585  }
5586
5587  // Helper function to make a search query "fuzzier", using the ~ syntax.
5588  // See https://lunrjs.com/guides/searching.html#fuzzy-matches.
5589  function getFuzzierQuery(query, amountOfFuzziness) {
5590    return query
5591      .split(' ')
5592      .map(function(x) { return x + '~' + amountOfFuzziness; })
5593      .join(' ');
5594  }
5595
5596  // Helper function to get the matched words from a result set.
5597  function getMatchedTerms(results) {
5598    var matchedTerms = {};
5599    results.forEach(function(result) {
5600      Object.keys(result.matchData.metadata).forEach(function(stemmedTerm) {
5601        Object.keys(result.matchData.metadata[stemmedTerm]).forEach(function(fieldName) {
5602          result.matchData.metadata[stemmedTerm][fieldName].unstemmed.forEach(function(unstemmedTerm) {
5603            matchedTerms[unstemmedTerm] = true;
5604          });
5605        });
5606      });
5607    });
5608    return Object.keys(matchedTerms);
5609  }
5610
5611  // Helper function to get a boost score, if any.
5612  function getSearchFieldOptions(field) {
5613    var opts = {}
5614    var fieldBoost = opensdg.searchIndexBoost.find(function(boost) {
5615      return boost.field === field;
5616    });
5617    if (fieldBoost) {
5618      opts['boost'] = parseInt(fieldBoost.boost)
5619    }
5620    return opts
5621  }
5622
5623  // Used to highlight search term matches on the screen.
5624  function escapeRegExp(str) {
5625    return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/gi, "\\$&");
5626  };
5627
5628  // Define a pipeline function that keeps the unstemmed word.
5629  // See: https://github.com/olivernn/lunr.js/issues/287#issuecomment-454923675
5630  function storeUnstemmed(builder) {
5631    function pipelineFunction(token) {
5632      token.metadata['unstemmed'] = token.toString();
5633      return token;
5634    };
5635    lunr.Pipeline.registerFunction(pipelineFunction, 'storeUnstemmed');
5636    var firstPipelineFunction = builder.pipeline._stack[0];
5637    builder.pipeline.before(firstPipelineFunction, pipelineFunction);
5638    builder.metadataWhitelist.push('unstemmed');
5639  }
5640};
5641
5642$(function() {
5643
5644  var $el = $('#indicator_search');
5645  $('#jump-to-search').show();
5646  $('#jump-to-search a').click(function() {
5647    if($el.is(':hidden')) {
5648      $('.navbar span[data-target="search"]').click();
5649    }
5650    $el.focus();
5651  });
5652
5653  indicatorSearch();
5654});
5655
5656/*! @source http://purl.eligrey.com/github/classList.js/blob/master/classList.js */
5657"document"in self&&("classList"in document.createElement("_")&&(!document.createElementNS||"classList"in document.createElementNS("http://www.w3.org/2000/svg","g"))||!function(t){"use strict";if("Element"in t){var e="classList",n="prototype",i=t.Element[n],s=Object,r=String[n].trim||function(){return this.replace(/^\s+|\s+$/g,"")},o=Array[n].indexOf||function(t){for(var e=0,n=this.length;n>e;e++)if(e in this&&this[e]===t)return e;return-1},a=function(t,e){this.name=t,this.code=DOMException[t],this.message=e},c=function(t,e){if(""===e)throw new a("SYNTAX_ERR","An invalid or illegal string was specified");if(/\s/.test(e))throw new a("INVALID_CHARACTER_ERR","String contains an invalid character");return o.call(t,e)},l=function(t){for(var e=r.call(t.getAttribute("class")||""),n=e?e.split(/\s+/):[],i=0,s=n.length;s>i;i++)this.push(n[i]);this._updateClassName=function(){t.setAttribute("class",""+this)}},u=l[n]=[],h=function(){return new l(this)};if(a[n]=Error[n],u.item=function(t){return this[t]||null},u.contains=function(t){return t+="",-1!==c(this,t)},u.add=function(){var t,e=arguments,n=0,i=e.length,s=!1;do t=e[n]+"",-1===c(this,t)&&(this.push(t),s=!0);while(++n<i);s&&this._updateClassName()},u.remove=function(){var t,e,n=arguments,i=0,s=n.length,r=!1;do for(t=n[i]+"",e=c(this,t);-1!==e;)this.splice(e,1),r=!0,e=c(this,t);while(++i<s);r&&this._updateClassName()},u.toggle=function(t,e){t+="";var n=this.contains(t),i=n?e!==!0&&"remove":e!==!1&&"add";return i&&this[i](t),e===!0||e===!1?e:!n},u.toString=function(){return this.join(" ")},s.defineProperty){var f={get:h,enumerable:!0,configurable:!0};try{s.defineProperty(i,e,f)}catch(g){(void 0===g.number||-2146823252===g.number)&&(f.enumerable=!1,s.defineProperty(i,e,f))}}else s[n].__defineGetter__&&i.__defineGetter__(e,h)}}(self),function(){"use strict";var t=document.createElement("_");if(t.classList.add("c1","c2"),!t.classList.contains("c2")){var e=function(t){var e=DOMTokenList.prototype[t];DOMTokenList.prototype[t]=function(t){var n,i=arguments.length;for(n=0;i>n;n++)t=arguments[n],e.call(this,t)}};e("add"),e("remove")}if(t.classList.toggle("c3",!1),t.classList.contains("c3")){var n=DOMTokenList.prototype.toggle;DOMTokenList.prototype.toggle=function(t,e){return 1 in arguments&&!this.contains(t)==!e?e:n.call(this,t)}}t=null}());/*! modernizr 3.5.0 (Custom Build) | MIT *
5658 * https://modernizr.com/download/?-blobconstructor-localstorage-setclasses !*/
5659 !function(e,n,o){function s(e,n){return typeof e===n}function t(){var e,n,o,t,a,l,c;for(var f in i)if(i.hasOwnProperty(f)){if(e=[],n=i[f],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(o=0;o<n.options.aliases.length;o++)e.push(n.options.aliases[o].toLowerCase());for(t=s(n.fn,"function")?n.fn():n.fn,a=0;a<e.length;a++)l=e[a],c=l.split("."),1===c.length?Modernizr[c[0]]=t:(!Modernizr[c[0]]||Modernizr[c[0]]instanceof Boolean||(Modernizr[c[0]]=new Boolean(Modernizr[c[0]])),Modernizr[c[0]][c[1]]=t),r.push((t?"":"no-")+c.join("-"))}}function a(e){var n=c.className,o=Modernizr._config.classPrefix||"";if(f&&(n=n.baseVal),Modernizr._config.enableJSClass){var s=new RegExp("(^|\\s)"+o+"no-js(\\s|$)");n=n.replace(s,"$1"+o+"js$2")}Modernizr._config.enableClasses&&(n+=" "+o+e.join(" "+o),f?c.className.baseVal=n:c.className=n)}var r=[],i=[],l={_version:"3.5.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,n){var o=this;setTimeout(function(){n(o[e])},0)},addTest:function(e,n,o){i.push({name:e,fn:n,options:o})},addAsyncTest:function(e){i.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=l,Modernizr=new Modernizr,Modernizr.addTest("blobconstructor",function(){try{return!!new Blob}catch(e){return!1}},{aliases:["blob-constructor"]}),Modernizr.addTest("localstorage",function(){var e="modernizr";try{return localStorage.setItem(e,e),localStorage.removeItem(e),!0}catch(n){return!1}});var c=n.documentElement,f="svg"===c.nodeName.toLowerCase();t(),a(r),delete l.addTest,delete l.addAsyncTest;for(var u=0;u<Modernizr._q.length;
5659u++)Modernizr._q[u]();e.Modernizr=Modernizr}(window,document);/*
5660 * Leaflet selection legend.
5661 *
5662 * This is a Leaflet control designed to keep track of selected layers on a map
5663 * and visualize the selections as stacked bar graphs.
5664 */
5665(function () {
5666  "use strict";
5667
5668  if (typeof L === 'undefined') {
5669    return;
5670  }
5671
5672  L.Control.SelectionLegend = L.Control.extend({
5673
5674    initialize: function(plugin) {
5675      this.selections = [];
5676      this.plugin = plugin;
5677    },
5678
5679    addSelection: function(selection) {
5680      this.selections.push(selection);
5681      this.update();
5682    },
5683
5684    removeSelection: function(selection) {
5685      var index = this.selections.indexOf(selection);
5686      this.selections.splice(index, 1);
5687      this.update();
5688    },
5689
5690    isSelected: function(selection) {
5691      return (this.selections.indexOf(selection) !== -1);
5692    },
5693
5694    onAdd: function() {
5695      var div = L.DomUtil.create('div', 'selection-legend');
5696      this.legendDiv = div;
5697      this.resetSwatches();
5698      return div;
5699    },
5700
5701    renderSwatches: function() {
5702      var controlTpl = '' +
5703        '<dl id="selection-list"></dl>' +
5704        '<div class="legend-footer">' +
5705          '<div class="legend-swatches">' +
5706            '{legendSwatches}' +
5707          '</div>' +
5708          '<div class="legend-values">' +
5709            '<span class="legend-value left">{lowValue}</span>' +
5710            '<span class="arrow left"></span>' +
5711            '<span class="legend-value right">{highValue}</span>' +
5712            '<span class="arrow right"></span>' +
5713          '</div>' +
5714        '</div>';
5715      var swatchTpl = '<span class="legend-swatch" style="width:{width}%; background:{color};"></span>';
5716      var swatchWidth = 100 / this.plugin.options.colorRange.length;
5717      var swatches = this.plugin.options.colorRange.map(function(swatchColor) {
5718        return L.Util.template(swatchTpl, {
5719          width: swatchWidth,
5720          color: swatchColor,
5721        });
5722      }).join('');
5723      var context = { indicatorId: this.plugin.indicatorId };
5724      return L.Util.template(controlTpl, {
5725        lowValue: this.plugin.alterData(opensdg.dataRounding(this.plugin.valueRanges[this.plugin.currentDisaggregation][0], context)),
5726        highValue: this.plugin.alterData(opensdg.dataRounding(this.plugin.valueRanges[this.plugin.currentDisaggregation][1], context)),
5727        legendSwatches: swatches,
5728      });
5729    },
5730
5731    resetSwatches: function() {
5732      this.legendDiv.innerHTML = this.renderSwatches();
5733    },
5734
5735    update: function() {
5736      var selectionList = L.DomUtil.get('selection-list');
5737      var selectionTplHighValue = '' +
5738        '<dt class="selection-name"><span class="selection-name-background">{name}</span></dt>' +
5739        '<dd class="selection-value-item {valueStatus}">' +
5740          '<span class="selection-bar" style="background-color: {color}; width: {percentage}%;">' +
5741            '<span class="selection-value selection-value-high">' +
5742              '<span class="selection-value-high-background">{value}</span>' +
5743            '</span>' +
5744          '</span>' +
5745          '<i class="selection-close fa fa-remove"></i>' +
5746        '</dd>';
5747      var selectionTplLowValue = '' +
5748      '<dt class="selection-name"><span class="selection-name-background">{name}</span></dt>' +
5749      '<dd class="selection-value-item {valueStatus}">' +
5750        '<span class="selection-bar" style="background-color: {color}; width: {percentage}%;"></span>' +
5751        '<span class="selection-value selection-value-low" style="left: {percentage}%;">' +
5752          '<span class="selection-value-low-background">{value}</span>' +
5753        '</span>' +
5754        '<i class="selection-close fa fa-remove"></i>' +
5755      '</dd>';
5756      var plugin = this.plugin;
5757      var valueRange = this.plugin.valueRanges[this.plugin.currentDisaggregation];
5758      selectionList.innerHTML = this.selections.map(function(selection) {
5759        var value = plugin.getData(selection.feature.properties);
5760        var color = '#FFFFFF';
5761        var percentage, valueStatus;
5762        var templateToUse = selectionTplHighValue;
5763        if (typeof value === 'number') {
5764          color = plugin.colorScale(value).hex();
5765          valueStatus = 'has-value';
5766          var fraction = (value - valueRange[0]) / (valueRange[1] - valueRange[0]);
5767          percentage = Math.round(fraction * 100);
5768          if (percentage <= 50) {
5769            templateToUse = selectionTplLowValue;
5770          }
5771        }
5772        else {
5773          value = '';
5774          valueStatus = 'no-value';
5775          percentage = 0;
5776        }
5777        return L.Util.template(templateToUse, {
5778          name: selection.feature.properties.name,
5779          valueStatus: valueStatus,
5780          percentage: percentage,
5781          value: plugin.alterData(value),
5782          color: color,
5783        });
5784      }).join('');
5785
5786      // Assign click behavior.
5787      var control = this,
5788          clickSelector = '#selection-list dd';
5789      $(clickSelector).click(function(e) {
5790        var index = $(clickSelector).index(this),
5791            selection = control.selections[index];
5792        control.removeSelection(selection);
5793        control.plugin.unhighlightFeature(selection);
5794      });
5795    }
5796
5797  });
5798
5799  // Factory function for this class.
5800  L.Control.selectionLegend = function(plugin) {
5801    return new L.Control.SelectionLegend(plugin);
5802  };
5803}());
5804
5805/*
5806 * Leaflet year Slider.
5807 *
5808 * This is merely a specific configuration of Leaflet of L.TimeDimension.
5809 * See here: https://github.com/socib/Leaflet.TimeDimension
5810 */
5811(function () {
5812  "use strict";
5813
5814  if (typeof L === 'undefined') {
5815    return;
5816  }
5817
5818  var defaultOptions = {
5819    // YearSlider options.
5820    yearChangeCallback: null,
5821    years: [],
5822    // TimeDimensionControl options.
5823    timeSliderDragUpdate: true,
5824    speedSlider: false,
5825    position: 'bottomleft',
5826    playButton: false,
5827  };
5828
5829  L.Control.YearSlider = L.Control.TimeDimension.extend({
5830
5831    // Hijack the displayed date format.
5832    _getDisplayDateFormat: function(date){
5833      var time = date.toISOString().slice(0, 10);
5834      var match = this.options.years.find(function(y) { return y.time == time; });
5835      if (match) {
5836        return match.display;
5837      }
5838      else {
5839        return date.getFullYear();
5840      }
5841    },
5842
5843    // Override the _createButton method to prevent the date from being a link.
5844    _createButton: function(title, container) {
5845      if (title === 'Date') {
5846        var span = L.DomUtil.create('span', this.options.styleNS + ' timecontrol-' + title.toLowerCase(), container);
5847        span.title = title;
5848        return span;
5849      }
5850      else {
5851        return L.Control.TimeDimension.prototype._createButton.call(this, title, container);
5852      }
5853    },
5854
5855    // Override the _createSliderTime method to give the slider accessibility features.
5856    _createSliderTime: function(className, container) {
5857      var knob = L.Control.TimeDimension.prototype._createSliderTime.call(this, className, container),
5858          control = this,
5859          times = this._timeDimension.getAvailableTimes(),
5860          years = times.map(function(time) {
5861            var date = new Date(time);
5862            return control._getDisplayDateFormat(date);
5863          }),
5864          minYear = years[0],
5865          maxYear = years[years.length - 1],
5866          knobElement = knob._element;
5867
5868      control._buttonBackward.title = translations.indicator.map_slider_back;
5869      control._buttonBackward.setAttribute('aria-label', control._buttonBackward.title);
5870      control._buttonForward.title = translations.indicator.map_slider_forward;
5871      control._buttonForward.setAttribute('aria-label', control._buttonForward.title);
5872
5873      knobElement.setAttribute('tabindex', '0');
5874      knobElement.setAttribute('role', 'slider');
5875      knobElement.setAttribute('aria-label', translations.indicator.map_slider_keyboard);
5876      knobElement.title = translations.indicator.map_slider_mouse;
5877      knobElement.setAttribute('aria-valuemin', minYear);
5878      knobElement.setAttribute('aria-valuemax', maxYear);
5879
5880      function updateSliderAttributes() {
5881        var yearIndex = 0;
5882        if (knob.getValue()) {
5883          yearIndex = knob.getValue();
5884        }
5885        knobElement.setAttribute('aria-valuenow', years[yearIndex]);
5886      }
5887      updateSliderAttributes();
5888
5889      // Give the slider left/right keyboard functionality.
5890      knobElement.addEventListener('keydown', function(e) {
5891        if (e.which === 37 || e.which === 40) {
5892          var min = knob.getMinValue();
5893          var value = knob.getValue();
5894          value = value - 1;
5895          if (value >= min) {
5896            knob.setValue(value);
5897            control._sliderTimeValueChanged(value);
5898            updateSliderAttributes();
5899          }
5900          e.preventDefault();
5901        }
5902        else if (e.which === 39 || e.which === 38) {
5903          var max = knob.getMaxValue();
5904          var value = knob.getValue();
5905          value = value + 1;
5906          if (value <= max) {
5907            knob.setValue(value);
5908            control._sliderTimeValueChanged(value);
5909            updateSliderAttributes();
5910          }
5911          e.preventDefault();
5912        }
5913      });
5914      return knob;
5915    }
5916
5917  });
5918
5919  // Helper function to compose the full widget.
5920  L.Control.yearSlider = function(options) {
5921    var years = getYears(options.years);
5922    // Extend the defaults.
5923    options = L.Util.extend(defaultOptions, options);
5924    // Hardcode the timeDimension to year intervals.
5925    options.timeDimension = new L.TimeDimension({
5926      // We pad our years to at least January 2nd, so that timezone issues don't
5927      // cause any problems. This converts the array of years into a comma-
5928      // delimited string of YYYY-MM-DD dates.
5929      times: years.map(function(y) { return y.time }).join(','),
5930      //Set the map to the most recent year
5931      currentTime: new Date(years.slice(-1)[0].time).getTime(),
5932    });
5933    // Listen for time changes.
5934    if (typeof options.yearChangeCallback === 'function') {
5935      options.timeDimension.on('timeload', options.yearChangeCallback);
5936    };
5937    // Also pass in another callback for managing the back/forward buttons.
5938    options.timeDimension.on('timeload', function(e) {
5939      var currentTimeIndex = this.getCurrentTimeIndex(),
5940          availableTimes = this.getAvailableTimes(),
5941          $backwardButton = $('.timecontrol-backward'),
5942          $forwardButton = $('.timecontrol-forward'),
5943          isFirstTime = (currentTimeIndex === 0),
5944          isLastTime = (currentTimeIndex === availableTimes.length - 1);
5945      $backwardButton
5946        .attr('disabled', isFirstTime)
5947        .attr('aria-disabled', isFirstTime);
5948      $forwardButton
5949        .attr('disabled', isLastTime)
5950        .attr('aria-disabled', isLastTime);
5951    });
5952    // Pass in our years for later use.
5953    options.years = years;
5954    // Return the control.
5955    return new L.Control.YearSlider(options);
5956  };
5957
5958  function isYear(year) {
5959    var parsedInt = parseInt(year, 10);
5960    return /^\d+$/.test(year) && parsedInt > 1900 && parsedInt < 3000;
5961  }
5962
5963  function getYears(years) {
5964    // Support an array of years or an array of strings starting with years.
5965    var day = 2;
5966    return years.map(function(year) {
5967      var mapped = {
5968        display: year,
5969        time: year,
5970      };
5971      // Usually this is a year.
5972      if (isYear(year)) {
5973        mapped.time = year + '-01-02';
5974        // Start over that day variable.
5975        day = 2;
5976      }
5977      // Otherwise we get the year from the beginning of the string.
5978      else {
5979        var delimiters = ['-', '.', ' ', '/'];
5980        for (var i = 0; i < delimiters.length; i++) {
5981          var parts = year.split(delimiters[i]);
5982          if (parts.length > 1 && isYear(parts[0])) {
5983            mapped.time = parts[0] + '-01-0' + day;
5984            day += 1;
5985            break;
5986          }
5987        }
5988      }
5989      return mapped;
5990    });
5991  }
5992}());
5993/*
5994 * Leaflet fullscreenAccessible.
5995 *
5996 * This is an override of L.Control.Fullscreen for accessibility fixes.
5997 * See here: https://github.com/Leaflet/Leaflet.fullscreen
5998 */
5999(function () {
6000    "use strict";
6001
6002    if (typeof L === 'undefined') {
6003        return;
6004    }
6005
6006    L.Control.FullscreenAccessible = L.Control.Fullscreen.extend({
6007        onAdd: function(map) {
6008            var container = L.Control.Fullscreen.prototype.onAdd.call(this, map);
6009            this.link.setAttribute('role', 'button');
6010            this.link.setAttribute('aria-label', this.link.title);
6011            this.link.innerHTML = '<i class="fa fa-expand" aria-hidden="true"></i>';
6012            return container;
6013        },
6014        _toggleTitle: function() {
6015            L.Control.Fullscreen.prototype._toggleTitle.call(this);
6016            this.link.setAttribute('aria-label', this.link.title);
6017            var faClass = this._map.isFullscreen() ? 'fa-compress' : 'fa-expand'
6018            this.link.innerHTML = '<i class="fa ' + faClass + '" aria-hidden="true"></i>';
6019        }
6020    });
6021
6022  }());
6023/*
6024 * Leaflet search.
6025 *
6026 * This is customized version of L.Control.Search.
6027 * See here: https://github.com/stefanocudini/leaflet-search
6028 */
6029(function () {
6030  "use strict";
6031
6032  if (typeof L === 'undefined') {
6033    return;
6034  }
6035
6036  L.Control.SearchAccessible = L.Control.Search.extend({
6037    onAdd: function(map) {
6038      var container = L.Control.Search.prototype.onAdd.call(this, map);
6039
6040      this._input.setAttribute('aria-label', this._input.placeholder);
6041      this._input.removeAttribute('role');
6042      this._tooltip.setAttribute('aria-label', this._input.placeholder);
6043
6044      this._button.setAttribute('role', 'button');
6045      this._accessibleCollapse();
6046      this._button.innerHTML = '<i class="fa fa-search" aria-hidden="true"></i>';
6047
6048      this._cancel.setAttribute('role', 'button');
6049      this._cancel.title = translations.indicator.map_search_cancel;
6050      this._cancel.setAttribute('aria-label', this._cancel.title);
6051      this._cancel.innerHTML = '<i class="fa fa-close" aria-hidden="true"></i>';
6052
6053      // Prevent the delayed collapse when tabbing out of the input box.
6054      L.DomEvent.on(this._cancel, 'focus', this.collapseDelayedStop, this);
6055
6056      return container;
6057    },
6058    _createInput: function (text, className) {
6059      var input = L.Control.Search.prototype._createInput.call(this, text, className);
6060      input.setAttribute('aria-autocomplete', 'list');
6061      input.setAttribute('aria-controls', 'map-search-listbox');
6062      var combobox = L.DomUtil.create('div', '', this._container);
6063      combobox.setAttribute('role', 'combobox');
6064      combobox.setAttribute('aria-expanded', 'false');
6065      combobox.setAttribute('aria-owns', 'map-search-listbox');
6066      combobox.setAttribute('aria-haspopup', 'listbox');
6067      combobox.id = 'map-search-combobox';
6068      combobox.append(input);
6069      this._combobox = combobox;
6070      return input;
6071    },
6072    _createTooltip: function(className) {
6073      var tooltip = L.Control.Search.prototype._createTooltip.call(this, className);
6074      tooltip.id = 'map-search-listbox';
6075      tooltip.setAttribute('role', 'listbox');
6076      return tooltip;
6077    },
6078    _accessibleExpand: function() {
6079      this._accessibleDescription(translations.indicator.map_search_hide);
6080      this._button.setAttribute('aria-expanded', 'true');
6081    },
6082    _accessibleCollapse: function() {
6083      this._accessibleDescription(translations.indicator.map_search_show);
6084      this._button.setAttribute('aria-expanded', 'false');
6085      this._button.focus();
6086    },
6087    _accessibleDescription: function(description) {
6088      this._button.title = description;
6089      this._button.setAttribute('aria-label', description);
6090    },
6091    expand: function(toggle) {
6092      L.Control.Search.prototype.expand.call(this, toggle);
6093      this._accessibleExpand();
6094      return this;
6095    },
6096    collapse: function() {
6097      L.Control.Search.prototype.collapse.call(this);
6098      this._accessibleCollapse();
6099      return this;
6100    },
6101    cancel: function() {
6102      L.Control.Search.prototype.cancel.call(this);
6103      this._accessibleExpand();
6104      this._combobox.setAttribute('aria-expanded', 'false');
6105      this._input.removeAttribute('aria-activedescendant');
6106      return this;
6107    },
6108    showTooltip: function(records) {
6109      L.Control.Search.prototype.showTooltip.call(this, records);
6110      this._accessibleDescription(translations.indicator.map_search);
6111      this._button.removeAttribute('aria-expanded');
6112      this._combobox.setAttribute('aria-expanded', 'true');
6113      if (this._countertips > 0) {
6114        this._input.setAttribute('aria-activedescendant', this._tooltip.childNodes[0].id);
6115      }
6116      return this._countertips;
6117    },
6118    _createTip: function(text, val) {
6119      var tip = L.Control.Search.prototype._createTip.call(this, text, val);
6120      tip.setAttribute('role', 'option');
6121      tip.id = 'map-search-option-' + val.layer.feature.properties.geocode;
6122      return tip;
6123    },
6124    _handleSubmit: function(e) {
6125      // Prevent the enter key from immediately collapsing the search bar.
6126      if ((typeof e === 'undefined' || e.type === 'keyup') && this._input.value === '') {
6127        return;
6128      }
6129      if (this._tooltip.childNodes.length > 0 && this._input.value !== '') {
6130        // This is a workaround for the bug where non-exact matches
6131        // do not successfully search. See this Github issue:
6132        // https://github.com/stefanocudini/leaflet-search/issues/264
6133        var firstSuggestion = this._tooltip.childNodes[0].innerText;
6134        var firstSuggestionLower = firstSuggestion.toLowerCase();
6135        var userInput = this._input.value;
6136        var userInputLower = userInput.toLowerCase();
6137        if (firstSuggestion !== userInput && firstSuggestionLower.includes(userInputLower)) {
6138          this._input.value = firstSuggestion;
6139        }
6140      }
6141      L.Control.Search.prototype._handleSubmit.call(this, e);
6142    },
6143    _handleArrowSelect: function(velocity) {
6144      L.Control.Search.prototype._handleArrowSelect.call(this, velocity);
6145      var searchTips = this._tooltip.hasChildNodes() ? this._tooltip.childNodes : [];
6146			for (i=0; i<searchTips.length; i++) {
6147			  searchTips[i].setAttribute('aria-selected', 'false');
6148      }
6149      var selectedTip = searchTips[this._tooltip.currentSelection];
6150      if (typeof selectedTip === 'undefined') {
6151        selectedTip = searchTips[0];
6152      }
6153      selectedTip.setAttribute('aria-selected', 'true');
6154      this._input.setAttribute('aria-activedescendant', selectedTip.id);
6155    },
6156    _createAlert: function(className) {
6157      var alert = L.Control.Search.prototype._createAlert.call(this, className);
6158      alert.setAttribute('role', 'alert');
6159      return alert;
6160    }
6161  });
6162}());
6163/*
6164 * Leaflet disaggregation controls.
6165 *
6166 * This is a Leaflet control designed replicate the disaggregation
6167 * controls that are in the sidebar for tables and charts.
6168 */
6169(function () {
6170    "use strict";
6171
6172    if (typeof L === 'undefined') {
6173        return;
6174    }
6175
6176    L.Control.DisaggregationControls = L.Control.extend({
6177
6178        options: {
6179            position: 'bottomleft'
6180        },
6181
6182        initialize: function (plugin) {
6183            this.plugin = plugin;
6184            this.list = null;
6185            this.form = null;
6186            this.currentDisaggregation = 0;
6187            this.displayedDisaggregation = 0;
6188            this.needsMapUpdate = false;
6189            this.seriesColumn = 'SERIES';
6190            this.unitsColumn = 'UNIT_MEASURE';
6191            this.displayForm = false;
6192            this.updateDisaggregations(plugin.startValues);
6193        },
6194
6195        updateDisaggregations: function(startValues) {
6196            // TODO: Not all of this needs to be done
6197            // at every update.
6198            var features = this.getFeatures();
6199            if (startValues && startValues.length > 0) {
6200                this.currentDisaggregation = this.getStartingDisaggregation(features, startValues);
6201                this.displayedDisaggregation = this.currentDisaggregation;
6202                this.needsMapUpdate = true;
6203            }
6204            this.disaggregations = this.getVisibleDisaggregations(features);
6205            this.fieldsInOrder = this.getFieldsInOrder();
6206            this.valuesInOrder = this.getValuesInOrder();
6207            this.allSeries = this.getAllSeries();
6208            this.allUnits = this.getAllUnits();
6209            this.allDisaggregations = this.getAllDisaggregations();
6210            this.hasSeries = (this.allSeries.length > 0);
6211            this.hasUnits = (this.allUnits.length > 0);
6212            this.hasDisaggregations = this.hasDissagregationsWithValues();
6213            this.hasDisaggregationsWithMultipleValuesFlag = this.hasDisaggregationsWithMultipleValues();
6214        },
6215
6216        getFeatures: function() {
6217            return this.plugin.getVisibleLayers().toGeoJSON().features.filter(function(feature) {
6218                return typeof feature.properties.disaggregations !== 'undefined';
6219            });
6220        },
6221
6222        getStartingDisaggregation: function(features, startValues) {
6223            if (features.length === 0) {
6224                return;
6225            }
6226            var disaggregations = features[0].properties.disaggregations,
6227                fields = Object.keys(disaggregations[0]),
6228                validStartValues = startValues.filter(function(startValue) {
6229                    return fields.includes(startValue.field);
6230                }),
6231                weighted = _.sortBy(disaggregations.map(function(disaggregation, index) {
6232                    var disaggClone = Object.assign({}, disaggregation);
6233                    disaggClone.emptyFields = 0;
6234                    disaggClone.index = index;
6235                    fields.forEach(function(field) {
6236                        if (disaggClone[field] == '') {
6237                            disaggClone.emptyFields += 1;
6238                        }
6239                    });
6240                    return disaggClone;
6241                }), 'emptyFields').reverse(),
6242                match = weighted.find(function(disaggregation) {
6243                    return _.every(validStartValues, function(startValue) {
6244                        return disaggregation[startValue.field] === startValue.value;
6245                    });
6246                });
6247            if (match) {
6248                return match.index;
6249            }
6250            else {
6251                return 0;
6252            }
6253        },
6254
6255        getVisibleDisaggregations: function(features) {
6256            if (features.length === 0) {
6257                return [];
6258            }
6259
6260            var disaggregations = features[0].properties.disaggregations;
6261            // The purpose of the rest of this function is to identiy
6262            // and remove any "region columns" - ie, any columns that
6263            // correspond exactly to names of map regions. These columns
6264            // are useful on charts and tables but should not display
6265            // on maps.
6266            var allKeys = Object.keys(disaggregations[0]);
6267            var relevantKeys = {};
6268            var rememberedValues = {};
6269            disaggregations.forEach(function(disagg) {
6270                for (var i = 0; i < allKeys.length; i++) {
6271                    var key = allKeys[i];
6272                    if (rememberedValues[key]) {
6273                        if (rememberedValues[key] !== disagg[key]) {
6274                            relevantKeys[key] = true;
6275                        }
6276                    }
6277                    rememberedValues[key] = disagg[key];
6278                }
6279            });
6280            relevantKeys = Object.keys(relevantKeys);
6281            if (features.length > 1) {
6282                // Any columns not already identified as "relevant" might
6283                // be region columns.
6284                var regionColumnCandidates = allKeys.filter(function(item) {
6285                    return relevantKeys.includes(item) ? false : true;
6286                });
6287                // Compare the column value across map regions - if it is
6288                // different then we assume the column is a "region column".
6289                // For efficiency we only check the first and second region.
6290                var regionColumns = regionColumnCandidates.filter(function(candidate) {
6291                    var region1 = features[0].properties.disaggregations[0][candidate];
6292                    var region2 = features[1].properties.disaggregations[0][candidate];
6293                    return region1 === region2 ? false : true;
6294                });
6295                // Now we can treat any non-region columns as relevant.
6296                regionColumnCandidates.forEach(function(item) {
6297                    if (!regionColumns.includes(item)) {
6298                        relevantKeys.push(item);
6299                    }
6300                });
6301            }
6302            relevantKeys.push(this.seriesColumn);
6303            relevantKeys.push(this.unitsColumn);
6304            var pruned = [];
6305            disaggregations.forEach(function(disaggregation) {
6306                var clone = Object.assign({}, disaggregation);
6307                Object.keys(clone).forEach(function(key) {
6308                    if (!(relevantKeys.includes(key))) {
6309                        delete clone[key];
6310                    }
6311                });
6312                pruned.push(clone);
6313            });
6314            return pruned;
6315        },
6316
6317        update: function() {
6318            this.updateDisaggregations();
6319            this.updateList();
6320            if (this.displayForm) {
6321                this.updateForm();
6322            }
6323        },
6324
6325        getFieldsInOrder: function () {
6326            return this.plugin.dataSchema.fields.map(function(field) {
6327                return field.name;
6328            });
6329        },
6330
6331        getValuesInOrder: function () {
6332            var valuesInOrder = {};
6333            this.plugin.dataSchema.fields.forEach(function(field) {
6334                if (field.constraints && field.constraints.enum) {
6335                    valuesInOrder[field.name] = field.constraints.enum;
6336                }
6337            });
6338            return valuesInOrder;
6339        },
6340
6341        hasDissagregationsWithValues: function () {
6342            var hasDisaggregations = false;
6343            this.allDisaggregations.forEach(function(disaggregation) {
6344                if (disaggregation.values.length > 0 && disaggregation.values[0] !== '') {
6345                    hasDisaggregations = true;
6346                }
6347            });
6348            return hasDisaggregations;
6349        },
6350
6351        hasDisaggregationsWithMultipleValues: function () {
6352            var hasDisaggregations = false;
6353            this.allDisaggregations.forEach(function(disaggregation) {
6354                if (disaggregation.values.length > 1 && disaggregation.values[1] !== '') {
6355                    hasDisaggregations = true;
6356                }
6357            });
6358            return hasDisaggregations;
6359        },
6360
6361        updateList: function () {
6362            var list = this.list;
6363            list.innerHTML = '';
6364            if (this.hasSeries) {
6365                var title = L.DomUtil.create('dt', 'disaggregation-title'),
6366                    definition = L.DomUtil.create('dd', 'disaggregation-definition'),
6367                    container = L.DomUtil.create('div', 'disaggregation-container');
6368                title.innerHTML = translations.indicator.series;
6369                definition.innerHTML = this.getCurrentSeries();
6370                container.append(title);
6371                container.append(definition);
6372                list.append(container);
6373            }
6374            if (this.hasUnits) {
6375                var title = L.DomUtil.create('dt', 'disaggregation-title'),
6376                    definition = L.DomUtil.create('dd', 'disaggregation-definition'),
6377                    container = L.DomUtil.create('div', 'disaggregation-container');
6378                title.innerHTML = translations.indicator.unit;
6379                definition.innerHTML = this.getCurrentUnit();
6380                container.append(title);
6381                container.append(definition);
6382                list.append(container);
6383            }
6384            if (this.hasDisaggregations) {
6385                var currentDisaggregation = this.disaggregations[this.currentDisaggregation];
6386                this.allDisaggregations.forEach(function(disaggregation) {
6387                    var title = L.DomUtil.create('dt', 'disaggregation-title'),
6388                        definition = L.DomUtil.create('dd', 'disaggregation-definition'),
6389                        container = L.DomUtil.create('div', 'disaggregation-container'),
6390                        field = disaggregation.field;
6391                    title.innerHTML = translations.t(field);
6392                    var disaggregationValue = currentDisaggregation[field];
6393                    if (disaggregationValue !== '') {
6394                        definition.innerHTML = disaggregationValue;
6395                        container.append(title);
6396                        container.append(definition);
6397                        list.append(container);
6398                    }
6399                });
6400            }
6401        },
6402
6403        updateForm: function() {
6404            var seriesColumn = this.seriesColumn,
6405                unitsColumn = this.unitsColumn,
6406                container = this.form,
6407                formInputs = L.DomUtil.create('div', 'disaggregation-form-inner'),
6408                that = this;
6409            container.innerHTML = '';
6410            container.append(formInputs)
6411            L.DomEvent.disableScrollPropagation(formInputs);
6412            if (this.hasSeries) {
6413                var form = L.DomUtil.create('div', 'disaggregation-fieldset-container'),
6414                    legend = L.DomUtil.create('legend', 'disaggregation-fieldset-legend'),
6415                    fieldset = L.DomUtil.create('fieldset', 'disaggregation-fieldset');
6416                legend.innerHTML = translations.indicator.series;
6417                fieldset.append(legend);
6418                form.append(fieldset);
6419                formInputs.append(form);
6420                this.allSeries.forEach(function(series) {
6421                    var input = L.DomUtil.create('input', 'disaggregation-input');
6422                    input.type = 'radio';
6423                    input.name = 'map-' + seriesColumn;
6424                    input.value = series;
6425                    input.tabindex = 0;
6426                    input.checked = (series === that.getCurrentSeries()) ? 'checked' : '';
6427                    var label = L.DomUtil.create('label', 'disaggregation-label');
6428                    label.innerHTML = series;
6429                    if (that.plugin.proxySerieses.includes(series)) {
6430                        label.innerHTML += ' ' + that.plugin.viewHelpers.PROXY_PILL;
6431                    }
6432                    label.prepend(input);
6433                    fieldset.append(label);
6434                    input.addEventListener('change', function(e) {
6435                        that.currentDisaggregation = that.getSelectedDisaggregationIndex(seriesColumn, series);
6436                        that.updateForm();
6437                    });
6438                });
6439            }
6440            if (this.hasUnits) {
6441                var form = L.DomUtil.create('div', 'disaggregation-fieldset-container'),
6442                    legend = L.DomUtil.create('legend', 'disaggregation-fieldset-legend'),
6443                    fieldset = L.DomUtil.create('fieldset', 'disaggregation-fieldset');
6444                legend.innerHTML = translations.indicator.unit_of_measurement;
6445                fieldset.append(legend);
6446                form.append(fieldset);
6447                formInputs.append(form);
6448                this.allUnits.forEach(function(unit) {
6449                    var input = L.DomUtil.create('input', 'disaggregation-input');
6450                    if (that.isDisaggegrationValidGivenCurrent(unitsColumn, unit)) {
6451                        input.type = 'radio';
6452                        input.name = 'map-' + unitsColumn;
6453                        input.value = unit;
6454                        input.tabindex = 0;
6455                        input.checked = (unit === that.getCurrentUnit()) ? 'checked' : '';
6456                        var label = L.DomUtil.create('label', 'disaggregation-label');
6457                        label.innerHTML = unit;
6458                        label.prepend(input);
6459                        fieldset.append(label);
6460                        input.addEventListener('change', function(e) {
6461                            that.currentDisaggregation = that.getSelectedDisaggregationIndex(unitsColumn, unit);
6462                            that.updateForm();
6463                        });
6464                    }
6465                });
6466            }
6467            if (this.hasDisaggregations) {
6468                var currentDisaggregation = this.disaggregations[this.currentDisaggregation];
6469                this.allDisaggregations.forEach(function (disaggregation) {
6470                    var form = L.DomUtil.create('div', 'disaggregation-fieldset-container'),
6471                        legend = L.DomUtil.create('legend', 'disaggregation-fieldset-legend'),
6472                        fieldset = L.DomUtil.create('fieldset', 'disaggregation-fieldset'),
6473                        field = disaggregation.field;
6474                    legend.innerHTML = translations.t(field);
6475                    fieldset.append(legend);
6476                    form.append(fieldset);
6477                    formInputs.append(form);
6478                    disaggregation.values.forEach(function (value) {
6479                        var input = L.DomUtil.create('input', 'disaggregation-input');
6480                        if (that.isDisaggegrationValidGivenCurrent(field, value)) {
6481                            input.type = 'radio';
6482                            input.name = 'map-' + field;
6483                            input.value = value;
6484                            input.tabindex = 0;
6485                            input.checked = (value === currentDisaggregation[field]) ? 'checked' : '';
6486                            var label = L.DomUtil.create('label', 'disaggregation-label');
6487                            label.innerHTML = (value === '') ? translations.indicator.total : value;
6488                            label.prepend(input);
6489                            fieldset.append(label);
6490                            input.addEventListener('change', function(e) {
6491                                that.currentDisaggregation = that.getSelectedDisaggregationIndex(field, value);
6492                                that.updateForm();
6493                            });
6494                        }
6495                    });
6496                });
6497            }
6498
6499            var applyButton = L.DomUtil.create('button', 'disaggregation-apply-button'),
6500                cancelButton = L.DomUtil.create('button', 'disaggregation-cancel-button'),
6501                buttonContainer = L.DomUtil.create('div', 'disaggregation-form-buttons');
6502            applyButton.innerHTML = translations.indicator.apply;
6503            buttonContainer.append(applyButton);
6504            cancelButton.innerHTML = translations.indicator.cancel;
6505            buttonContainer.append(cancelButton);
6506            container.append(buttonContainer);
6507
6508            cancelButton.addEventListener('click', function(e) {
6509                that.currentDisaggregation = that.displayedDisaggregation;
6510                $('.disaggregation-form-outer').toggle();
6511                that.updateForm();
6512            });
6513            applyButton.addEventListener('click', function(e) {
6514                that.updateMap();
6515                that.updateList();
6516                $('.disaggregation-form-outer').toggle();
6517            });
6518        },
6519
6520        updateMap: function() {
6521            this.needsMapUpdate = false;
6522            this.plugin.currentDisaggregation = this.currentDisaggregation;
6523            this.plugin.updatePrecision();
6524            this.plugin.setColorScale();
6525            this.plugin.updateColors();
6526            this.plugin.updateTooltips();
6527            this.plugin.selectionLegend.resetSwatches();
6528            this.plugin.selectionLegend.update();
6529            this.plugin.updateTitle();
6530            this.plugin.updateFooterFields();
6531            this.plugin.replaceYearSlider();
6532        },
6533
6534        onAdd: function () {
6535            var div = L.DomUtil.create('div', 'disaggregation-controls'),
6536                list = L.DomUtil.create('dl', 'disaggregation-list'),
6537                that = this;
6538
6539            if (this.hasSeries || this.hasUnits || this.hasDisaggregations) {
6540                this.list = list;
6541                div.append(list);
6542                this.updateList();
6543
6544                var numSeries = this.allSeries.length,
6545                    numUnits = this.allUnits.length,
6546                    displayForm = this.displayForm;
6547
6548                if (displayForm && (this.hasDisaggregationsWithMultipleValuesFlag || (numSeries > 1 || numUnits > 1))) {
6549
6550                    var button = L.DomUtil.create('button', 'disaggregation-button');
6551                    button.innerHTML = translations.indicator.change_breakdowns;
6552                    button.addEventListener('click', function(e) {
6553                        that.displayedDisaggregation = that.currentDisaggregation;
6554                        $('.disaggregation-form-outer').show();
6555                    });
6556                    div.append(button);
6557
6558                    var container = L.DomUtil.create('div', 'disaggregation-form');
6559                    var containerOuter = L.DomUtil.create('div', 'disaggregation-form-outer');
6560                    containerOuter.append(container);
6561                    this.form = container;
6562                    div.append(containerOuter);
6563                    this.updateForm();
6564                }
6565            }
6566
6567            return div;
6568        },
6569
6570        getCurrentSeries: function() {
6571            var disaggregation = this.disaggregations[this.currentDisaggregation];
6572            return disaggregation[this.seriesColumn];
6573        },
6574
6575        getCurrentUnit: function() {
6576            var disaggregation = this.disaggregations[this.currentDisaggregation];
6577            return disaggregation[this.unitsColumn];
6578        },
6579
6580        getAllSeries: function () {
6581            var seriesColumn = this.seriesColumn;
6582            if (typeof this.disaggregations[0][seriesColumn] === 'undefined' || !this.disaggregations[0][seriesColumn]) {
6583                return [];
6584            }
6585            var allSeries = _.uniq(this.disaggregations.map(function(disaggregation) {
6586                return disaggregation[seriesColumn];
6587            }));
6588            var sortedSeries = this.valuesInOrder[seriesColumn];
6589            allSeries.sort(function(a, b) {
6590                return sortedSeries.indexOf(a) - sortedSeries.indexOf(b);
6591            });
6592            return allSeries;
6593        },
6594
6595        getAllUnits: function () {
6596            var unitsColumn = this.unitsColumn;
6597            if (typeof this.disaggregations[0][unitsColumn] === 'undefined' || !this.disaggregations[0][unitsColumn]) {
6598                return [];
6599            }
6600            var allUnits = _.uniq(this.disaggregations.map(function(disaggregation) {
6601                return disaggregation[unitsColumn];
6602            }));
6603            var sortedUnits = this.valuesInOrder[unitsColumn];
6604            allUnits.sort(function(a, b) {
6605                return sortedUnits.indexOf(a) - sortedUnits.indexOf(b);
6606            });
6607            return allUnits;
6608        },
6609
6610        getAllDisaggregations: function () {
6611            var disaggregations = this.disaggregations,
6612                valuesInOrder = this.valuesInOrder,
6613                validFields = Object.keys(disaggregations[0]),
6614                invalidFields = [this.seriesColumn, this.unitsColumn],
6615                allDisaggregations = [];
6616            if (this.plugin.configObsAttributes && this.plugin.configObsAttributes.length > 0) {
6617                this.plugin.configObsAttributes.forEach(function(obsAttribute) {
6618                    invalidFields.push(obsAttribute.field);
6619                });
6620            }
6621
6622            this.fieldsInOrder.forEach(function(field) {
6623                if (!(invalidFields.includes(field)) && validFields.includes(field)) {
6624                    var sortedValues = valuesInOrder[field],
6625                        item = {
6626                            field: field,
6627                            values: _.uniq(disaggregations.map(function(disaggregation) {
6628                                return disaggregation[field];
6629                            })),
6630                        };
6631                    if (typeof sortedValues === 'undefined') {
6632                        return;
6633                    }
6634                    item.values.sort(function(a, b) {
6635                        return sortedValues.indexOf(a) - sortedValues.indexOf(b);
6636                    });
6637                    allDisaggregations.push(item);
6638                }
6639            });
6640
6641            return allDisaggregations;
6642        },
6643
6644        getSelectedDisaggregationIndex: function(changedKey, newValue) {
6645            for (var i = 0; i < this.disaggregations.length; i++) {
6646                var disaggregation = this.disaggregations[i],
6647                    keys = Object.keys(disaggregation),
6648                    matchesSelections = true;
6649                for (var j = 0; j < keys.length; j++) {
6650                    var key = keys[j],
6651                        inputName = 'map-' + key,
6652                        $inputElement = $('input[name="' + inputName + '"]:checked'),
6653                        selection = $inputElement.val();
6654                    if ($inputElement.length > 0 && selection !== disaggregation[key]) {
6655                        matchesSelections = false;
6656                        break;
6657                    }
6658                }
6659                if (matchesSelections) {
6660                    return i;
6661                }
6662            }
6663            // If we are still here, it means that a recent change
6664            // has resulted in an illegal combination. In this case
6665            // we look at the recently-changed key and its value,
6666            // and we pick the first disaggregation that matches.
6667            for (var i = 0; i < this.disaggregations.length; i++) {
6668                var disaggregation = this.disaggregations[i],
6669                    keys = Object.keys(disaggregation);
6670                if (keys.includes(changedKey) && disaggregation[changedKey] === newValue) {
6671                    return i;
6672                }
6673            }
6674            // If we are still here, something went wrong.
6675            throw('Could not find match');
6676        },
6677
6678        isDisaggegrationValidGivenCurrent: function(field, value) {
6679            var currentDisaggregation = Object.assign({}, this.disaggregations[this.currentDisaggregation]);
6680            currentDisaggregation[field] = value;
6681            var keys = Object.keys(currentDisaggregation);
6682            for (var i = 0; i < this.disaggregations.length; i++) {
6683                var valid = true;
6684                var otherDisaggregation = this.disaggregations[i];
6685                for (var j = 0; j < keys.length; j++) {
6686                    var key = keys[j];
6687                    if (currentDisaggregation[key] !== otherDisaggregation[key]) {
6688                        valid = false;
6689                    }
6690                }
6691                if (valid) {
6692                    return true;
6693                }
6694            }
6695            return false;
6696        },
6697
6698    });
6699
6700    // Factory function for this class.
6701    L.Control.disaggregationControls = function (plugin) {
6702        return new L.Control.DisaggregationControls(plugin);
6703    };
6704}());
6705$(document).ready(function() {
6706    $('a[href="#top"]').prepend('<svg class="app-c-back-to-top__icon" xmlns="http://www.w3.org/2000/svg" width="13" height="17" viewBox="0 0 13 17" aria-hidden="true" focusable="false"><path fill="currentColor" d="M6.5 0L0 6.5 1.4 8l4-4v12.7h2V4l4.3 4L13 6.4z"></path></svg>');
6707});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.