diff --git a/src/app/chart/map/map.component.html b/src/app/chart/map/map.component.html index b924188..a99fef0 100644 --- a/src/app/chart/map/map.component.html +++ b/src/app/chart/map/map.component.html @@ -1,2 +1,3 @@
+
diff --git a/src/app/chart/map/map.component.scss b/src/app/chart/map/map.component.scss index 233b93d..89172cd 100644 --- a/src/app/chart/map/map.component.scss +++ b/src/app/chart/map/map.component.scss @@ -1,5 +1,10 @@ -#mapChart { - height: 100%; - min-height: 275px; +#mapChart, +#mapChartHidden { + height: 275px; width: 100%; } + +#mapChartHidden { + margin-top: -100%; + visibility: hidden; +} diff --git a/src/app/chart/map/map.component.spec.ts b/src/app/chart/map/map.component.spec.ts index 7595f3b..7e73f69 100644 --- a/src/app/chart/map/map.component.spec.ts +++ b/src/app/chart/map/map.component.spec.ts @@ -21,6 +21,7 @@ describe('MapComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(MapComponent); component = fixture.componentInstance; + component.results = []; fixture.detectChanges(); }); diff --git a/src/app/chart/map/map.component.ts b/src/app/chart/map/map.component.ts index 133d924..69e6192 100644 --- a/src/app/chart/map/map.component.ts +++ b/src/app/chart/map/map.component.ts @@ -1,16 +1,17 @@ -import { Component, Inject, Input, NgZone, PLATFORM_ID } from '@angular/core'; -import { isPlatformBrowser } from '@angular/common'; - -// amCharts imports +import { Component, EventEmitter, Input, Output } from '@angular/core'; import * as am4core from '@amcharts/amcharts4/core'; import * as am4maps from '@amcharts/amcharts4/maps'; import am4themes_animated from '@amcharts/amcharts4/themes/animated'; import am4geodata_worldHigh from '@amcharts/amcharts4-geodata/worldHigh'; -import { isoCountryCodes } from '../../_data'; -import { APIService } from '../../_services'; import { IHash, NameValue } from '../../_models'; +enum ZoomLevel { + SINGLE = 'SINGLE', + MULTIPLE = 'MULTIPLE', + INTERMEDIATE = 'INTERMEDIATE' +} + @Component({ selector: 'app-map-chart', templateUrl: './map.component.html', @@ -18,190 +19,415 @@ import { IHash, NameValue } from '../../_models'; standalone: true }) export class MapComponent { + @Output() mapCountrySet = new EventEmitter(); + _results: Array; chart: am4maps.MapChart; + chartHidden: am4maps.MapChart; polygonSeries: am4maps.MapPolygonSeries; - countryCodes: IHash; + polygonSeriesHidden: am4maps.MapPolygonSeries; + legend: am4maps.HeatLegend; + + mapHeight: number; + mapWidth: number; + + animationTime = 750; + boundingCountries = ['IS', 'TR', 'ES', 'NO']; + mapCountries = []; + + selectedCountryNext?: string; + selectedCountryPrev?: string; + _selectedCountry?: string; + selectedIndex: number; + + get selectedCountry(): string { + return this._selectedCountry; + } + + set selectedCountry(selectedCountry: string | undefined) { + this._selectedCountry = selectedCountry; + this.polygonSeries.tooltip.disabled = !!selectedCountry; + + const selectedIndex = this.mapCountries.indexOf(selectedCountry); + const length = this.mapCountries.length; + const indexNext = (selectedIndex + 1) % length; + const indexPrev = (selectedIndex ? selectedIndex : length) - 1; - // controls - hasLegend = true; + this.selectedIndex = selectedCountry ? selectedIndex : undefined; + this.selectedCountryNext = this.mapCountries[indexNext]; + this.selectedCountryPrev = this.mapCountries[indexPrev]; + // emit output + this.mapCountrySet.emit(selectedCountry); + } + + // TODO rename results + // generate unique list of countries than includes the bounding countries @Input() set results(results: Array) { + this.mapCountries = Object.keys( + results + .map((item: NameValue) => { + return item.name; + }) + .concat(this.boundingCountries) + .reduce((ob: IHash, name) => { + ob[name] = true; + return ob; + }, {}) + ); + this._results = results; this.updateData(); am4core.options.autoDispose = true; } + get results(): Array { return this._results; } - constructor( - @Inject(PLATFORM_ID) private readonly platformId, - private readonly zone: NgZone, - private readonly api: APIService - ) { - this.countryCodes = isoCountryCodes; + constructor() { am4core.options.autoDispose = true; } - // Run the function only in the browser / (exempt rendering from change detection) - browserOnly(f: () => void): void { - if (isPlatformBrowser(this.platformId)) { - this.zone.runOutsideAngular((): void => { - f(); - }); + /** updateData + * + **/ + updateData(): void { + if (!this.polygonSeries || !this.results) { + return; } + this.polygonSeries.data = this.filterResultsData(); + this.polygonSeriesHidden.data = this.filterResultsData(); } - updateData(): void { - this.browserOnly((): void => { - if (!this.polygonSeries) { - return; - } - if (!this.results) { - return; - } + /** ngAfterViewInit + * Event hook: calls drawChart + **/ + ngAfterViewInit(): void { + this.drawChart(); + } - this.polygonSeries.data = this.results.map((nv: NameValue) => { + /** filterResultsData + * filters mapped data + **/ + filterResultsData( + countries = this.mapCountries + ): Array<{ id: string; value: number }> { + return this.results + .filter((nv: NameValue) => { + return countries.includes(nv.name); + }) + .map((nv: NameValue) => { return { - id: this.countryCodes[nv.name], - name: nv.name, + id: nv.name, value: nv.value }; }); + } + + /** setCountryInclusion + * @param { Array } countries + * sets the countries to include in the map: isolates single + * country or restores all countries, triggers a zoom and + * optionally resets the selectedCountry. + **/ + setCountryInclusion(countries: Array): void { + this.polygonSeries.include = countries; + this.polygonSeries.data = this.filterResultsData(); + this.polygonSeries.events.once('datavalidated', () => { + if (countries.length === 1) { + this.zoomToCountries(countries, ZoomLevel.SINGLE, 0); + } else { + this.selectedCountry = undefined; + this.zoomToCountries(this.boundingCountries, ZoomLevel.MULTIPLE, 0); + } }); } - /** ngAfterViewInit - /* Event hook: calls drawChart - */ - ngAfterViewInit(): void { - this.drawChart(); + /** countryMorph + * @param { string } newCountry - the country to select + * assumes single-country mode + **/ + countryMorph(newCountry: string): void { + const oldCountry = this.polygonSeries.include[0]; + if (this.polygonSeriesHidden.include.length === 1) { + console.log('countryMorph exit: animating!'); + return; + } + if (this.polygonSeries.include.length > 1) { + console.log('countryMorph exit: not in single country mode'); + return; + } + if (oldCountry === newCountry) { + console.log('countryMorph exit: old and new are the same: ' + oldCountry); + return; + } + + // curtail the hidden map to include only the + this.polygonSeriesHidden.data = []; + this.polygonSeriesHidden.include = [newCountry]; + this.selectedCountry = newCountry; + + this.polygonSeriesHidden.events.once('validated', () => { + const polyHidden = this.polygonSeriesHidden.mapPolygons.getIndex(0); + const poly = this.polygonSeries.getPolygonById(oldCountry); + const morphAnimationEnded = (): void => { + // reset the hidden / update the actual + this.polygonSeriesHidden.data = []; + this.polygonSeriesHidden.include = this.mapCountries; + setTimeout(() => { + this.setCountryInclusion([newCountry]); + }); + }; + if (poly) { + const morphAnimation = polyHidden + ? poly.polygon.morpher.morphToPolygon( + polyHidden.polygon.points, + this.animationTime + ) + : poly.polygon.morpher.morphToCircle(1, this.animationTime); + morphAnimation.events.on('animationended', morphAnimationEnded); + } else { + morphAnimationEnded(); + } + }); + } + + /** countryClick + * @param { string } country - the clicked country + * toggles selected setCountryInclusion with (optional animation) + **/ + countryClick(country: string): void { + const singleMode = this.polygonSeries.include.length === 1; + if (singleMode) { + // revert back to full map + this.setCountryInclusion(this.mapCountries); + } else { + // set selection and zoom + this.legend.hide(); + this.selectedCountry = country; + const animation = this.zoomToCountries( + [country], + ZoomLevel.INTERMEDIATE, + this.animationTime + ); + animation.events.on('animationended', () => { + // remove other countries + this.setCountryInclusion([country]); + }); + } } /** drawChart - /* ... - */ + * + **/ drawChart(): void { - this.browserOnly((): void => { - // Themes begin - am4core.useTheme(am4themes_animated); + const countryClick = this.countryClick.bind(this); - // Create map instance - const chart = am4core.create('mapChart', am4maps.MapChart); - this.chart = chart; + am4core.useTheme(am4themes_animated); - // Set map definition - //chart.geodata = am4geodata_usaLow; - chart.geodata = am4geodata_worldHigh; + // Create map instance + const chart = am4core.create('mapChart', am4maps.MapChart); + const chartHidden = am4core.create('mapChartHidden', am4maps.MapChart); + this.chart = chart; + this.chartHidden = chartHidden; - // Set projection - chart.projection = new am4maps.projections.Miller(); + // Set map definition + chart.geodata = am4geodata_worldHigh; + chartHidden.geodata = am4geodata_worldHigh; - // Create map polygon series - const polygonSeries = chart.series.push(new am4maps.MapPolygonSeries()); + chart.events.on('over', () => { + if (this.polygonSeries.include.length > 1) { + this.legend.show(); + } + }); - this.polygonSeries = polygonSeries; + chart.events.on('out', () => { + this.legend.hide(); + }); - // Hide antarctica - polygonSeries.exclude = ['AQ']; + // Set projection - //Set min/max fill color for each area - polygonSeries.heatRules.push({ - property: 'fill', - target: polygonSeries.mapPolygons.template, - min: chart.colors.getIndex(1).brighten(1), - max: chart.colors.getIndex(1).brighten(-0.3) - }); + const mp = new am4maps.projections.Miller(); + chart.projection = mp; + const mpHidden = new am4maps.projections.Miller(); + chartHidden.projection = mpHidden; - // Make map load polygon data (state shapes and names) from GeoJSON - polygonSeries.useGeodata = true; - - // Set up heat legend - if (this.hasLegend) { - const legend = chart.createChild(am4maps.HeatLegend); - legend.id = 'mapLegend'; - legend.series = polygonSeries; - legend.align = 'left'; - legend.valign = 'bottom'; - legend.width = am4core.percent(35); - legend.marginRight = am4core.percent(4); - legend.background.fill = am4core.color('#000'); - legend.background.fillOpacity = 0.05; - legend.padding(5, 5, 5, 5); - // Set up custom heat map legend labels using axis ranges - const minRange = legend.valueAxis.axisRanges.create(); - minRange.label.horizontalCenter = 'left'; - - const maxRange = legend.valueAxis.axisRanges.create(); - maxRange.label.horizontalCenter = 'right'; - - // Blank out internal heat legend value axis labels - legend.valueAxis.renderer.labels.template.adapter.add( - 'text', - function (_: string) { - return ''; - } - ); - - // Update heat legend value labels - polygonSeries.events.on('datavalidated', function (ev) { - const heatLegend = ev.target.map.getKey('mapLegend'); - const hlMin = heatLegend.series.dataItem.values.value.low; - const hlMinRange = heatLegend.valueAxis.axisRanges.getIndex(0); - hlMinRange.value = hlMin; - hlMinRange.label.text = '' + heatLegend.numberFormatter.format(hlMin); - - const hlMax = heatLegend.series.dataItem.values.value.high; - const hlMaxRange = heatLegend.valueAxis.axisRanges.getIndex(1); - hlMaxRange.value = hlMax; - hlMaxRange.label.text = '' + heatLegend.numberFormatter.format(hlMax); - }); + // Create map polygon series + const polygonSeries = chart.series.push(new am4maps.MapPolygonSeries()); + const polygonSeriesHidden = chartHidden.series.push( + new am4maps.MapPolygonSeries() + ); + + this.polygonSeries = polygonSeries; + this.polygonSeriesHidden = polygonSeriesHidden; + + polygonSeries.include = this.mapCountries; + polygonSeries.data = this.filterResultsData(); + polygonSeriesHidden.include = this.mapCountries; + + // Make map load polygon data (state shapes and names) from GeoJSON + polygonSeries.useGeodata = true; + polygonSeriesHidden.useGeodata = true; + + // add heat rules + polygonSeries.heatRules.push({ + property: 'fill', + target: this.polygonSeries.mapPolygons.template, + min: this.chart.colors.getIndex(1).brighten(1), + max: this.chart.colors.getIndex(1).brighten(-0.3) + }); + + // add legend + const legend = this.chart.createChild(am4maps.HeatLegend); + this.legend = legend; + legend.hide(); + legend.cursorOverStyle = am4core.MouseCursorStyle.pointer; + legend.id = 'mapLegend'; + legend.series = this.polygonSeries; + legend.align = 'left'; + legend.valign = 'bottom'; + legend.width = am4core.percent(35); + legend.marginRight = am4core.percent(4); + legend.background.fillOpacity = 0.05; + legend.padding(5, 5, 5, 5); + legend.events.on('hit', () => { + this.zoomToCountries(); + }); + + //legend.tooltipText = 'home'; + + // Set up custom heat map legend labels using axis ranges + const minRange = legend.valueAxis.axisRanges.create(); + minRange.label.horizontalCenter = 'left'; + + const maxRange = legend.valueAxis.axisRanges.create(); + maxRange.label.horizontalCenter = 'right'; + + // Blank out internal heat legend value axis labels + legend.valueAxis.renderer.labels.template.adapter.add( + 'text', + function (_: string) { + return ''; } + ); - // Configure series tooltip - const polygonTemplate = polygonSeries.mapPolygons.template; - polygonTemplate.tooltipText = '{name}: {value}'; - polygonTemplate.nonScalingStroke = true; - polygonTemplate.strokeWidth = 0.5; + // Update heat legend value labels + polygonSeries.events.once('datavalidated', function (ev) { + const heatLegend = ev.target.map.getKey('mapLegend'); + const hlMin = heatLegend.series.dataItem.values.value.low; + const hlMinRange = heatLegend.valueAxis.axisRanges.getIndex(0); + hlMinRange.value = hlMin; + hlMinRange.label.text = '' + heatLegend.numberFormatter.format(hlMin); - // Create hover state and set alternative fill color - const hs = polygonTemplate.states.create('hover'); - hs.properties.fill = am4core.color('#3c5bdc'); + const hlMax = heatLegend.series.dataItem.values.value.high; + const hlMaxRange = heatLegend.valueAxis.axisRanges.getIndex(1); + hlMaxRange.value = hlMax; + hlMaxRange.label.text = '' + heatLegend.numberFormatter.format(hlMax); + }); - this.updateData(); + //chart.seriesContainer.draggable = false; + //chart.seriesContainer.resizable = false; - const fn = (): void => { - this.zoomToCountries(); - }; - chart.events.on('ready', fn); + this.chart.maxZoomLevel = 24; + this.chartHidden.maxZoomLevel = 24; + this.chart.minZoomLevel = 0.2; + this.chartHidden.minZoomLevel = 0.2; + + // Bind to country click + polygonSeries.mapPolygons.template.events.on('hit', function (ev) { + const clickedId = ev.target.dataItem.dataContext['id']; + countryClick(clickedId); + }); + + // Configure series tooltip + const polygonTemplate = polygonSeries.mapPolygons.template; + polygonTemplate.tooltipText = '{name}: {value}'; + polygonTemplate.nonScalingStroke = true; + polygonTemplate.strokeWidth = 0.5; + + // Create hover state and set alternative fill color + const hs = polygonTemplate.states.create('hover'); + + hs.properties.fill = am4core.color('#0a72cc'); // $stats-blue + hs.properties.strokeWidth = 2.5; + hs.properties.stroke = am4core.color('#ffcb5c'); // $eu-yellow + + this.updateData(); + + chart.zoomLevel = 1; + chart.events.on('ready', () => { + const [n, s, e, w] = this.getBoundingCoords(this.mapCountries); + this.mapHeight = n - s; + this.mapWidth = e - w; + this.zoomToCountries(); }); } - zoomToCountries(zoomTo = ['IS', 'TR', 'ES', 'NO']): void { - this.browserOnly((): void => { - // Init extremes - let north, south, west, east; + getBoundingCoords(countryIds: Array): Array { + let [north, south, east, west] = [-90, 90, -180, 180]; + countryIds.forEach((countryId: string) => { + const country = this.polygonSeries.getPolygonById(countryId); + if (country && !isNaN(country.north)) { + north = Math.max(north, country.north); + south = Math.min(south, country.south); + west = Math.min(west, country.west); + east = Math.max(east, country.east); + } + }); + return [north, south, east, west]; + } - // Find extreme coordinates for all pre-zoom countries - zoomTo.forEach((countryId: string) => { - const country = this.polygonSeries.getPolygonById(countryId); - if (!north || country.north > north) { - north = country.north; - } - if (!south || country.south < south) { - south = country.south; - } - if (!west || country.west < west) { - west = country.west; + zoomToCountries( + zoomTo = this.boundingCountries, + zoomLevelIn = ZoomLevel.MULTIPLE, + duration = this.animationTime + ): am4core.Animation { + const aspectRatioChart = this.chart.pixelWidth / this.chart.pixelHeight; + + let zoomLevel: number; + let zoomLevelSingle = 0.8; + let zoomLevelMultiple = 3; + + let [north, south, east, west] = this.getBoundingCoords(zoomTo); + + if (zoomLevelIn === ZoomLevel.SINGLE) { + zoomLevel = zoomLevelSingle; + if (aspectRatioChart > 2.4) { + console.log('correct single'); + zoomLevel = 0.75; + } + } else if (zoomLevelIn === ZoomLevel.MULTIPLE) { + zoomLevel = zoomLevelMultiple; + } else { + const selectionHeight = north - south; + const aspectRatioSelection = (east - west) / selectionHeight; + + const getRatio = (tgt: number, val: number, rec = 0): number => { + const half = val / 2; + if (tgt > val) { + return 0; } - if (!east || country.east > east) { - east = country.east; + if (tgt > half) { + return Math.max(rec, 1) + ((val / tgt) % 1); } - }); + return getRatio(tgt, half, rec + 1); + }; - // Pre-zoom - this.chart.zoomToRectangle(north, east, south, west, 1, true); - }); + zoomLevel = getRatio(selectionHeight, this.mapHeight); + zoomLevel = zoomLevelSingle * zoomLevel; + // zoomLevel -= aspectRatioSelection / aspectRatioChart; + zoomLevel -= aspectRatioChart / aspectRatioSelection; + zoomLevel = Math.max(1, zoomLevel); + } + return this.chart.zoomToRectangle( + north, + east, + south, + west, + zoomLevel, + true, + duration + ); } } diff --git a/src/app/landing/landing.component.html b/src/app/landing/landing.component.html index e4c5ec2..bc86a95 100644 --- a/src/app/landing/landing.component.html +++ b/src/app/landing/landing.component.html @@ -1,8 +1,8 @@
@@ -49,25 +49,132 @@ >
+ + + @if (countryLink && mapChart && mapChart.selectedCountry) { + + {{ mapChart.selectedCountry | renameCountry | uppercase }} + + + } @else { {{ dimension | renameApiFacet | uppercase }} ITEMS PROVIDED + } +
+ + + @if (countryLink){ @if(mapChart && mapChart.selectedCountry){ +
+ + {{ navCountry | renameCountry }} + + + @if(navCountry === mapChart.selectedCountry){ + {{ + landingData[dimension][mapChart.selectedIndex].value | number + }} + } +
+ + + + @if(countryData && countryData[mapChart.selectedCountry]){ +
+ 3D + + {{ + countryData[mapChart.selectedCountry][0]["three_d"] || 0 | number + }} + +
+
+ HQ + + {{ + countryData[mapChart.selectedCountry][0]["high_quality"] || 0 + | number + }} + +
+ } + + + @if(targetMetaData){ +
+
+ } } @else { +
- @if (countryLink){ - + {{ row.name | renameCountry }} - } @else { + {{ row.value | number }} + {{ row.percent }}% +
+ } } @else { +
- } {{ row.value | number }} {{ row.percent }}%
+ }
@@ -166,8 +273,13 @@
- +
+