PageSourceSearch

https://sdg-indikatoren.de/assets/js/sdg.js?v=20260924122545

js sdg-indikatoren.de collected 2026-09-26 00:39:09 UTC 266,765 bytes, 7,174 lines download raw bytes

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

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.