From 5d0dd22ec7f80aa5a71b296c4edc9c80d2ff7223 Mon Sep 17 00:00:00 2001 From: andyjmaclean Date: Fri, 8 Mar 2024 14:58:22 +0100 Subject: [PATCH 01/90] MET-5835 Member State Target Page --- cypress/e2e/targets.cy.ts | 99 +++++ src/app/_data/static-data.ts | 12 +- src/app/_mocked/index.ts | 1 + src/app/_mocked/mock-api.service.ts | 39 +- src/app/_mocked/mock-line.component.ts | 31 ++ src/app/_models/index.ts | 1 + src/app/_models/targets.ts | 20 + src/app/_services/api.service.ts | 141 +++++- src/app/_translate/index.ts | 2 + src/app/_translate/rename-country.pipe.ts | 22 + src/app/_translate/rename-target-type.pipe.ts | 22 + src/app/app-routing.module.ts | 5 + src/app/chart/index.ts | 1 + src/app/chart/line/line.component.html | 1 + src/app/chart/line/line.component.scss | 5 + src/app/chart/line/line.component.spec.ts | 25 ++ src/app/chart/line/line.component.ts | 408 ++++++++++++++++++ src/app/targets/index.ts | 1 + src/app/targets/targets.component.html | 128 ++++++ src/app/targets/targets.component.scss | 190 ++++++++ src/app/targets/targets.component.spec.ts | 40 ++ src/app/targets/targets.component.ts | 262 +++++++++++ 22 files changed, 1451 insertions(+), 5 deletions(-) create mode 100644 cypress/e2e/targets.cy.ts create mode 100644 src/app/_mocked/mock-line.component.ts create mode 100644 src/app/_models/targets.ts create mode 100644 src/app/_translate/rename-country.pipe.ts create mode 100644 src/app/_translate/rename-target-type.pipe.ts create mode 100644 src/app/chart/line/line.component.html create mode 100644 src/app/chart/line/line.component.scss create mode 100644 src/app/chart/line/line.component.spec.ts create mode 100644 src/app/chart/line/line.component.ts create mode 100644 src/app/targets/index.ts create mode 100644 src/app/targets/targets.component.html create mode 100644 src/app/targets/targets.component.scss create mode 100644 src/app/targets/targets.component.spec.ts create mode 100644 src/app/targets/targets.component.ts diff --git a/cypress/e2e/targets.cy.ts b/cypress/e2e/targets.cy.ts new file mode 100644 index 00000000..6ff59e9f --- /dev/null +++ b/cypress/e2e/targets.cy.ts @@ -0,0 +1,99 @@ +context('Statistics Dashboard', () => { + describe('targets', () => { + beforeEach(() => { + cy.visit('/targets'); + }); + + const selIsOpen = '.is-open'; + const selLegendGrid = '.legend-grid'; + const selPinnedOpener = '.stick-left'; + const selOpenerCountry = '.legend-item-country-opener'; + const selOpenerSeries = '.legend-item-series-opener'; + + it('should show the legend', () => { + cy.get('#lineChart').should('have.length', 1); + cy.get(selLegendGrid).should('have.length', 1); + }); + + it('should pin a country when opened', () => { + cy.get(selPinnedOpener).should('have.length', 1); + cy.get(selOpenerCountry) + .contains('Cyprus') + .should('have.length', 1) + .click(); + cy.get(selPinnedOpener).should('have.length', 2); + }); + + it('should unpin a country when closed', () => { + cy.get(selPinnedOpener).should('have.length', 1); + cy.get(selIsOpen).should('have.length', 3); + + cy.get(selOpenerCountry) + .contains('Cyprus') + .should('have.length', 1) + .click(); + cy.get(selPinnedOpener).should('have.length', 2); + cy.get(selIsOpen).should('have.length', 6); + + cy.get(selOpenerCountry).contains('Cyprus').click(); + cy.get(selPinnedOpener).should('have.length', 1); + cy.get(selIsOpen).should('have.length', 3); + }); + + it('should pin a country (when individual item opened)', () => { + cy.get(selPinnedOpener).should('have.length', 1); + + // Open an entry under Cyprus + cy.get(selOpenerSeries).eq(6).click(); + cy.get(selPinnedOpener).should('have.length', 2); + + // Open an entry under Denmark + cy.get(selOpenerSeries).eq(12).click(); + cy.get(selPinnedOpener).should('have.length', 3); + + // Close again with country openers + cy.get(selOpenerCountry).contains('Cyprus').click(); + cy.get(selPinnedOpener).should('have.length', 2); + + cy.get(selOpenerCountry).contains('Denmark').click(); + cy.get(selPinnedOpener).should('have.length', 1); + + cy.wait(1000); + // Open the next Danish entry + cy.get(selOpenerSeries).eq(13).click(); + cy.get(selPinnedOpener).should('have.length', 2); + }); + + it('should re-open a country initially opened by an individual item', () => { + cy.get(selPinnedOpener).should('have.length', 1); + cy.get(selIsOpen).should('have.length', 3); + + // open with individual item + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + cy.get(selOpenerSeries).eq(12).click(); + cy.get(selIsOpen).should('have.length', 4); + + // close with country opener + cy.get(selPinnedOpener).contains('Denmark').click(); + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + cy.get(selPinnedOpener).should('have.length', 1); + cy.get(selIsOpen).should('have.length', 3); + + // open again with country opener + cy.get(selOpenerCountry).contains('Denmark').click(); + cy.get(selIsOpen).should('have.length', 4); + }); + + it('should keep individually-opened items open when siblings added', () => { + cy.get(selIsOpen).should('have.length', 3); + + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + cy.get(selOpenerSeries).eq(12).click(); + cy.get(selIsOpen).should('have.length', 4); + cy.get(selPinnedOpener).contains('Denmark').should('have.length', 1); + + cy.get(selOpenerSeries).eq(4).click(); + cy.get(selIsOpen).should('have.length', 5); + }); + }); +}); diff --git a/src/app/_data/static-data.ts b/src/app/_data/static-data.ts index d261a713..d110a37b 100644 --- a/src/app/_data/static-data.ts +++ b/src/app/_data/static-data.ts @@ -56,7 +56,17 @@ facetNamesFriendly['dates'] = $localize`:@@facetNameDates:Last Updated`; export const portalNamesFriendly = facetNamesFriendly; -export const colours = ['#0a72cc', '#e11d53', '#ffae00', '#219d31']; +export const colours = [ + '#0a72cc', + '#e11d53', + '#ffae00', + '#219d31', + + '#0a72cc', + '#e11d53', + '#ffae00', + '#219d31' +]; export const DiacriticsMap = { A: 'AÁĂẮẶẰẲẴǍÂẤẬẦẨẪÄẠÀẢĀĄÅǺÃÆǼА', diff --git a/src/app/_mocked/index.ts b/src/app/_mocked/index.ts index 3451053e..0350b1c2 100644 --- a/src/app/_mocked/index.ts +++ b/src/app/_mocked/index.ts @@ -1,6 +1,7 @@ export * from './mockpipe'; export * from './mock-bar.component'; export * from './mock-grid.component'; +export * from './mock-line.component'; export * from './mock-export-csv.service'; export * from './mock-export-pdf.service'; export * from './mock-api.service'; diff --git a/src/app/_mocked/mock-api.service.ts b/src/app/_mocked/mock-api.service.ts index c73a9c42..01811808 100644 --- a/src/app/_mocked/mock-api.service.ts +++ b/src/app/_mocked/mock-api.service.ts @@ -3,10 +3,13 @@ import { delay } from 'rxjs/operators'; import { BreakdownRequest, BreakdownResults, + CountryTargetData, DimensionName, GeneralResults, IHash, - RequestFilter + IHashArray, + RequestFilter, + TargetData } from '../_models'; const rightsCategories = [ @@ -658,6 +661,40 @@ export class MockAPIService { ): Observable> { return of([`${rightsCategories[0]}/1.0`, `${rightsCategories[0]}/2.0`]); } + + getTargetData(): Observable>> { + return of({}); + } + + loadCountryTargetData(country: string): Observable { + const ticks = 24; + const baseValue = 12; + + const date = new Date(); + const dataRows = []; + + [...Array(ticks).keys()].forEach(() => { + dataRows.push({ + date: date, + total: baseValue, + three_d: baseValue, + meta_tier_a: baseValue + }); + }); + + const res = { + targetAll: 1000, + target3D: 200, + targetMetaTierA: 200, + dataRows: dataRows + }; + if (country === 'DE') { + res.targetAll = 1700; + res.target3D = 400; + res.targetMetaTierA = 300; + } + return of(res); + } } export class MockAPIServiceErrors extends MockAPIService { diff --git a/src/app/_mocked/mock-line.component.ts b/src/app/_mocked/mock-line.component.ts new file mode 100644 index 00000000..4f811966 --- /dev/null +++ b/src/app/_mocked/mock-line.component.ts @@ -0,0 +1,31 @@ +import { Component } from '@angular/core'; +import * as am4charts from '@amcharts/amcharts4/charts'; + +@Component({ + standalone: true, + selector: 'app-line-chart', + template: '' +}) +export class MockLineComponent { + allSeries: { [key: string]: am4charts.LineSeries } = {}; + + addSeries(_, __, ___, ____, _____): void { + console.log('MockLineComponent addSeries'); + } + + removeSeries(_: string): void { + console.log('MockLineComponent removeSeries'); + } + + showRange(_: string, __: number): void { + console.log('MockLineComponent showRange'); + } + + hideRange(_: string, __: number): void { + console.log('MockLineComponent hideRange'); + } + + hideSeries(_: string): void { + console.log('MockLineComponent hideSeries'); + } +} diff --git a/src/app/_models/index.ts b/src/app/_models/index.ts index a2388282..b8145494 100644 --- a/src/app/_models/index.ts +++ b/src/app/_models/index.ts @@ -3,3 +3,4 @@ export * from './chart'; export * from './grid'; export * from './models'; export * from './stats-server'; +export * from './targets'; diff --git a/src/app/_models/targets.ts b/src/app/_models/targets.ts new file mode 100644 index 00000000..12e95f49 --- /dev/null +++ b/src/app/_models/targets.ts @@ -0,0 +1,20 @@ +import * as am4charts from '@amcharts/amcharts4/charts'; +import { IHash } from '../_models'; + +export interface TargetData { + value: number; + label: string; + interim?: boolean; + range?: am4charts.ValueAxisDataItem; +} + +export interface TemporalDataItem extends IHash { + date: string; +} + +export interface CountryTargetData { + targetAll: number; + target3D: number; + targetMetaTierA: number; + dataRows: Array; +} diff --git a/src/app/_services/api.service.ts b/src/app/_services/api.service.ts index d250000e..87449b9e 100644 --- a/src/app/_services/api.service.ts +++ b/src/app/_services/api.service.ts @@ -1,12 +1,16 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; import { environment } from '../../environments/environment'; import { BreakdownRequest, BreakdownResults, + CountryTargetData, GeneralResults, - IHash + IHash, + IHashArray, + TargetData } from '../_models'; import { ISOCountryCodes } from '../_data'; @@ -16,7 +20,16 @@ export class APIService { suffixFiltering = 'statistics/filtering'; suffixRightsUrls = 'statistics/rights/urls'; - constructor(private readonly http: HttpClient) {} + dateTicks: Array = []; + + constructor(private readonly http: HttpClient) { + for (let i = 0; i < 24; i++) { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(i); + this.dateTicks.push(date.toISOString()); + } + } loadISOCountryCodes(): IHash { return ISOCountryCodes; @@ -54,4 +67,126 @@ export class APIService { { params: { rightsCategories } } ); } + + /** + * loadTargetData + * + * Returns data as is from back end + * + * Expected back-end format: + * { + * "country": "DE", + * "label": "2025", + * "value": 370, + * "interim": true, + * "targetType": "total" + * }... + **/ + loadTargetData(): Observable>> { + const res = [ + 'BE', + 'CY', + 'CZ', + 'DK', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IT', + 'MK', + 'MT', + 'NL', + 'PL', + 'SK', + 'SI', + 'SE' + ].map((country: string, index: number) => { + const resLabel = ['2025', '2030'].map((label: string) => { + // make values larger for later targets + let value = parseInt(label) * (index + 1); + return ['total', 'three_d', 'meta_tier_a'].map((targetType: string) => { + // make subtarget values smaller than total + value -= 123; + return { + country, + targetType, + label, + interim: label === '2025', + value + }; + }); + }); + return [].concat(...resLabel); + }); + const rows = [].concat(...res); + return of(rows); + } + + /** getTargetData + * invokes loadTargetData and reduces rows to a single hash + **/ + getTargetData(): Observable>> { + return this.loadTargetData().pipe( + map((rows: Array>) => { + return rows.reduce( + ( + res: IHash>, + item: IHash + ) => { + const country = item.country as string; + if (!res[country]) { + res[country] = {}; + } + + let arr = res[country][item.targetType as string]; + if (!arr) { + arr = []; + res[country][item.targetType as string] = arr; + } + + const resEntry = {} as TargetData; + + resEntry.label = item.label as string; + resEntry.value = item.value as number; + resEntry.interim = item.interim as boolean; + + arr.push(resEntry); + return res; + }, + {} + ); + }) + ); + } + + loadCountryTargetData(country: string): Observable { + let baseValue = 12; + const numDateTicks = this.dateTicks.length; + + const res = { + targetAll: 1000, + target3D: 200, + targetMetaTierA: 200, + dataRows: [] + }; + if (country === 'DE') { + res.targetAll = 1700; + res.target3D = 400; + res.targetMetaTierA = 300; + baseValue = baseValue * 12; + } + res.dataRows = this.dateTicks.map((dateTick: string) => { + baseValue += + (baseValue % (numDateTicks + 1)) - (baseValue % (numDateTicks / 2)); + return { + date: dateTick, + total: baseValue, + three_d: baseValue / 4, + meta_tier_a: baseValue / 5 + }; + }); + + return of(res); + } } diff --git a/src/app/_translate/index.ts b/src/app/_translate/index.ts index 9b0d302a..1ac8181c 100644 --- a/src/app/_translate/index.ts +++ b/src/app/_translate/index.ts @@ -1,3 +1,5 @@ export * from './highlight-match.pipe'; +export * from './rename-country.pipe'; export * from './rename-facet.pipe'; export * from './rename-facet-short.pipe'; +export * from './rename-target-type.pipe'; diff --git a/src/app/_translate/rename-country.pipe.ts b/src/app/_translate/rename-country.pipe.ts new file mode 100644 index 00000000..252a8cd6 --- /dev/null +++ b/src/app/_translate/rename-country.pipe.ts @@ -0,0 +1,22 @@ +/** RenameCountryPipe +/* +/* a translation utility for html files +/* supplies human-readable labels for countries +*/ +import { Pipe, PipeTransform } from '@angular/core'; +import { ISOCountryCodes } from '../_data'; + +@Pipe({ + name: 'renameCountry', + standalone: true +}) +export class RenameCountryPipe implements PipeTransform { + countryNames = Object.entries(ISOCountryCodes).reduce( + (obj, item) => (obj[item[1]] = item[0]) && obj, + {} + ); + + transform(value: string): string { + return this.countryNames[value] || value; + } +} diff --git a/src/app/_translate/rename-target-type.pipe.ts b/src/app/_translate/rename-target-type.pipe.ts new file mode 100644 index 00000000..222e5742 --- /dev/null +++ b/src/app/_translate/rename-target-type.pipe.ts @@ -0,0 +1,22 @@ +/** RenameTargetType +/* +/* a translation utility for html files +/* supplies human-readable labels for target types +*/ +import { Pipe, PipeTransform } from '@angular/core'; + +const targetTypeNames = { + total: 'Total', + three_d: '3D', + meta_tier_a: 'Metadata Tier A' +}; + +@Pipe({ + name: 'renameTargetType', + standalone: true +}) +export class RenameTargetTypePipe implements PipeTransform { + transform(value: string): string { + return targetTypeNames[value] || value; + } +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 911c2bd7..9a1f6be2 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -2,8 +2,13 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { OverviewComponent } from './overview/overview.component'; import { LandingComponent } from './landing/landing.component'; +import { TargetsComponent } from './targets/targets.component'; const routes: Routes = [ + { + path: 'targets', + component: TargetsComponent + }, { path: 'data/:facet', component: OverviewComponent diff --git a/src/app/chart/index.ts b/src/app/chart/index.ts index f6d549d5..206b7982 100644 --- a/src/app/chart/index.ts +++ b/src/app/chart/index.ts @@ -1,3 +1,4 @@ export * from './chart-defaults'; export * from './bar/bar.component'; export * from './map/map.component'; +export * from './line/line.component'; diff --git a/src/app/chart/line/line.component.html b/src/app/chart/line/line.component.html new file mode 100644 index 00000000..6ef31f80 --- /dev/null +++ b/src/app/chart/line/line.component.html @@ -0,0 +1 @@ +
diff --git a/src/app/chart/line/line.component.scss b/src/app/chart/line/line.component.scss new file mode 100644 index 00000000..73d8cb0c --- /dev/null +++ b/src/app/chart/line/line.component.scss @@ -0,0 +1,5 @@ +#lineChart { + height: 100%; + min-height: 375px; + width: 100%; +} diff --git a/src/app/chart/line/line.component.spec.ts b/src/app/chart/line/line.component.spec.ts new file mode 100644 index 00000000..97555804 --- /dev/null +++ b/src/app/chart/line/line.component.spec.ts @@ -0,0 +1,25 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { LineComponent } from './line.component'; + +describe('LineComponent', () => { + let component: LineComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [LineComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LineComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/chart/line/line.component.ts b/src/app/chart/line/line.component.ts new file mode 100644 index 00000000..0f42ea76 --- /dev/null +++ b/src/app/chart/line/line.component.ts @@ -0,0 +1,408 @@ +import { + AfterViewInit, + Component, + Inject, + Input, + NgZone, + PLATFORM_ID +} from '@angular/core'; +import { NgFor, NgIf } from '@angular/common'; + +import * as am4core from '@amcharts/amcharts4/core'; +import * as am4charts from '@amcharts/amcharts4/charts'; +import * as am4plugins_bullets from '@amcharts/amcharts4/plugins/bullets'; +import am4themes_animated from '@amcharts/amcharts4/themes/animated'; + +import { colours } from '../../_data'; + +import { IHash, IHashArray, TargetData, TemporalDataItem } from '../../_models'; + +@Component({ + selector: 'app-line-chart', + templateUrl: './line.component.html', + styleUrls: ['./line.component.scss'], + standalone: true, + imports: [NgIf, NgFor] +}) +export class LineComponent implements AfterViewInit { + allSeriesData: { + [key: string]: { + series: am4charts.LineSeries; + dataMax: number; + appliedTargetIndicatorValue?: number; + }; + } = {}; + + chart: am4charts.XYChart; + + valueAxis: am4charts.ValueAxis; + + @Input() targetData: IHash>; + + constructor( + @Inject(PLATFORM_ID) private readonly platformId, + private readonly zone: NgZone + ) { + am4core.options.autoDispose = true; + } + + ngAfterViewInit(): void { + this.drawChart(); + } + + rangeRemovalCleanUp(): void { + if (this.valueAxis.axisRanges.length === 0) { + this.chart.paddingRight = 0; + this.valueAxis.max = undefined; + this.valueAxis.min = undefined; + } else { + // TODO: recalculate the max min to reclaim freed space. + console.log('recalculate the max min to reclaim freed space'); + + // who are the other range users? + + // this is the series data... now recorded... + // ...but it may not be needed... + Object.keys(this.allSeriesData).forEach((key: string) => { + const seriesData = this.allSeriesData[key]; + const series = seriesData.series; + if (!series.isHidden) { + console.log(' - still showing ' + seriesData.dataMax); + } + }); + // this.targetData[country][the others....] + } + } + + /** removeRange + * Removes either a single range or a set of ranges + **/ + removeRange( + country: string, + specificType?: string, + specificIndex?: number + ): void { + /* + //orig + + this.targetData[country][type] + .forEach( + (td: TargetData, tdIndex: number) => { + if (parseInt(`${specificIndex}`) > -1) { + if (specificIndex !== tdIndex) { + return; + } + } + this.valueAxis.axisRanges.removeValue(td.range); + delete td.range; + } + ); + */ + + ['total', 'three_d', 'meta_tier_a'].forEach((seriesType: string) => { + if (!specificType || specificType === seriesType) { + const targetDataType = this.targetData[country][seriesType]; + if (targetDataType) { + targetDataType.forEach((td: TargetData, tdIndex: number) => { + if (parseInt(`${specificIndex}`) > -1) { + if (specificIndex !== tdIndex) { + return; + } + } + this.valueAxis.axisRanges.removeValue(td.range); + delete td.range; + }); + } + } + }); + + /* +this.targetData[country][type] +.forEach( + (td: TargetData, tdIndex: number) => { + if (parseInt(`${specificIndex}`) > -1) { + if (specificIndex !== tdIndex) { + return; + } + } + this.valueAxis.axisRanges.removeValue(td.range); + delete td.range; + } +); + +*/ + this.rangeRemovalCleanUp(); + } + + /* + removeRangeSet(country: string, type: string): void { + this.targetData[country][type].forEach((td: TargetData)=> { + this.valueAxis.axisRanges.removeValue(td.range); + delete td.range; + }); + this.rangeRemovalCleanUp(); + } + + removeRange(country: string, type: string, index: number): void { + //this.valueAxis.axisRanges.clear(); + this.valueAxis.axisRanges.removeValue( + this.targetData[country][type][index].range + ); + delete this.targetData[country][type][index].range; + this.rangeRemovalCleanUp(); + } + */ + + showRange( + country: string, + type: string, + index: number, + colour: am4core.Color + ): void { + this.chart.paddingRight = 38; + console.log('showRange type = ' + type + ', color = ' + colour); + this.addChartTargetIndicator(country, type, index, colour); + } + + /** createRange + * creates and styles a (pinned) axisRange + * assigns reference for open / closing behaviour + * updates appliedTargetIndicatorValue + **/ + createRange( + valueAxis: am4charts.ValueAxis, + targetData: TargetData, + colour: am4core.Color + ): void { + const colourPin = am4core.color('#0c529c'); // eu-flag colour + const colourRangeLine = colour; // '#009900'; //'#A96478'; + const colourRangeFill = colourRangeLine; //'#990000';//'#396478'; + + const range = valueAxis.axisRanges.create(); + range.value = targetData.value; + + console.log('createRange: add limit of ' + targetData.value); + /* + TODO: can we update this here? + // allSeriesData[] appliedTargetIndicatorValue + no. + We'd have to run a lookup... + ...and it doesn't map yet, because the targetData is not typed? + */ + + range.axisFill.fill = colourRangeFill; + range.axisFill.fillOpacity = 0.3; + + if (targetData.interim) { + range.grid.strokeDasharray = '3,3'; + } + range.grid.above = true; + // the grid line should be at the top, not the middle + range.grid.location = 0; + range.grid.stroke = colourRangeLine; + range.grid.strokeWidth = 2; + range.grid.strokeOpacity = 1; + + range.label.inside = true; + range.label.location = 0; + // range.label.zIndex = 3; + + range.label.align = 'right'; + range.label.text = targetData.label; + range.label.fill = range.grid.stroke; + range.label.verticalCenter = 'bottom'; + range.label.fontSize = 14; + + const pin = range.label.createChild(am4plugins_bullets.PinBullet); + + //pin.background.radius = 20; + pin.background.radius = 10; + pin.background.fill = colourPin; + pin.cursorOverStyle = am4core.MouseCursorStyle.pointer; + pin.dy = 29; + + const setRangeAndPinDefaults = (): void => { + const defaultDx = 24; + range.endValue = range.value; + pin.background.pointerAngle = 180; + pin.dx = defaultDx; + }; + + setRangeAndPinDefaults(); + + pin.verticalCenter = 'top'; + pin.image = new am4core.Image(); + pin.image.href = + 'https://upload.wikimedia.org/wikipedia/en/2/27/EU_flag_square.PNG'; + + // toggle display of the limit floor + pin.events.on('hit', function () { + if (range.endValue === range.value) { + // the minimum allowed is 40% of the target + range.endValue = range.value * 0.4; + pin.background.pointerAngle = 90; + pin.dx = 42; + } else { + setRangeAndPinDefaults(); + } + }); + // targetData.pin = pin; + targetData.range = range; + } + + getMaxValue(data: Array, country: string): number { + return data.reduce((highest: number, item: TemporalDataItem) => { + const val = item[country] as number; + if (val && val > highest) { + highest = val; + } + return highest; + }, 0); + } + + addChartTargetIndicator( + country: string, + type: string, + index: number, + colour: am4core.Color + ): void { + const targetData = this.targetData[country][type][index]; + const maxValue = this.getMaxValue(this.chart.data, country); + + console.log('maxValue for ' + country + ' = ' + maxValue); + + this.createRange(this.valueAxis, targetData, colour); + + if (targetData.value > maxValue) { + console.log( + 'LIMIT (' + targetData.value + ') exceeds highest value ' + maxValue + ); + if (targetData.value > this.valueAxis.max) { + console.log( + 'limits (' + + targetData.value + + ') exceeds AXIS value ' + + this.valueAxis.max + ); + } else { + console.log( + 'limits (' + + targetData.value + + ') NOT exceeds AXIS value ' + + this.valueAxis.max + ); + } + this.valueAxis.max = targetData.value; + } + } + + removeSeries(id: string): void { + const series = this.allSeriesData[id]; + + if (series) { + const seriesIndex = this.chart.series.indexOf(series.series); + if (seriesIndex > -1) { + this.chart.series.removeIndex(seriesIndex).dispose(); + + console.log('remove series id ' + id); + } + delete this.allSeriesData[id]; + } else { + console.log(`Line: can't find series to remove (${id})`); + } + this.chart.invalidateData(); + } + + hideSeries(id: string): void { + const series = this.allSeriesData[id]; + if (series) { + series.series.hide(); + } + } + + showSeries(id: string): void { + const series = this.allSeriesData[id]; + if (series) { + series.series.show(); + } + } + + addSeries( + seriesDisplayName: string, + seriesValueY: string, + valueY: string, + // isHidden: boolean, + seriesData?: Array + ): void { + const series = this.chart.series.push(new am4charts.LineSeries()); + series.dataFields.valueY = seriesValueY; + series.dataFields.dateX = 'date'; + + series.strokeWidth = 2; + series.tooltipText = `${seriesDisplayName} {${seriesValueY}}`; + series.tooltip.pointerOrientation = 'vertical'; + series.tooltip.getFillFromObject = true; + + // series.hidden = isHidden; + + let seriesMax = 0; + if (seriesData) { + const chartData = this.chart.data; + + seriesData.forEach((sd: TemporalDataItem, rowIndex: number) => { + const val = sd[valueY] as number; + if (rowIndex >= chartData.length) { + chartData.push(sd); + } + chartData[rowIndex][seriesValueY] = val; + if (val > seriesMax) { + seriesMax = val; + } + }); + + //this.chart.invalidateData(); + } + + // console.log('add series name ' + seriesValueY + ', max is ' + seriesMax); + + // const maxValue = this.getMaxValue(this.chartData, country); + + this.allSeriesData[seriesValueY] = { + series: series, + dataMax: seriesMax + }; + } + + /** drawChart + /* ... + */ + drawChart(): void { + am4core.useTheme(am4themes_animated); + + // Create chart instance + const chart = am4core.create('lineChart', am4charts.XYChart); + this.chart = chart; + + chart.colors.list = colours.map((colour: string) => { + return am4core.color(colour); + }); + + //chart.maskBullets = false; + // chart.data = this.chartData; + chart.data = [{}]; //this.chartData; + + // Create date axis + const dateAxis = chart.xAxes.push(new am4charts.DateAxis()); + dateAxis.renderer.minGridDistance = 60; + + // Create value axis + this.valueAxis = chart.yAxes.push(new am4charts.ValueAxis()); + this.valueAxis.extraMax = 0.1; + //this.valueAxis.extraMin = 0.2; + + chart.cursor = new am4charts.XYCursor(); + chart.cursor.xAxis = dateAxis; + //chart.scrollbarX = new am4core.Scrollbar(); + //chart.legend = new am4charts.Legend(); + } +} diff --git a/src/app/targets/index.ts b/src/app/targets/index.ts new file mode 100644 index 00000000..3eff6195 --- /dev/null +++ b/src/app/targets/index.ts @@ -0,0 +1 @@ +export * from './targets.component'; diff --git a/src/app/targets/targets.component.html b/src/app/targets/targets.component.html new file mode 100644 index 00000000..0492b212 --- /dev/null +++ b/src/app/targets/targets.component.html @@ -0,0 +1,128 @@ +
+
+

+ + {{ c.key | renameCountry }} + +

+ + + + + +
+ + + + + + {{ country | renameCountry }} + + + + + + + + + + + + + +
+ +
+ + + +
diff --git a/src/app/targets/targets.component.scss b/src/app/targets/targets.component.scss new file mode 100644 index 00000000..25c9e8f0 --- /dev/null +++ b/src/app/targets/targets.component.scss @@ -0,0 +1,190 @@ +@import "../../scss/_variables"; +@import "../../scss/functions/_icon_urls"; +@import "../../scss/functions/_replace"; + +$disabled-colour: $gray-5; + +li { + list-style: none; +} + +.bulleted { + list-style: disc; +} + +.indented { + transform: translateX(16px); + &::marker { + color: inherit; + } +} + +.legend-grid { + grid-template-columns: auto auto auto auto; + margin: 2em 0; + max-height: 20em; + overflow: auto; + position: relative; +} + +.legend-grid-inner { + grid-template-columns: auto auto auto; + margin: 0; + column-gap: 4px; + + & > * { + border-left: 1px solid $disabled-colour; + } +} + +.legend-grid-wide-cell { + grid-column: 2/5; + margin: 0; +} + +.legend-grid, +.legend-grid-inner { + background: #fff; + display: grid; + font-size: 14px; + & > :not(.legend-grid-wide-cell) { + padding: 0.6em; + } + & > * { + white-space: nowrap; + } +} + +.legend-item-country-opener { + color: #000; + font-weight: bold; + position: relative; +} + +.legend-item-series-opener, +.legend-item-target-opener { + color: $gray-med; + + &.hidden { + color: $disabled-colour; + } +} + +.legend-item-target-opener { + font-size: 12px; + font-weight: bold; +} + +.target-total { + font-weight: normal; + margin-left: 0.4em; + margin-left: 4.4em; + color: #777; +} + +.legend-item-series-opener { + font-weight: bold; + + display: flex; + flex-direction: row; + align-items: center; + + * { + line-height: 1.6em; + } + + // legend marker + .marker { + border: 1px solid $disabled-colour; + content: ""; + display: block; + height: 12px; + margin-right: 0.4em; + width: 12px; + } +} + +.stick-left, +.stick-right { + background-color: #fff; + position: sticky; + z-index: 1; +} + +.stick-left { + // use transparent fade on the the last of class + &:not(:has(~ .stick-left)) { + background: linear-gradient(0deg, transparent 00%, #fff 50%); + } +} + +.stick-right { + right: 0; +} + +.title { + &:not(:last-child)::after { + content: ","; + } +} + +$timeTransition: 0.25s; + +.roll-down-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows $timeTransition linear, + margin-bottom $timeTransition linear; + + &:not(.is-open) > * { + grid-template-rows: 1fr; + } + + &.is-open { + grid-template-rows: 1fr; + } + + .roll-down { + overflow: hidden; + } +} + +///////////// COPIED + +.save, +.saved { + background-position: center; + background-size: 14px; + background-image: url("data:image/svg+xml,%3Csvg xmlns:dc='http://purl.org/dc/elements/1.1/' xmlns:cc='http://creativecommons.org/ns%23' xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns%23' xmlns:svg='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg' xmlns:sodipodi='http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd' xmlns:inkscape='http://www.inkscape.org/namespaces/inkscape' viewBox='0 -256 1792 1792' id='svg2' version='1.1' inkscape:version='0.48.3.1 r9886' width='100%25' height='100%25' sodipodi:docname='pushpin_font_awesome.svg'%3E%3Cmetadata id='metadata12'%3E%3Crdf:rdf%3E%3Ccc:work rdf:about=''%3E%3Cdc:format%3Eimage/svg+xml%3C/dc:format%3E%3Cdc:type rdf:resource='http://purl.org/dc/dcmitype/StillImage'%3E%3C/dc:type%3E%3C/cc:work%3E%3C/rdf:rdf%3E%3C/metadata%3E%3Cdefs id='defs10'%3E%3C/defs%3E%3Csodipodi:namedview pagecolor='%23ffffff' bordercolor='%23666666' borderopacity='1' objecttolerance='10' gridtolerance='10' guidetolerance='10' inkscape:pageopacity='0' inkscape:pageshadow='2' inkscape:window-width='640' inkscape:window-height='480' id='namedview8' showgrid='false' inkscape:zoom='0.13169643' inkscape:cx='896' inkscape:cy='896' inkscape:window-x='0' inkscape:window-y='25' inkscape:window-maximized='0' inkscape:current-layer='svg2'%3E%3C/sodipodi:namedview%3E%3Cg transform='matrix%281,0,0,-1,318.91525,1209.4915%29' id='g4'%3E%3Cpath d='m 480,672 v 448 q 0,14 -9,23 -9,9 -23,9 -14,0 -23,-9 -9,-9 -9,-23 V 672 q 0,-14 9,-23 9,-9 23,-9 14,0 23,9 9,9 9,23 z m 672,-352 q 0,-26 -19,-45 -19,-19 -45,-19 H 659 l -51,-483 q -2,-12 -10.5,-20.5 Q 589,-256 577,-256 h -1 q -27,0 -32,27 L 468,256 H 64 Q 38,256 19,275 0,294 0,320 0,443 78.5,541.5 157,640 256,640 v 512 q -52,0 -90,38 -38,38 -38,90 0,52 38,90 38,38 90,38 h 640 q 52,0 90,-38 38,-38 38,-90 0,-52 -38,-90 -38,-38 -90,-38 V 640 q 99,0 177.5,-98.5 Q 1152,443 1152,320 z' id='path6' inkscape:connector-curvature='0' style='fill:%23828282'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); + background-repeat: no-repeat; + content: ""; + height: 12px; + left: -21px; + padding: 12px; + position: absolute; + top: -2px; + transform: rotate(270deg); + transition: transform 20ms; + width: 12px; + + &:hover { + top: -1px; + transform: rotate(360deg); + } +} + +.save { + // change + opacity: 0; +} + +.saved { + // change + // opacity: 1; +} + +// change +.saved { + top: -1px; + transform: rotate(360deg); +} diff --git a/src/app/targets/targets.component.spec.ts b/src/app/targets/targets.component.spec.ts new file mode 100644 index 00000000..2d35da67 --- /dev/null +++ b/src/app/targets/targets.component.spec.ts @@ -0,0 +1,40 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { APIService } from '../_services'; +import { MockAPIService, MockLineComponent } from '../_mocked'; +import { LineComponent } from '../chart'; + +import { TargetsComponent } from '.'; + +describe('TargetsComponent', () => { + let component: TargetsComponent; + let fixture: ComponentFixture; + + const configureTestBed = (): void => { + TestBed.configureTestingModule({ + imports: [TargetsComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [{ provide: APIService, useClass: MockAPIService }] + }) + .overrideComponent(TargetsComponent, { + remove: { imports: [LineComponent] }, + add: { imports: [MockLineComponent] } + }) + .compileComponents(); + }; + + beforeEach(waitForAsync(() => { + configureTestBed(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TargetsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/targets/targets.component.ts b/src/app/targets/targets.component.ts new file mode 100644 index 00000000..2c9335c4 --- /dev/null +++ b/src/app/targets/targets.component.ts @@ -0,0 +1,262 @@ +import { + DecimalPipe, + JsonPipe, + KeyValuePipe, + NgClass, + NgFor, + NgIf, + NgStyle, + NgTemplateOutlet +} from '@angular/common'; +import { Component, CUSTOM_ELEMENTS_SCHEMA, ViewChild } from '@angular/core'; +import * as am4charts from '@amcharts/amcharts4/charts'; + +import { CountryTargetData, IHash, IHashArray, TargetData } from '../_models'; +import { APIService } from '../_services'; +import { RenameCountryPipe, RenameTargetTypePipe } from '../_translate'; +import { LineComponent } from '../chart'; +import { SubscriptionManager } from '../subscription-manager'; + +@Component({ + selector: 'app-targets', + templateUrl: './targets.component.html', + schemas: [CUSTOM_ELEMENTS_SCHEMA], + styleUrls: ['./targets.component.scss'], + standalone: true, + imports: [ + DecimalPipe, + JsonPipe, + NgClass, + NgIf, + NgFor, + NgTemplateOutlet, + NgStyle, + KeyValuePipe, + LineComponent, + RenameCountryPipe, + RenameTargetTypePipe + ] +}) +export class TargetsComponent extends SubscriptionManager { + targetCountries: Array; + targetCountriesOO: Array; + targetData: IHash>; + loadedCountryTargetData: IHash = {}; + pinnedCountries: IHash = {}; + + public seriesSuffixes = ['total', '3D', 'META_A']; + public seriesSuffixesFmt = [' (total)', ' (3D)', ' (meta tier A)']; + public seriesValueNames = ['total', 'three_d', 'meta_tier_a']; + + @ViewChild('lineChart') lineChart: LineComponent; + + constructor(private readonly api: APIService) { + super(); + this.subs.push( + this.api.getTargetData().subscribe((targetData) => { + this.targetData = targetData; + this.targetCountries = Object.keys(targetData); + this.targetCountriesOO = Object.keys(targetData); + }) + ); + } + + ngAfterContentInit(): void { + setTimeout(() => { + this.toggleCountryTargetData('DE'); + this.pinnedCountries['DE'] = 0; + }, 0); + } + + getCountrySeries(country: string): Array<{ series: am4charts.LineSeries }> { + const res = this.seriesSuffixes + .map((seriesSuffix: string) => { + const seriesName = `${country}${seriesSuffix}`; + + return this.lineChart.allSeriesData[seriesName]; + }) + .filter((x) => { + return x; + }); + return res; + } + + /** resetChartColors + * aligns the chart's internal color index with the visible series count + **/ + resetChartColors(): void { + const data = this.lineChart.allSeriesData; + let openCount = 0; + + Object.keys(data).forEach((key: string) => { + const isHidden = this.lineChart.allSeriesData[key].series.isHidden; + if (!isHidden) { + openCount += 1; + } + }); + + this.lineChart.chart.colors.reset(); + for ( + let i = 0; + i < openCount % this.lineChart.chart.colors.list.length; + i++ + ) { + this.lineChart.chart.colors.next(); + } + } + + /** toggleCountryTargetData + * shows existing (hidden) data or loads and creates series + * @param { string } country - the target series + * @return boolean + **/ + toggleCountryTargetData(country: string): void { + if (this.loadedCountryTargetData[country]) { + const countrySeries = this.getCountrySeries(country); + const hasVisible = + countrySeries.filter((series: { series: am4charts.LineSeries }) => { + return !series.series.isHidden; + }).length > 0; + + if (hasVisible) { + countrySeries.forEach((series: { series: am4charts.LineSeries }) => { + series.series.hide(); + }); + this.togglePin(country); + } else { + countrySeries.forEach((series: { series: am4charts.LineSeries }) => { + series.series.show(); + }); + this.togglePin(country); + } + } else { + this.loadCountryTargetData(country); + } + } + + loadCountryTargetData( + country: string, + specificSeriesTypeIndex?: number + ): void { + this.resetChartColors(); + this.subs.push( + this.api + .loadCountryTargetData(country) + .subscribe((countryTargetData: CountryTargetData) => { + this.loadedCountryTargetData[country] = countryTargetData; + + // add pin and series + this.togglePin(country); + + // loop the types + [...Array(3).keys()].forEach((i: number) => { + const skipSeriesCreation = + !isNaN(specificSeriesTypeIndex) && specificSeriesTypeIndex !== i; + if (!skipSeriesCreation) { + this.lineChart.addSeries( + country + this.seriesSuffixesFmt[i], + country + this.seriesSuffixes[i], + this.seriesValueNames[i], + countryTargetData.dataRows + ); + } + }); + }) + ); + } + + removeRange(country: string, type: string, index: number): void { + this.lineChart.removeRange(country, type, index); + } + + /** togglePin + * pins or unpins an item, maintaining pinnedCountries + * and the order of targetCountries + * @param { string } country - the country to (un)pin + **/ + togglePin(country: string): void { + const itemHeight = 84; + + if (country in this.pinnedCountries) { + // delete and re-assign existing pin values + delete this.pinnedCountries[country]; + Object.keys(this.pinnedCountries).forEach((key: string, i: number) => { + this.pinnedCountries[key] = i * itemHeight; + }); + } else { + // add new pin + this.pinnedCountries[country] = + Object.keys(this.pinnedCountries).length * itemHeight; + } + + // re-order targetCountries, putting the pinned items first + this.targetCountries = Object.keys(this.pinnedCountries).concat( + this.targetCountriesOO.filter((country: string) => { + return !(country in this.pinnedCountries); + }) + ); + } + + /** toggleSeries + * loads a series if it hasn't been loaded and toggles its display + * @param { string } + * @param { string } + * @param { LineSeries } + **/ + toggleSeries( + country: string, + type: string, + series?: am4charts.LineSeries + ): void { + const typeIndex = this.seriesValueNames.indexOf(type); + + if (!series) { + if (this.loadedCountryTargetData[country]) { + // create from existing data + this.resetChartColors(); + const visibleSiblings = this.getCountrySeries(country).filter( + (series: { series: am4charts.LineSeries }) => { + return !series.series.isHidden; + } + ); + + this.lineChart.addSeries( + country + this.seriesSuffixesFmt[typeIndex], + country + this.seriesSuffixes[typeIndex], + this.seriesValueNames[typeIndex], + this.loadedCountryTargetData[country].dataRows + ); + + if (visibleSiblings.length === 0) { + this.togglePin(country); + } + } else { + this.loadCountryTargetData(country, typeIndex); + } + return; + } + if (series.isHidden) { + series.show(); + + if (!(country in this.pinnedCountries)) { + this.togglePin(country); + } + } else { + series.hide(); + // remove associated ranges + this.lineChart.removeRange(country, type); + + let visCount = 0; + this.seriesSuffixes.forEach((suffix: string) => { + const sd = this.lineChart.allSeriesData[country + suffix]; + if (sd && !sd.series.isHidden) { + visCount += 1; + } + }); + // yes we can unpin - will be 0 on animation completion + if (visCount === 1) { + this.togglePin(country); + } + } + } +} From 93c1e75d340a4fbb9fa4f546fc61599bf13c3735 Mon Sep 17 00:00:00 2001 From: andyjmaclean Date: Mon, 11 Mar 2024 18:34:22 +0100 Subject: [PATCH 02/90] MET-5835-Member-State-Target-Page --- cypress/e2e/targets.cy.ts | 52 ++-- src/app/_mocked/mock-api.service.ts | 34 +-- src/app/_models/targets.ts | 8 +- src/app/_services/api.service.ts | 207 ++++++++------- src/app/chart/line/line.component.scss | 3 +- src/app/chart/line/line.component.ts | 338 ++++++++----------------- src/app/targets/targets.component.html | 242 +++++++++++++----- src/app/targets/targets.component.scss | 286 +++++++++++++++++---- src/app/targets/targets.component.ts | 185 ++++++++------ 9 files changed, 785 insertions(+), 570 deletions(-) diff --git a/cypress/e2e/targets.cy.ts b/cypress/e2e/targets.cy.ts index 6ff59e9f..274f055d 100644 --- a/cypress/e2e/targets.cy.ts +++ b/cypress/e2e/targets.cy.ts @@ -4,11 +4,23 @@ context('Statistics Dashboard', () => { cy.visit('/targets'); }); + const force = { force: true }; const selIsOpen = '.is-open'; const selLegendGrid = '.legend-grid'; const selPinnedOpener = '.stick-left'; - const selOpenerCountry = '.legend-item-country-opener'; - const selOpenerSeries = '.legend-item-series-opener'; + const selToggleCountry = '.legend-item-country-toggle'; + const selOpenerSeries = '.legend-item-series-toggle'; + + const clickSeriesOpener = (country: string, seriesIndex = 0): void => { + cy.get(selToggleCountry) + .contains(country) + .closest(selToggleCountry) + .next() + .find(selOpenerSeries) + .eq(seriesIndex) + .click(force); + cy.wait(1000); + }; it('should show the legend', () => { cy.get('#lineChart').should('have.length', 1); @@ -17,7 +29,7 @@ context('Statistics Dashboard', () => { it('should pin a country when opened', () => { cy.get(selPinnedOpener).should('have.length', 1); - cy.get(selOpenerCountry) + cy.get(selToggleCountry) .contains('Cyprus') .should('have.length', 1) .click(); @@ -28,14 +40,14 @@ context('Statistics Dashboard', () => { cy.get(selPinnedOpener).should('have.length', 1); cy.get(selIsOpen).should('have.length', 3); - cy.get(selOpenerCountry) + cy.get(selToggleCountry) .contains('Cyprus') .should('have.length', 1) .click(); cy.get(selPinnedOpener).should('have.length', 2); cy.get(selIsOpen).should('have.length', 6); - cy.get(selOpenerCountry).contains('Cyprus').click(); + cy.get(selToggleCountry).contains('Cyprus').click(); cy.get(selPinnedOpener).should('have.length', 1); cy.get(selIsOpen).should('have.length', 3); }); @@ -43,24 +55,27 @@ context('Statistics Dashboard', () => { it('should pin a country (when individual item opened)', () => { cy.get(selPinnedOpener).should('have.length', 1); - // Open an entry under Cyprus - cy.get(selOpenerSeries).eq(6).click(); + // Open (the first) Cyprus series + clickSeriesOpener('Cyprus'); cy.get(selPinnedOpener).should('have.length', 2); - // Open an entry under Denmark - cy.get(selOpenerSeries).eq(12).click(); + // Open (the first) Danish series + clickSeriesOpener('Denmark'); cy.get(selPinnedOpener).should('have.length', 3); - // Close again with country openers - cy.get(selOpenerCountry).contains('Cyprus').click(); + // Close each with country togglers + cy.get(selToggleCountry).contains('Cyprus').click(force); + cy.wait(1000); + cy.get(selPinnedOpener).should('have.length', 2); - cy.get(selOpenerCountry).contains('Denmark').click(); + cy.get(selToggleCountry).contains('Denmark').click(force); + cy.wait(1000); + cy.get(selPinnedOpener).should('have.length', 1); - cy.wait(1000); // Open the next Danish entry - cy.get(selOpenerSeries).eq(13).click(); + clickSeriesOpener('Denmark', 1); cy.get(selPinnedOpener).should('have.length', 2); }); @@ -70,7 +85,7 @@ context('Statistics Dashboard', () => { // open with individual item cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); - cy.get(selOpenerSeries).eq(12).click(); + clickSeriesOpener('Denmark'); cy.get(selIsOpen).should('have.length', 4); // close with country opener @@ -80,7 +95,7 @@ context('Statistics Dashboard', () => { cy.get(selIsOpen).should('have.length', 3); // open again with country opener - cy.get(selOpenerCountry).contains('Denmark').click(); + cy.get(selToggleCountry).contains('Denmark').click(); cy.get(selIsOpen).should('have.length', 4); }); @@ -88,11 +103,12 @@ context('Statistics Dashboard', () => { cy.get(selIsOpen).should('have.length', 3); cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); - cy.get(selOpenerSeries).eq(12).click(); + clickSeriesOpener('Denmark'); + cy.get(selIsOpen).should('have.length', 4); cy.get(selPinnedOpener).contains('Denmark').should('have.length', 1); - cy.get(selOpenerSeries).eq(4).click(); + clickSeriesOpener('Denmark', 1); cy.get(selIsOpen).should('have.length', 5); }); }); diff --git a/src/app/_mocked/mock-api.service.ts b/src/app/_mocked/mock-api.service.ts index 01811808..b4978784 100644 --- a/src/app/_mocked/mock-api.service.ts +++ b/src/app/_mocked/mock-api.service.ts @@ -3,13 +3,13 @@ import { delay } from 'rxjs/operators'; import { BreakdownRequest, BreakdownResults, - CountryTargetData, DimensionName, GeneralResults, IHash, IHashArray, RequestFilter, - TargetData + TargetData, + TemporalLocalisedDataItem } from '../_models'; const rightsCategories = [ @@ -666,34 +666,8 @@ export class MockAPIService { return of({}); } - loadCountryTargetData(country: string): Observable { - const ticks = 24; - const baseValue = 12; - - const date = new Date(); - const dataRows = []; - - [...Array(ticks).keys()].forEach(() => { - dataRows.push({ - date: date, - total: baseValue, - three_d: baseValue, - meta_tier_a: baseValue - }); - }); - - const res = { - targetAll: 1000, - target3D: 200, - targetMetaTierA: 200, - dataRows: dataRows - }; - if (country === 'DE') { - res.targetAll = 1700; - res.target3D = 400; - res.targetMetaTierA = 300; - } - return of(res); + loadCountryData(): Observable> { + return of([]); } } diff --git a/src/app/_models/targets.ts b/src/app/_models/targets.ts index 12e95f49..83c92a75 100644 --- a/src/app/_models/targets.ts +++ b/src/app/_models/targets.ts @@ -11,10 +11,6 @@ export interface TargetData { export interface TemporalDataItem extends IHash { date: string; } - -export interface CountryTargetData { - targetAll: number; - target3D: number; - targetMetaTierA: number; - dataRows: Array; +export interface TemporalLocalisedDataItem extends TemporalDataItem { + country: string; } diff --git a/src/app/_services/api.service.ts b/src/app/_services/api.service.ts index 87449b9e..8b1eac15 100644 --- a/src/app/_services/api.service.ts +++ b/src/app/_services/api.service.ts @@ -6,11 +6,11 @@ import { environment } from '../../environments/environment'; import { BreakdownRequest, BreakdownResults, - CountryTargetData, GeneralResults, IHash, IHashArray, - TargetData + TargetData, + TemporalLocalisedDataItem } from '../_models'; import { ISOCountryCodes } from '../_data'; @@ -21,6 +21,47 @@ export class APIService { suffixRightsUrls = 'statistics/rights/urls'; dateTicks: Array = []; + targetCountries = [ + 'BA', + 'BE', + 'CY', + 'CZ', + 'DK', + 'FI', + 'FR', + 'DE', + 'GR', + 'HU', + 'IT', + 'MK', + 'MT', + 'NL', + 'PL', + 'SK', + 'SI', + 'SE' + ]; + + targetData = [].concat( + ...this.targetCountries.map((country: string, index: number) => { + const resLabel = ['2025', '2030'].map((label: string) => { + // make values larger for later targets + let value = parseInt(label) * (index + 1); + return ['total', 'three_d', 'meta_tier_a'].map((targetType: string) => { + // make subtarget values smaller than total + value -= 123; + return { + country, + targetType, + label, + interim: label === '2025', + value: label === '2025' ? Math.floor(value * 0.7) : value + }; + }); + }); + return [].concat(...resLabel); + }) + ); constructor(private readonly http: HttpClient) { for (let i = 0; i < 24; i++) { @@ -83,44 +124,39 @@ export class APIService { * }... **/ loadTargetData(): Observable>> { - const res = [ - 'BE', - 'CY', - 'CZ', - 'DK', - 'FI', - 'FR', - 'DE', - 'GR', - 'HU', - 'IT', - 'MK', - 'MT', - 'NL', - 'PL', - 'SK', - 'SI', - 'SE' - ].map((country: string, index: number) => { - const resLabel = ['2025', '2030'].map((label: string) => { - // make values larger for later targets - let value = parseInt(label) * (index + 1); - return ['total', 'three_d', 'meta_tier_a'].map((targetType: string) => { - // make subtarget values smaller than total - value -= 123; - return { - country, - targetType, - label, - interim: label === '2025', - value - }; - }); - }); - return [].concat(...resLabel); - }); - const rows = [].concat(...res); - return of(rows); + return of(this.targetData); + } + + reduceTargetData( + rows: Array> + ): IHash> { + return rows.reduce( + ( + res: IHash>, + item: IHash + ) => { + const country = item.country as string; + if (!res[country]) { + res[country] = {}; + } + + let arr = res[country][item.targetType as string]; + if (!arr) { + arr = []; + res[country][item.targetType as string] = arr; + } + + const resEntry = {} as TargetData; + + resEntry.label = item.label as string; + resEntry.value = item.value as number; + resEntry.interim = item.interim as boolean; + + arr.push(resEntry); + return res; + }, + {} + ); } /** getTargetData @@ -129,64 +165,51 @@ export class APIService { getTargetData(): Observable>> { return this.loadTargetData().pipe( map((rows: Array>) => { - return rows.reduce( - ( - res: IHash>, - item: IHash - ) => { - const country = item.country as string; - if (!res[country]) { - res[country] = {}; - } - - let arr = res[country][item.targetType as string]; - if (!arr) { - arr = []; - res[country][item.targetType as string] = arr; - } - - const resEntry = {} as TargetData; - - resEntry.label = item.label as string; - resEntry.value = item.value as number; - resEntry.interim = item.interim as boolean; - - arr.push(resEntry); - return res; - }, - {} - ); + return this.reduceTargetData(rows); }) ); } - loadCountryTargetData(country: string): Observable { - let baseValue = 12; + loadCountryData(): Observable> { const numDateTicks = this.dateTicks.length; + const res = []; - const res = { - targetAll: 1000, - target3D: 200, - targetMetaTierA: 200, - dataRows: [] - }; - if (country === 'DE') { - res.targetAll = 1700; - res.target3D = 400; - res.targetMetaTierA = 300; - baseValue = baseValue * 12; - } - res.dataRows = this.dateTicks.map((dateTick: string) => { - baseValue += - (baseValue % (numDateTicks + 1)) - (baseValue % (numDateTicks / 2)); - return { - date: dateTick, - total: baseValue, - three_d: baseValue / 4, - meta_tier_a: baseValue / 5 - }; - }); + const targetDataRef = this.reduceTargetData(this.targetData); + + this.targetCountries.forEach((country: string, countryIndex: number) => { + const baseValueTotal = targetDataRef[country]['total'][1].value; + const baseValue3D = targetDataRef[country]['three_d'][1].value; + const baseValueTierA = targetDataRef[country]['meta_tier_a'][1].value; + + let value = baseValueTotal * 1; + let value3D = baseValue3D * 1.2; + let valueTierA = baseValueTierA * 0.9; + + this.dateTicks.forEach((dateTick: string, dateTickIndex: number) => { + const random1 = + (value % (numDateTicks + 1)) - (value % (numDateTicks / 2)); + + value -= random1; + value3D -= random1; + valueTierA += random1; - return of(res); + const random2 = + (value % (numDateTicks + 1)) + (value % (numDateTicks / 2)); + + value -= 0.8 * (random2 % random1); + value3D -= 100 * (random1 % random2); + valueTierA -= 1 * (random2 * random1 * 5); + value -= value3D / numDateTicks; + + res.push({ + country, + date: this.dateTicks[this.dateTicks.length - (dateTickIndex + 1)], + total: Math.floor(value), + three_d: Math.floor(value3D), + meta_tier_a: Math.floor(valueTierA) + }); + }); + }); + return of(res.reverse()); } } diff --git a/src/app/chart/line/line.component.scss b/src/app/chart/line/line.component.scss index 73d8cb0c..8d69ba8c 100644 --- a/src/app/chart/line/line.component.scss +++ b/src/app/chart/line/line.component.scss @@ -1,5 +1,4 @@ #lineChart { - height: 100%; - min-height: 375px; + height: 338px; // exactly 4 items width: 100%; } diff --git a/src/app/chart/line/line.component.ts b/src/app/chart/line/line.component.ts index 0f42ea76..4d86b588 100644 --- a/src/app/chart/line/line.component.ts +++ b/src/app/chart/line/line.component.ts @@ -14,7 +14,6 @@ import * as am4plugins_bullets from '@amcharts/amcharts4/plugins/bullets'; import am4themes_animated from '@amcharts/amcharts4/themes/animated'; import { colours } from '../../_data'; - import { IHash, IHashArray, TargetData, TemporalDataItem } from '../../_models'; @Component({ @@ -25,16 +24,16 @@ import { IHash, IHashArray, TargetData, TemporalDataItem } from '../../_models'; imports: [NgIf, NgFor] }) export class LineComponent implements AfterViewInit { - allSeriesData: { - [key: string]: { - series: am4charts.LineSeries; - dataMax: number; - appliedTargetIndicatorValue?: number; - }; - } = {}; - + allSeriesData: IHash = {}; chart: am4charts.XYChart; - + dateAxis: am4charts.DateAxis; + padding = { + top: 21, + bottom: 6, + left: 0, + rightDefault: 14, + rightWide: 30 + }; valueAxis: am4charts.ValueAxis; @Input() targetData: IHash>; @@ -50,30 +49,6 @@ export class LineComponent implements AfterViewInit { this.drawChart(); } - rangeRemovalCleanUp(): void { - if (this.valueAxis.axisRanges.length === 0) { - this.chart.paddingRight = 0; - this.valueAxis.max = undefined; - this.valueAxis.min = undefined; - } else { - // TODO: recalculate the max min to reclaim freed space. - console.log('recalculate the max min to reclaim freed space'); - - // who are the other range users? - - // this is the series data... now recorded... - // ...but it may not be needed... - Object.keys(this.allSeriesData).forEach((key: string) => { - const seriesData = this.allSeriesData[key]; - const series = seriesData.series; - if (!series.isHidden) { - console.log(' - still showing ' + seriesData.dataMax); - } - }); - // this.targetData[country][the others....] - } - } - /** removeRange * Removes either a single range or a set of ranges **/ @@ -82,26 +57,9 @@ export class LineComponent implements AfterViewInit { specificType?: string, specificIndex?: number ): void { - /* - //orig - - this.targetData[country][type] - .forEach( - (td: TargetData, tdIndex: number) => { - if (parseInt(`${specificIndex}`) > -1) { - if (specificIndex !== tdIndex) { - return; - } - } - this.valueAxis.axisRanges.removeValue(td.range); - delete td.range; - } - ); - */ - - ['total', 'three_d', 'meta_tier_a'].forEach((seriesType: string) => { - if (!specificType || specificType === seriesType) { - const targetDataType = this.targetData[country][seriesType]; + ['total', 'three_d', 'meta_tier_a'].forEach((seriesValueName: string) => { + if (!specificType || specificType === seriesValueName) { + const targetDataType = this.targetData[country][seriesValueName]; if (targetDataType) { targetDataType.forEach((td: TargetData, tdIndex: number) => { if (parseInt(`${specificIndex}`) > -1) { @@ -116,42 +74,11 @@ export class LineComponent implements AfterViewInit { } }); - /* -this.targetData[country][type] -.forEach( - (td: TargetData, tdIndex: number) => { - if (parseInt(`${specificIndex}`) > -1) { - if (specificIndex !== tdIndex) { - return; - } + if (this.valueAxis.axisRanges.values.length === 0) { + this.chart.paddingRight = this.padding.rightDefault; } - this.valueAxis.axisRanges.removeValue(td.range); - delete td.range; - } -); - -*/ - this.rangeRemovalCleanUp(); - } - - /* - removeRangeSet(country: string, type: string): void { - this.targetData[country][type].forEach((td: TargetData)=> { - this.valueAxis.axisRanges.removeValue(td.range); - delete td.range; - }); - this.rangeRemovalCleanUp(); - } - - removeRange(country: string, type: string, index: number): void { - //this.valueAxis.axisRanges.clear(); - this.valueAxis.axisRanges.removeValue( - this.targetData[country][type][index].range - ); - delete this.targetData[country][type][index].range; - this.rangeRemovalCleanUp(); + this.chart.invalidateData(); } - */ showRange( country: string, @@ -159,70 +86,57 @@ this.targetData[country][type] index: number, colour: am4core.Color ): void { - this.chart.paddingRight = 38; - console.log('showRange type = ' + type + ', color = ' + colour); - this.addChartTargetIndicator(country, type, index, colour); + const targetData = this.targetData[country][type][index]; + this.createRange(targetData, colour); + this.chart.paddingRight = this.padding.rightWide; } /** createRange * creates and styles a (pinned) axisRange * assigns reference for open / closing behaviour - * updates appliedTargetIndicatorValue **/ - createRange( - valueAxis: am4charts.ValueAxis, - targetData: TargetData, - colour: am4core.Color - ): void { + createRange(targetData: TargetData, colour: am4core.Color): void { const colourPin = am4core.color('#0c529c'); // eu-flag colour - const colourRangeLine = colour; // '#009900'; //'#A96478'; - const colourRangeFill = colourRangeLine; //'#990000';//'#396478'; + const range = this.valueAxis.axisRanges.create(); - const range = valueAxis.axisRanges.create(); - range.value = targetData.value; - - console.log('createRange: add limit of ' + targetData.value); - /* - TODO: can we update this here? - // allSeriesData[] appliedTargetIndicatorValue - no. - We'd have to run a lookup... - ...and it doesn't map yet, because the targetData is not typed? - */ + targetData.range = range; - range.axisFill.fill = colourRangeFill; + range.axisFill.fill = colour; range.axisFill.fillOpacity = 0.3; if (targetData.interim) { range.grid.strokeDasharray = '3,3'; } + range.grid.above = true; - // the grid line should be at the top, not the middle range.grid.location = 0; - range.grid.stroke = colourRangeLine; + range.grid.stroke = colour; range.grid.strokeWidth = 2; range.grid.strokeOpacity = 1; + range.label.align = 'right'; + range.label.fill = range.grid.stroke; + range.label.fontSize = 14; range.label.inside = true; range.label.location = 0; - // range.label.zIndex = 3; - - range.label.align = 'right'; range.label.text = targetData.label; - range.label.fill = range.grid.stroke; range.label.verticalCenter = 'bottom'; - range.label.fontSize = 14; + + range.value = targetData.value; const pin = range.label.createChild(am4plugins_bullets.PinBullet); - //pin.background.radius = 20; pin.background.radius = 10; pin.background.fill = colourPin; pin.cursorOverStyle = am4core.MouseCursorStyle.pointer; - pin.dy = 29; + pin.dy = 26; + pin.image = new am4core.Image(); + pin.image.href = + 'https://upload.wikimedia.org/wikipedia/en/2/27/EU_flag_square.PNG'; + pin.verticalCenter = 'top'; const setRangeAndPinDefaults = (): void => { - const defaultDx = 24; + const defaultDx = 34; range.endValue = range.value; pin.background.pointerAngle = 180; pin.dx = defaultDx; @@ -230,100 +144,30 @@ this.targetData[country][type] setRangeAndPinDefaults(); - pin.verticalCenter = 'top'; - pin.image = new am4core.Image(); - pin.image.href = - 'https://upload.wikimedia.org/wikipedia/en/2/27/EU_flag_square.PNG'; - // toggle display of the limit floor pin.events.on('hit', function () { if (range.endValue === range.value) { // the minimum allowed is 40% of the target range.endValue = range.value * 0.4; pin.background.pointerAngle = 90; - pin.dx = 42; + pin.dx = 41; } else { setRangeAndPinDefaults(); } }); - // targetData.pin = pin; - targetData.range = range; - } - - getMaxValue(data: Array, country: string): number { - return data.reduce((highest: number, item: TemporalDataItem) => { - const val = item[country] as number; - if (val && val > highest) { - highest = val; - } - return highest; - }, 0); - } - - addChartTargetIndicator( - country: string, - type: string, - index: number, - colour: am4core.Color - ): void { - const targetData = this.targetData[country][type][index]; - const maxValue = this.getMaxValue(this.chart.data, country); - - console.log('maxValue for ' + country + ' = ' + maxValue); - - this.createRange(this.valueAxis, targetData, colour); - - if (targetData.value > maxValue) { - console.log( - 'LIMIT (' + targetData.value + ') exceeds highest value ' + maxValue - ); - if (targetData.value > this.valueAxis.max) { - console.log( - 'limits (' + - targetData.value + - ') exceeds AXIS value ' + - this.valueAxis.max - ); - } else { - console.log( - 'limits (' + - targetData.value + - ') NOT exceeds AXIS value ' + - this.valueAxis.max - ); - } - this.valueAxis.max = targetData.value; - } - } - - removeSeries(id: string): void { - const series = this.allSeriesData[id]; - - if (series) { - const seriesIndex = this.chart.series.indexOf(series.series); - if (seriesIndex > -1) { - this.chart.series.removeIndex(seriesIndex).dispose(); - - console.log('remove series id ' + id); - } - delete this.allSeriesData[id]; - } else { - console.log(`Line: can't find series to remove (${id})`); - } - this.chart.invalidateData(); } hideSeries(id: string): void { const series = this.allSeriesData[id]; if (series) { - series.series.hide(); + series.hide(); } } showSeries(id: string): void { const series = this.allSeriesData[id]; if (series) { - series.series.show(); + series.show(); } } @@ -331,46 +175,26 @@ this.targetData[country][type] seriesDisplayName: string, seriesValueY: string, valueY: string, - // isHidden: boolean, - seriesData?: Array + seriesData: Array ): void { const series = this.chart.series.push(new am4charts.LineSeries()); series.dataFields.valueY = seriesValueY; series.dataFields.dateX = 'date'; - series.strokeWidth = 2; series.tooltipText = `${seriesDisplayName} {${seriesValueY}}`; series.tooltip.pointerOrientation = 'vertical'; series.tooltip.getFillFromObject = true; - // series.hidden = isHidden; - - let seriesMax = 0; - if (seriesData) { - const chartData = this.chart.data; + const chartData = this.chart.data; - seriesData.forEach((sd: TemporalDataItem, rowIndex: number) => { - const val = sd[valueY] as number; - if (rowIndex >= chartData.length) { - chartData.push(sd); - } - chartData[rowIndex][seriesValueY] = val; - if (val > seriesMax) { - seriesMax = val; - } - }); - - //this.chart.invalidateData(); - } - - // console.log('add series name ' + seriesValueY + ', max is ' + seriesMax); - - // const maxValue = this.getMaxValue(this.chartData, country); - - this.allSeriesData[seriesValueY] = { - series: series, - dataMax: seriesMax - }; + seriesData.forEach((sd: TemporalDataItem, rowIndex: number) => { + const val = sd[valueY] as number; + if (rowIndex >= chartData.length) { + chartData.push(sd); + } + chartData[rowIndex][seriesValueY] = val; + }); + this.allSeriesData[seriesValueY] = series; } /** drawChart @@ -383,26 +207,68 @@ this.targetData[country][type] const chart = am4core.create('lineChart', am4charts.XYChart); this.chart = chart; + chart.paddingTop = this.padding.top; + chart.paddingBottom = this.padding.bottom; + chart.paddingLeft = this.padding.left; + chart.paddingRight = this.padding.rightDefault; + chart.colors.list = colours.map((colour: string) => { return am4core.color(colour); }); - //chart.maskBullets = false; - // chart.data = this.chartData; - chart.data = [{}]; //this.chartData; + chart.data = [{}]; + + const colourAxis = am4core.color('#4d4d4d'); // Create date axis const dateAxis = chart.xAxes.push(new am4charts.DateAxis()); - dateAxis.renderer.minGridDistance = 60; + this.dateAxis = dateAxis; + dateAxis.renderer.minGridDistance = 78; + dateAxis.renderer.labels.template.fill = colourAxis; + dateAxis.renderer.labels.template.dy = 16; + dateAxis.renderer.labels.template.fontSize = 14; // Create value axis - this.valueAxis = chart.yAxes.push(new am4charts.ValueAxis()); - this.valueAxis.extraMax = 0.1; - //this.valueAxis.extraMin = 0.2; - - chart.cursor = new am4charts.XYCursor(); - chart.cursor.xAxis = dateAxis; - //chart.scrollbarX = new am4core.Scrollbar(); - //chart.legend = new am4charts.Legend(); + const valueAxis = chart.yAxes.push(new am4charts.ValueAxis()); + this.valueAxis = valueAxis; + valueAxis.extraMax = 0.1; + valueAxis.includeRangesInMinMax = true; + valueAxis.renderer.labels.template.fill = colourAxis; + valueAxis.renderer.labels.template.fontSize = 14; + + this.toggleGridlines(); + } + + toggleGridlines(): void { + // disable grid lines + if (this.dateAxis.renderer.grid.template.disabled) { + this.dateAxis.renderer.grid.template.disabled = false; + this.valueAxis.renderer.grid.template.disabled = false; + } else { + this.dateAxis.renderer.grid.template.disabled = true; + this.valueAxis.renderer.grid.template.disabled = true; + } + } + + toggleCursor(): void { + if (this.chart.cursor) { + this.chart.cursor.dispose(); + this.chart.cursor = undefined; + } else { + const cursor = new am4charts.XYCursor(); + this.chart.cursor = cursor; + cursor.xAxis = this.dateAxis; + } + } + + toggleScrollbar(): void { + if (this.chart.scrollbarX) { + this.chart.scrollbarX.dispose(); + this.chart.scrollbarX = undefined; + } else { + const scrollbar = new am4core.Scrollbar(); + this.chart.scrollbarX = scrollbar; + scrollbar.dy = -25; + } } } diff --git a/src/app/targets/targets.component.html b/src/app/targets/targets.component.html index 0492b212..c7ea01be 100644 --- a/src/app/targets/targets.component.html +++ b/src/app/targets/targets.component.html @@ -1,10 +1,11 @@ -
-
-

- +
+

Europeana member state targets

+ + + {{ c.key | renameCountry }} -

+
diff --git a/src/app/targets/targets.component.scss b/src/app/targets/targets.component.scss index 25c9e8f0..059e7b37 100644 --- a/src/app/targets/targets.component.scss +++ b/src/app/targets/targets.component.scss @@ -1,93 +1,261 @@ @import "../../scss/_variables"; -@import "../../scss/functions/_icon_urls"; -@import "../../scss/functions/_replace"; $disabled-colour: $gray-5; +h2 { + padding: 16px; +} + li { list-style: none; } +.appendice-grid { + grid-template-columns: repeat(11, auto); + .header { + font-weight: 600; + } +} + +.appendice-grid-wrapper { + margin: 32px 16px; +} + +.appendice-grid, +.chart-wrapper, +.legend-wrapper, +.line-chart-wrapper, +.legend-grid, +.legend-grid-inner { + font-size: 14px; + background: #fff; +} + +.appendice-grid, +.legend-wrapper, +.line-chart-wrapper { + border-radius: 8px; +} + +.appendice-grid, +.legend-grid, +.legend-grid-inner { + display: grid; + + & > :not(.legend-grid-inner) { + padding: 0.6em; + } +} + .bulleted { list-style: disc; } +.hover-show { + position: relative; + &:hover { + .hover-show-content { + display: block; + } + } +} + +.hover-show-content { + display: none; + font-size: 14px; + padding: 2em 1em 3em 5em; + position: absolute; + right: -170px; + top: -82px; + z-index: 1; + text-transform: none; + + .hover-show-content-inner { + background: #fff; + border: 1px solid #777; + border-radius: 8px; + display: inline-block; + padding: 0.5em; + } +} + .indented { - transform: translateX(16px); + margin-left: 1rem; &::marker { color: inherit; } } +.chart-and-legend-wrapper { + display: flex; + margin-top: 4em; + > :first-child { + flex-grow: 1; + } +} + +.chart-and-legend-wrapper, +.page-subtitle, +.page-title { + padding: 0 16px; +} + +.item-header { + position: relative; +} + +.headed-item { + display: flex; + flex-direction: column; + + .item-header { + text-transform: uppercase; + font-weight: 600; + line-height: 24px; + font-size: 12px; + margin-bottom: 12px; + } +} + .legend-grid { - grid-template-columns: auto auto auto auto; - margin: 2em 0; - max-height: 20em; - overflow: auto; + grid-template-columns: repeat(4, auto); + max-height: 338px; + overflow-y: auto; + overflow-x: hidden; position: relative; + + &:hover { + &.scrollable-downwards .scrollable-indicator { + opacity: 0; + } + } + + &.scrollable-downwards .scrollable-indicator { + opacity: 1; + } + + // push scrollbar away from data + .pad-right { + padding-right: 0.6em; + } + + .scrollable-indicator { + opacity: 0; + } + + .scrollable-indicator { + bottom: 0; + grid-column: 1/5; + height: 0px; + padding: 0 !important; + pointer-events: none; + position: sticky; + transition: opacity 0.2s linear; + + &::before { + background: linear-gradient(0deg, #fff 0%, transparent 100%); + content: ""; + display: block; + height: 4.5em; + position: absolute; + top: -4.5em; + width: 100%; + } + } } .legend-grid-inner { - grid-template-columns: auto auto auto; + grid-column: 2/5; + grid-template-columns: subgrid; margin: 0; column-gap: 4px; - & > * { border-left: 1px solid $disabled-colour; } } -.legend-grid-wide-cell { - grid-column: 2/5; - margin: 0; +.line-chart-wrapper, +.legend-wrapper { + padding-top: 22px; + display: flex; + flex-direction: column; } -.legend-grid, -.legend-grid-inner { - background: #fff; - display: grid; - font-size: 14px; - & > :not(.legend-grid-wide-cell) { - padding: 0.6em; - } - & > * { - white-space: nowrap; - } +.legend-grid-wrapper { + margin: 0 0 0 32px; } -.legend-item-country-opener { - color: #000; - font-weight: bold; +.legend-wrapper { + padding-right: 25px; +} + +.line-chart-wrapper { + padding-right: 25px; +} + +.legend-item-country-toggle { position: relative; + + &.stick-left { + font-weight: bold; + } + a { + color: $gray-med; + position: relative; + margin: 0 0.4em 0 2em; + white-space: nowrap; + } } -.legend-item-series-opener, -.legend-item-target-opener { +.legend-item-series-toggle, +.legend-item-target-toggle { color: $gray-med; + white-space: nowrap; &.hidden { color: $disabled-colour; } } -.legend-item-target-opener { - font-size: 12px; +.legend-item-sizer { + display: block; font-weight: bold; + height: 0; + margin-left: 1em; + visibility: hidden; +} + +.legend-item-target-toggle { + font-size: 12px; + max-width: 8em; + min-width: 8em; + display: flex; + justify-content: space-between; } .target-total { - font-weight: normal; - margin-left: 0.4em; - margin-left: 4.4em; + flex-grow: 1; color: #777; + font-weight: normal; + margin-left: 1.4em; + text-align: right; + + &.italic { + font-style: italic; + margin-right: 0.4em; + } } -.legend-item-series-opener { - font-weight: bold; +.legend-item-series-toggle, +.legend-item-country-toggle, +.legend-item-target-toggle { + font-weight: 600; +} +.legend-item-series-toggle { + align-items: center; display: flex; flex-direction: row; - align-items: center; * { line-height: 1.6em; @@ -120,14 +288,21 @@ li { .stick-right { right: 0; + .legend-item-series-toggle { + font-weight: bold; + } } -.title { - &:not(:last-child)::after { +.page-subtitle { + :not(:last-child)::after { content: ","; } } +.page-title { + padding-top: 16px; +} + $timeTransition: 0.25s; .roll-down-wrapper { @@ -145,11 +320,12 @@ $timeTransition: 0.25s; } .roll-down { - overflow: hidden; + overflow-y: hidden; + margin: 0.2em 0 0.3em 0; } } -///////////// COPIED +// COPIED: TODO: refactor .save, .saved { @@ -159,20 +335,26 @@ $timeTransition: 0.25s; background-repeat: no-repeat; content: ""; height: 12px; - left: -21px; + left: -24px; padding: 12px; + position: absolute; - top: -2px; + top: 0px; transform: rotate(270deg); transition: transform 20ms; width: 12px; &:hover { - top: -1px; + //top: -1px; transform: rotate(360deg); } } +:hover + .save { + //top: -1px; + transform: rotate(360deg); +} + .save { // change opacity: 0; @@ -180,11 +362,23 @@ $timeTransition: 0.25s; .saved { // change - // opacity: 1; + opacity: 1; } // change .saved { - top: -1px; - transform: rotate(360deg); + // top: 1px; + //transform: rotate(360deg); +} + +// COPIED + +.data-link { + color: $stats-blue; + display: inline-block; + font-weight: 600; + margin: 14px 0 25px auto; + &::after { + content: " >"; + } } diff --git a/src/app/targets/targets.component.ts b/src/app/targets/targets.component.ts index 2c9335c4..845e4198 100644 --- a/src/app/targets/targets.component.ts +++ b/src/app/targets/targets.component.ts @@ -1,4 +1,5 @@ import { + DatePipe, DecimalPipe, JsonPipe, KeyValuePipe, @@ -8,10 +9,21 @@ import { NgStyle, NgTemplateOutlet } from '@angular/common'; -import { Component, CUSTOM_ELEMENTS_SCHEMA, ViewChild } from '@angular/core'; +import { + Component, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, + ViewChild +} from '@angular/core'; import * as am4charts from '@amcharts/amcharts4/charts'; -import { CountryTargetData, IHash, IHashArray, TargetData } from '../_models'; +import { + IHash, + IHashArray, + TargetData, + TemporalDataItem, + TemporalLocalisedDataItem +} from '../_models'; import { APIService } from '../_services'; import { RenameCountryPipe, RenameTargetTypePipe } from '../_translate'; import { LineComponent } from '../chart'; @@ -24,6 +36,7 @@ import { SubscriptionManager } from '../subscription-manager'; styleUrls: ['./targets.component.scss'], standalone: true, imports: [ + DatePipe, DecimalPipe, JsonPipe, NgClass, @@ -41,7 +54,7 @@ export class TargetsComponent extends SubscriptionManager { targetCountries: Array; targetCountriesOO: Array; targetData: IHash>; - loadedCountryTargetData: IHash = {}; + countryData: IHash> = {}; pinnedCountries: IHash = {}; public seriesSuffixes = ['total', '3D', 'META_A']; @@ -49,9 +62,32 @@ export class TargetsComponent extends SubscriptionManager { public seriesValueNames = ['total', 'three_d', 'meta_tier_a']; @ViewChild('lineChart') lineChart: LineComponent; + @ViewChild('legendGrid') legendGrid: ElementRef; constructor(private readonly api: APIService) { super(); + + this.subs.push( + this.api + .loadCountryData() + .subscribe((countryData: Array) => { + this.countryData = countryData.reduce( + ( + res: IHash>, + item: TemporalLocalisedDataItem + ) => { + if (!res[item.country]) { + res[item.country] = []; + } + const { country, ...itemNoCountry } = item; + res[country].push(itemNoCountry); + return res; + }, + {} + ); + }) + ); + this.subs.push( this.api.getTargetData().subscribe((targetData) => { this.targetData = targetData; @@ -63,17 +99,14 @@ export class TargetsComponent extends SubscriptionManager { ngAfterContentInit(): void { setTimeout(() => { - this.toggleCountryTargetData('DE'); - this.pinnedCountries['DE'] = 0; + this.toggleCountry('DE'); }, 0); } - getCountrySeries(country: string): Array<{ series: am4charts.LineSeries }> { + getCountrySeries(country: string): Array { const res = this.seriesSuffixes .map((seriesSuffix: string) => { - const seriesName = `${country}${seriesSuffix}`; - - return this.lineChart.allSeriesData[seriesName]; + return this.lineChart.allSeriesData[`${country}${seriesSuffix}`]; }) .filter((x) => { return x; @@ -81,6 +114,18 @@ export class TargetsComponent extends SubscriptionManager { return res; } + toggleCursor(): void { + this.lineChart.toggleCursor(); + } + + toggleGridlines(): void { + this.lineChart.toggleGridlines(); + } + + toggleScrollbar(): void { + this.lineChart.toggleScrollbar(); + } + /** resetChartColors * aligns the chart's internal color index with the visible series count **/ @@ -89,7 +134,7 @@ export class TargetsComponent extends SubscriptionManager { let openCount = 0; Object.keys(data).forEach((key: string) => { - const isHidden = this.lineChart.allSeriesData[key].series.isHidden; + const isHidden = this.lineChart.allSeriesData[key].isHidden; if (!isHidden) { openCount += 1; } @@ -105,64 +150,58 @@ export class TargetsComponent extends SubscriptionManager { } } - /** toggleCountryTargetData + /** toggleCountry * shows existing (hidden) data or loads and creates series * @param { string } country - the target series * @return boolean **/ - toggleCountryTargetData(country: string): void { - if (this.loadedCountryTargetData[country]) { - const countrySeries = this.getCountrySeries(country); + toggleCountry(country: string): void { + const countrySeries = this.getCountrySeries(country); + if (countrySeries.length === 0) { + this.addSeriesSetAndPin(country, this.countryData[country]); + } else { const hasVisible = - countrySeries.filter((series: { series: am4charts.LineSeries }) => { - return !series.series.isHidden; + countrySeries.filter((series: am4charts.LineSeries) => { + return !series.isHidden; }).length > 0; if (hasVisible) { - countrySeries.forEach((series: { series: am4charts.LineSeries }) => { - series.series.hide(); + countrySeries.forEach((series: am4charts.LineSeries) => { + series.hide(); }); + // remove associated ranges + this.lineChart.removeRange(country); this.togglePin(country); } else { - countrySeries.forEach((series: { series: am4charts.LineSeries }) => { - series.series.show(); + // should create missinf... + + countrySeries.forEach((series: am4charts.LineSeries) => { + series.show(); }); this.togglePin(country); } - } else { - this.loadCountryTargetData(country); } } - loadCountryTargetData( + addSeriesSetAndPin( country: string, - specificSeriesTypeIndex?: number + data: Array + // specificSeriesTypeIndex?: number ): void { this.resetChartColors(); - this.subs.push( - this.api - .loadCountryTargetData(country) - .subscribe((countryTargetData: CountryTargetData) => { - this.loadedCountryTargetData[country] = countryTargetData; - // add pin and series - this.togglePin(country); + // add pin and series + this.togglePin(country); - // loop the types - [...Array(3).keys()].forEach((i: number) => { - const skipSeriesCreation = - !isNaN(specificSeriesTypeIndex) && specificSeriesTypeIndex !== i; - if (!skipSeriesCreation) { - this.lineChart.addSeries( - country + this.seriesSuffixesFmt[i], - country + this.seriesSuffixes[i], - this.seriesValueNames[i], - countryTargetData.dataRows - ); - } - }); - }) - ); + // loop the types + [...Array(3).keys()].forEach((i: number) => { + this.lineChart.addSeries( + country + this.seriesSuffixesFmt[i], + country + this.seriesSuffixes[i], + this.seriesValueNames[i], + data + ); + }); } removeRange(country: string, type: string, index: number): void { @@ -175,7 +214,7 @@ export class TargetsComponent extends SubscriptionManager { * @param { string } country - the country to (un)pin **/ togglePin(country: string): void { - const itemHeight = 84; + const itemHeight = 84.5; if (country in this.pinnedCountries) { // delete and re-assign existing pin values @@ -197,6 +236,15 @@ export class TargetsComponent extends SubscriptionManager { ); } + gridScroll(): void { + const el = this.legendGrid.nativeElement; + const sh = el.scrollHeight; + const h = el.getBoundingClientRect().height; + const st = el.scrollTop; + const canScrollDown = sh > st + h + 1; + el.classList.toggle('scrollable-downwards', canScrollDown); + } + /** toggleSeries * loads a series if it hasn't been loaded and toggles its display * @param { string } @@ -211,33 +259,26 @@ export class TargetsComponent extends SubscriptionManager { const typeIndex = this.seriesValueNames.indexOf(type); if (!series) { - if (this.loadedCountryTargetData[country]) { - // create from existing data - this.resetChartColors(); - const visibleSiblings = this.getCountrySeries(country).filter( - (series: { series: am4charts.LineSeries }) => { - return !series.series.isHidden; - } - ); + // create from existing data + this.resetChartColors(); + const visibleSiblings = this.getCountrySeries(country).filter( + (series: am4charts.LineSeries) => { + return !series.isHidden; + } + ); - this.lineChart.addSeries( - country + this.seriesSuffixesFmt[typeIndex], - country + this.seriesSuffixes[typeIndex], - this.seriesValueNames[typeIndex], - this.loadedCountryTargetData[country].dataRows - ); + this.lineChart.addSeries( + country + this.seriesSuffixesFmt[typeIndex], + country + this.seriesSuffixes[typeIndex], + this.seriesValueNames[typeIndex], + this.countryData[country] + ); - if (visibleSiblings.length === 0) { - this.togglePin(country); - } - } else { - this.loadCountryTargetData(country, typeIndex); + if (visibleSiblings.length === 0) { + this.togglePin(country); } - return; - } - if (series.isHidden) { + } else if (series.isHidden) { series.show(); - if (!(country in this.pinnedCountries)) { this.togglePin(country); } @@ -249,11 +290,11 @@ export class TargetsComponent extends SubscriptionManager { let visCount = 0; this.seriesSuffixes.forEach((suffix: string) => { const sd = this.lineChart.allSeriesData[country + suffix]; - if (sd && !sd.series.isHidden) { + if (sd && !sd.isHidden) { visCount += 1; } }); - // yes we can unpin - will be 0 on animation completion + // we can unpin (it will be 0 on animation completion) if (visCount === 1) { this.togglePin(country); } From 124c1de4cefc19e30cbd9029f2264753e4b43e82 Mon Sep 17 00:00:00 2001 From: andyjmaclean Date: Mon, 11 Mar 2024 18:36:19 +0100 Subject: [PATCH 03/90] MET-5835-Member-State-Target-Page: Lint --- src/app/_services/api.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/_services/api.service.ts b/src/app/_services/api.service.ts index 8b1eac15..5d9cda5b 100644 --- a/src/app/_services/api.service.ts +++ b/src/app/_services/api.service.ts @@ -176,7 +176,7 @@ export class APIService { const targetDataRef = this.reduceTargetData(this.targetData); - this.targetCountries.forEach((country: string, countryIndex: number) => { + this.targetCountries.forEach((country: string) => { const baseValueTotal = targetDataRef[country]['total'][1].value; const baseValue3D = targetDataRef[country]['three_d'][1].value; const baseValueTierA = targetDataRef[country]['meta_tier_a'][1].value; From 0d71f4ac8c32d4e3318b35aaee96ed01e5715c16 Mon Sep 17 00:00:00 2001 From: andyjmaclean Date: Wed, 13 Mar 2024 01:32:44 +0100 Subject: [PATCH 04/90] Tweaks --- src/app/chart/line/line.component.ts | 20 ++++++++++++++--- src/app/targets/targets.component.html | 23 ++++++++++---------- src/app/targets/targets.component.scss | 8 ++++--- src/app/targets/targets.component.ts | 30 +++++++++++++++++--------- 4 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/app/chart/line/line.component.ts b/src/app/chart/line/line.component.ts index 4d86b588..02a7b072 100644 --- a/src/app/chart/line/line.component.ts +++ b/src/app/chart/line/line.component.ts @@ -113,7 +113,6 @@ export class LineComponent implements AfterViewInit { range.grid.stroke = colour; range.grid.strokeWidth = 2; range.grid.strokeOpacity = 1; - range.label.align = 'right'; range.label.fill = range.grid.stroke; range.label.fontSize = 14; @@ -121,7 +120,6 @@ export class LineComponent implements AfterViewInit { range.label.location = 0; range.label.text = targetData.label; range.label.verticalCenter = 'bottom'; - range.value = targetData.value; const pin = range.label.createChild(am4plugins_bullets.PinBullet); @@ -129,7 +127,7 @@ export class LineComponent implements AfterViewInit { pin.background.radius = 10; pin.background.fill = colourPin; pin.cursorOverStyle = am4core.MouseCursorStyle.pointer; - pin.dy = 26; + pin.dy = 28; pin.image = new am4core.Image(); pin.image.href = 'https://upload.wikimedia.org/wikipedia/en/2/27/EU_flag_square.PNG'; @@ -267,8 +265,24 @@ export class LineComponent implements AfterViewInit { this.chart.scrollbarX = undefined; } else { const scrollbar = new am4core.Scrollbar(); + const scrollbarColour = am4core.color('#0a72cc'); + this.chart.scrollbarX = scrollbar; scrollbar.dy = -25; + + const customiseGrip = (grip): void => { + grip.icon.disabled = true; + grip.background.fill = scrollbarColour; + grip.background.fillOpacity = 0.8; + }; + customiseGrip(scrollbar.startGrip); + customiseGrip(scrollbar.endGrip); + + scrollbar.background.fill = scrollbarColour; + scrollbar.background.fillOpacity = 0.1; + + scrollbar.thumb.background.fill = scrollbarColour; + scrollbar.thumb.background.fillOpacity = 0.2; } } } diff --git a/src/app/targets/targets.component.html b/src/app/targets/targets.component.html index c7ea01be..885815fc 100644 --- a/src/app/targets/targets.component.html +++ b/src/app/targets/targets.component.html @@ -17,7 +17,7 @@

Europeana member state targets

  • Europeana member state targets > - {{ data.label }} - {{ data.value | number }} - - {{ data.label }} + {{ + data.value | number + }}
  • diff --git a/src/app/targets/targets.component.scss b/src/app/targets/targets.component.scss index 059e7b37..3411e59e 100644 --- a/src/app/targets/targets.component.scss +++ b/src/app/targets/targets.component.scss @@ -74,8 +74,10 @@ li { background: #fff; border: 1px solid #777; border-radius: 8px; + color: $gray-med; + cursor: default; display: inline-block; - padding: 0.5em; + padding: 0.6em; } } @@ -212,7 +214,7 @@ li { color: $gray-med; white-space: nowrap; - &.hidden { + &.rolled-up { color: $disabled-colour; } } @@ -240,7 +242,7 @@ li { margin-left: 1.4em; text-align: right; - &.italic { + &.range-value { font-style: italic; margin-right: 0.4em; } diff --git a/src/app/targets/targets.component.ts b/src/app/targets/targets.component.ts index 845e4198..20420bd3 100644 --- a/src/app/targets/targets.component.ts +++ b/src/app/targets/targets.component.ts @@ -16,6 +16,7 @@ import { ViewChild } from '@angular/core'; import * as am4charts from '@amcharts/amcharts4/charts'; +import * as am4core from '@amcharts/amcharts4/core'; import { IHash, @@ -99,7 +100,13 @@ export class TargetsComponent extends SubscriptionManager { ngAfterContentInit(): void { setTimeout(() => { - this.toggleCountry('DE'); + this.toggleCountry('FR'); + this.toggleRange( + 'FR', + 'total', + 0, + this.lineChart.chart.colors.getIndex(0) + ); }, 0); } @@ -173,8 +180,6 @@ export class TargetsComponent extends SubscriptionManager { this.lineChart.removeRange(country); this.togglePin(country); } else { - // should create missinf... - countrySeries.forEach((series: am4charts.LineSeries) => { series.show(); }); @@ -183,11 +188,7 @@ export class TargetsComponent extends SubscriptionManager { } } - addSeriesSetAndPin( - country: string, - data: Array - // specificSeriesTypeIndex?: number - ): void { + addSeriesSetAndPin(country: string, data: Array): void { this.resetChartColors(); // add pin and series @@ -204,8 +205,17 @@ export class TargetsComponent extends SubscriptionManager { }); } - removeRange(country: string, type: string, index: number): void { - this.lineChart.removeRange(country, type, index); + toggleRange( + country: string, + type: string, + index: number, + colour?: am4core.Color + ): void { + if (colour) { + this.lineChart.showRange(country, type, index, colour); + } else { + this.lineChart.removeRange(country, type, index); + } } /** togglePin From eb8fe99f3c8650ae7bead53aab4d4f5c26722506 Mon Sep 17 00:00:00 2001 From: andyjmaclean Date: Wed, 13 Mar 2024 10:14:55 +0100 Subject: [PATCH 05/90] Fix delay on toggle --- src/app/chart/line/line.component.ts | 7 ------- src/app/targets/targets.component.html | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app/chart/line/line.component.ts b/src/app/chart/line/line.component.ts index 02a7b072..07c8b23d 100644 --- a/src/app/chart/line/line.component.ts +++ b/src/app/chart/line/line.component.ts @@ -162,13 +162,6 @@ export class LineComponent implements AfterViewInit { } } - showSeries(id: string): void { - const series = this.allSeriesData[id]; - if (series) { - series.show(); - } - } - addSeries( seriesDisplayName: string, seriesValueY: string, diff --git a/src/app/targets/targets.component.html b/src/app/targets/targets.component.html index 885815fc..0e1bbabf 100644 --- a/src/app/targets/targets.component.html +++ b/src/app/targets/targets.component.html @@ -17,15 +17,23 @@

    Europeana member state targets

  • @@ -46,7 +54,7 @@

    Europeana member state targets

  • diff --git a/src/app/targets/targets.component.scss b/src/app/targets/targets.component.scss index 059e7b37..3411e59e 100644 --- a/src/app/targets/targets.component.scss +++ b/src/app/targets/targets.component.scss @@ -74,8 +74,10 @@ li { background: #fff; border: 1px solid #777; border-radius: 8px; + color: $gray-med; + cursor: default; display: inline-block; - padding: 0.5em; + padding: 0.6em; } } @@ -212,7 +214,7 @@ li { color: $gray-med; white-space: nowrap; - &.hidden { + &.rolled-up { color: $disabled-colour; } } @@ -240,7 +242,7 @@ li { margin-left: 1.4em; text-align: right; - &.italic { + &.range-value { font-style: italic; margin-right: 0.4em; } diff --git a/src/app/targets/targets.component.ts b/src/app/targets/targets.component.ts index 845e4198..20420bd3 100644 --- a/src/app/targets/targets.component.ts +++ b/src/app/targets/targets.component.ts @@ -16,6 +16,7 @@ import { ViewChild } from '@angular/core'; import * as am4charts from '@amcharts/amcharts4/charts'; +import * as am4core from '@amcharts/amcharts4/core'; import { IHash, @@ -99,7 +100,13 @@ export class TargetsComponent extends SubscriptionManager { ngAfterContentInit(): void { setTimeout(() => { - this.toggleCountry('DE'); + this.toggleCountry('FR'); + this.toggleRange( + 'FR', + 'total', + 0, + this.lineChart.chart.colors.getIndex(0) + ); }, 0); } @@ -173,8 +180,6 @@ export class TargetsComponent extends SubscriptionManager { this.lineChart.removeRange(country); this.togglePin(country); } else { - // should create missinf... - countrySeries.forEach((series: am4charts.LineSeries) => { series.show(); }); @@ -183,11 +188,7 @@ export class TargetsComponent extends SubscriptionManager { } } - addSeriesSetAndPin( - country: string, - data: Array - // specificSeriesTypeIndex?: number - ): void { + addSeriesSetAndPin(country: string, data: Array): void { this.resetChartColors(); // add pin and series @@ -204,8 +205,17 @@ export class TargetsComponent extends SubscriptionManager { }); } - removeRange(country: string, type: string, index: number): void { - this.lineChart.removeRange(country, type, index); + toggleRange( + country: string, + type: string, + index: number, + colour?: am4core.Color + ): void { + if (colour) { + this.lineChart.showRange(country, type, index, colour); + } else { + this.lineChart.removeRange(country, type, index); + } } /** togglePin From 5d7503578b64e57aef12bf4025d696c9d606f4b9 Mon Sep 17 00:00:00 2001 From: andyjmaclean Date: Wed, 13 Mar 2024 10:14:55 +0100 Subject: [PATCH 16/90] Fix delay on toggle --- src/app/chart/line/line.component.ts | 7 ------- src/app/targets/targets.component.html | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/app/chart/line/line.component.ts b/src/app/chart/line/line.component.ts index 02a7b072..07c8b23d 100644 --- a/src/app/chart/line/line.component.ts +++ b/src/app/chart/line/line.component.ts @@ -162,13 +162,6 @@ export class LineComponent implements AfterViewInit { } } - showSeries(id: string): void { - const series = this.allSeriesData[id]; - if (series) { - series.show(); - } - } - addSeries( seriesDisplayName: string, seriesValueY: string, diff --git a/src/app/targets/targets.component.html b/src/app/targets/targets.component.html index 885815fc..0e1bbabf 100644 --- a/src/app/targets/targets.component.html +++ b/src/app/targets/targets.component.html @@ -17,15 +17,23 @@

    Europeana member state targets

  • @@ -46,7 +54,7 @@

    Europeana member state targets