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