diff --git a/cypress/e2e/country.cy.ts b/cypress/e2e/country.cy.ts new file mode 100644 index 00000000..204c4976 --- /dev/null +++ b/cypress/e2e/country.cy.ts @@ -0,0 +1,139 @@ +context('Statistics Dashboard', () => { + const force = { force: true }; + + describe('Country Target Page', () => { + const selPowerBar = '.powerbar .powerbar-charge'; + const selPowerBar3D = '.entry-card:first-child .powerbar-charge'; + const selPowerBarHQ = '.entry-card:last-child .powerbar-charge'; + + beforeEach(() => { + cy.visit('/country/Austria'); + }); + + it('should toggle the appendices', () => { + const selAppendiceToggle = '.appendice-toggle'; + const selAppendiceTable = '.appendice-grid'; + + cy.get(selAppendiceTable).filter(':visible').should('have.length', 0); + cy.get(selAppendiceToggle).click(force); + cy.get(selAppendiceTable).filter(':visible').should('have.length', 1); + cy.get(selAppendiceToggle).click(force); + cy.get(selAppendiceTable).filter(':visible').should('have.length', 0); + }); + + it('should toggle the cards and columns', () => { + const selColClose = '.column-close'; + const selColRestore = '.column-restore'; + const selAppendiceToggle = '.appendice-toggle'; + const selAppendiceTable = '.appendice-grid'; + + cy.get(selAppendiceTable).should('not.have.class', 'single'); + cy.get(selAppendiceTable).should('not.have.class', 'double'); + cy.get(selPowerBar).should('have.length', 6); + cy.get(selColClose).should('have.length', 3); + cy.get(selColRestore).should('not.exist', 2); + + // close the first column + cy.get(selColClose).eq(0).click(force); + cy.get(selPowerBar).should('have.length', 4); + cy.get(selColRestore).filter(':visible').should('have.length', 2); + cy.get(selAppendiceTable).should('not.have.class', 'single'); + cy.get(selAppendiceTable).should('have.class', 'double'); + + // close the 2nd column + cy.get(selColClose).eq(0).click(force); + cy.get(selPowerBar).should('have.length', 2); + cy.get(selColRestore).filter(':visible').should('have.length', 2); + cy.get(selAppendiceTable).should('have.class', 'single'); + cy.get(selAppendiceTable).should('not.have.class', 'double'); + + // restore + cy.get(selColRestore).eq(0).click(force); + cy.get(selPowerBar).should('have.length', 4); + cy.get(selAppendiceTable).should('not.have.class', 'single'); + cy.get(selAppendiceTable).should('have.class', 'double'); + + cy.get(selColRestore).eq(0).click(force); + cy.get(selPowerBar).should('have.length', 6); + cy.get(selAppendiceTable).should('not.have.class', 'single'); + cy.get(selAppendiceTable).should('not.have.class', 'double'); + }); + + it('should show the power bars', () => { + cy.get(selPowerBar).should('have.length', 6); + cy.get(selPowerBarHQ).should('have.length', 2); + cy.get(selPowerBar3D).should('have.length', 2); + }); + + it('should show the data entry point links', () => { + const selLinkData3D = '[data-e2e=link-entry-3d]'; + const selLinkDataHQ = '[data-e2e=link-entry-hq]'; + const selLinkDataType = '[data-e2e=link-entry-type]'; + const selLinkDataRights = '[data-e2e=link-entry-rights]'; + const selLinkDataDataProvider = '[data-e2e=link-entry-provider]'; + const selLinkDataProvider = '[data-e2e=link-entry-data-provider]'; + + cy.get(selLinkData3D).should('have.length', 1); + cy.get(selLinkDataHQ).should('have.length', 1); + cy.get(selLinkDataType).should('have.length', 1); + cy.get(selLinkDataRights).should('have.length', 1); + cy.get(selLinkDataDataProvider).should('have.length', 1); + cy.get(selLinkDataProvider).should('have.length', 1); + }); + }); + + describe('Country Target Menu', () => { + beforeEach(() => { + cy.visit('/'); + cy.wait(3000); + }); + + const selLinkHomeHeader = '[data-e2e=link-home-header]'; + const selTarget = '.header .target'; + const selTargetMenu = '.header .target-select'; + const selTargetLink = '.header .target-select a span'; + const selTargetLinkActive = '.header .target-select a[disabled]'; + + it('should open the target menu', () => { + cy.get(selTarget).should('have.length', 1); + cy.get(selTargetMenu).should('have.length', 1); + cy.get(selTargetMenu).should('not.be.visible'); + cy.get('.active-country').click(); + cy.get(selTargetMenu).should('be.visible'); + }); + + it('should open the country target page', () => { + const country = 'Austria'; + + cy.url().should('not.include', country); + cy.get(selTargetLinkActive).should('not.exist'); + + cy.wait(3000); + cy.get(selTargetLink).contains(country).click(force); + cy.wait(3000); + + cy.url().should('include', country); + cy.get(selTargetLinkActive).should('have.length', 1); + }); + + it('should change the country target page', () => { + const country1 = 'Austria'; + const country2 = 'Belgium'; + cy.url().should('not.include', country1); + cy.url().should('not.include', country2); + + cy.get(selTargetLink).contains(country1).click(force); + + cy.url().should('include', country1); + cy.url().should('not.include', country2); + + cy.get(selTargetLink).contains(country2).click(force); + + cy.url().should('include', country2); + cy.url().should('not.include', country1); + + cy.get(selLinkHomeHeader).click(force); + cy.url().should('not.include', country2); + }); + }); +}); diff --git a/cypress/e2e/legend-grid.cy.ts b/cypress/e2e/legend-grid.cy.ts new file mode 100644 index 00000000..419c45b0 --- /dev/null +++ b/cypress/e2e/legend-grid.cy.ts @@ -0,0 +1,265 @@ +context('Statistics Dashboard', () => { + const waitTime = 1500; + + describe('legend grid', () => { + beforeEach(() => { + cy.visit('/country/France'); + cy.wait(waitTime); + }); + + const numSeriesInGroup = 3; + const force = { force: true }; + const selIsOpen = '.roll-down-wrapper.is-open'; + const selLegendGrid = '.legend-grid'; + const selPinnedOpener = '.stick-left'; + const selToggleCountry = '.legend-item-country-toggle'; + const selOpenerSeries = '.legend-item-series-toggle'; + const selColClose = '.column-close'; + const selColRestore = '.column-restore'; + + const clickSeriesOpener = (country: string, seriesIndex = 0): void => { + cy.get(selToggleCountry) + .contains(country) + .closest(selToggleCountry) + .next() + .find(selOpenerSeries) + .eq(seriesIndex) + .click(force); + cy.wait(waitTime); + }; + + 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(selToggleCountry) + .contains('Cyprus') + .should('have.length', 1) + .click(); + cy.wait(waitTime); + 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', numSeriesInGroup); + + cy.get(selToggleCountry) + .contains('Cyprus') + .should('have.length', 1) + .click(); + cy.get(selPinnedOpener).should('have.length', 2); + cy.get(selIsOpen).should('have.length', 2 * numSeriesInGroup); + + cy.get(selToggleCountry).contains('Cyprus').click(); + cy.wait(waitTime); + cy.get(selPinnedOpener).should('have.length', 1); + cy.get(selIsOpen).should('have.length', numSeriesInGroup); + }); + + it('should pin a country (when individual item opened)', () => { + cy.get(selPinnedOpener).should('have.length', 1); + + // Open (the first) Cyprus series + clickSeriesOpener('Cyprus'); + cy.get(selPinnedOpener).should('have.length', 2); + + // Open (the first) Danish series + clickSeriesOpener('Denmark'); + cy.get(selPinnedOpener).should('have.length', numSeriesInGroup); + + // Close each with country togglers + cy.get(selToggleCountry).contains('Cyprus').click(force); + cy.wait(waitTime); + + cy.get(selPinnedOpener).should('have.length', 2); + + cy.get(selToggleCountry).contains('Denmark').click(force); + cy.wait(waitTime); + + cy.get(selPinnedOpener).should('have.length', 1); + + // Open the next Danish entry + clickSeriesOpener('Denmark', 1); + + 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', numSeriesInGroup); + + // open with individual item + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + clickSeriesOpener('Denmark'); + cy.get(selIsOpen).should('have.length', numSeriesInGroup + 1); + + // close with country opener + cy.get(selPinnedOpener).contains('Denmark').click(); + cy.wait(waitTime); + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + cy.get(selPinnedOpener).should('have.length', 1); + cy.get(selIsOpen).should('have.length', numSeriesInGroup); + + // open again with country opener + cy.get(selToggleCountry).contains('Denmark').click(); + cy.wait(waitTime); + cy.get(selIsOpen).should('have.length', numSeriesInGroup + 1); + }); + + it('should keep individually-opened items open when siblings added', () => { + cy.get(selIsOpen).should('have.length', numSeriesInGroup); + + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + clickSeriesOpener('Denmark'); + + cy.get(selIsOpen).should('have.length', numSeriesInGroup + 1); + cy.get(selPinnedOpener).contains('Denmark').should('have.length', 1); + + clickSeriesOpener('Denmark', 1); + cy.get(selIsOpen).should('have.length', numSeriesInGroup + 2); + }); + + it('should toggle the columns', () => { + cy.get(selIsOpen).should('have.length', 3); + + cy.get(selColClose).eq(0).click(force); + cy.get(selIsOpen).should('have.length', 2); + + cy.get(selColClose).eq(0).click(force); + cy.get(selIsOpen).should('have.length', 1); + + cy.get(selColRestore).eq(0).click(force); + cy.get(selIsOpen).should('have.length', 2); + + cy.get(selColRestore).eq(0).click(force); + cy.get(selIsOpen).should('have.length', 3); + }); + + it('should remove and restore pins when toggling columns', () => { + cy.get(selIsOpen).should('have.length', numSeriesInGroup); + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + + // open Denmark 3D + clickSeriesOpener('Denmark', 0); + + cy.get(selIsOpen).should('have.length', numSeriesInGroup + 1); + cy.get(selPinnedOpener).contains('Denmark').should('have.length', 1); + + // hide 3d column + cy.get(selColClose).eq(0).click(force); + cy.wait(waitTime); + + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + cy.get(selIsOpen).should('have.length', 2); + + // restore + cy.get(selColRestore).eq(0).click(force); + cy.wait(waitTime); + cy.get(selPinnedOpener).contains('Denmark').should('have.length', 1); + cy.get(selIsOpen).should('have.length', 1 + numSeriesInGroup); + + // now repeat with the original 3d item closed + + clickSeriesOpener('France', 0); + cy.get(selIsOpen).should('have.length', numSeriesInGroup); + + cy.get(selColClose).eq(0).click(force); + cy.wait(waitTime); + + cy.get(selIsOpen).should('have.length', 2); + cy.get(selColRestore).eq(0).click(force); + cy.wait(waitTime); + + // confirm it did not accifentally re-enable the original 3d item + cy.get(selIsOpen).should('have.length', numSeriesInGroup); + }); + + it('should restore pins in the correct order', () => { + const countries = ['Belgium', 'Croatia', 'Denmark']; + const checkPinOrder = () => { + ['France', ...countries].forEach((country: string, index: number) => { + cy.get(selPinnedOpener) + .eq(index) + .contains(country) + .should('have.length', 1); + }); + }; + + countries.forEach((country: string, index: number) => { + clickSeriesOpener(country, index); + }); + + // check the pin order + checkPinOrder(); + + // Hide column, removing Belgium's only series + cy.get(selColClose).eq(0).click(force); + + cy.get(selPinnedOpener).contains('Belgium').should('not.exist'); + + cy.get(selPinnedOpener).eq(0).contains('France').should('have.length', 1); + cy.get(selPinnedOpener) + .eq(1) + .contains('Croatia') + .should('have.length', 1); + cy.get(selPinnedOpener) + .eq(2) + .contains('Denmark') + .should('have.length', 1); + + // Restore column + cy.get(selColRestore).eq(0).click(force); + + // Belgian pin should be restored + cy.get(selPinnedOpener).contains('Belgium').should('exist'); + checkPinOrder(); + + // Hide column, removing Croatia's only series + cy.get(selColClose).eq(1).click(force); + cy.get(selPinnedOpener).contains('Croatia').should('not.exist'); + + // Restore column + cy.get(selColRestore).eq(1).click(force); + cy.get(selPinnedOpener).contains('Croatia').should('exist'); + checkPinOrder(); + + // Hide column, removing Denmark's only series + cy.get(selColClose).eq(2).click(force); + cy.get(selPinnedOpener).contains('Denmark').should('not.exist'); + + // Restore column + cy.get(selColRestore).eq(2).click(force); + cy.get(selPinnedOpener).contains('Denmark').should('exist'); + checkPinOrder(); + + // Additional test: remove the default pin when the column is hidden + + // Hide column, removing Croatia's only series + cy.get(selColClose).eq(1).click(force); + cy.get(selPinnedOpener).contains('Croatia').should('not.exist'); + + // Hide default pin + cy.get(selPinnedOpener).contains('France').click(force); + cy.wait(5); + cy.get(selPinnedOpener).contains('France').should('not.exist'); + cy.get(selPinnedOpener).contains('Croatia').should('not.exist'); + + // Restore column + cy.get(selColRestore).eq(1).click(force); + cy.get(selPinnedOpener).contains('Croatia').should('exist'); + cy.get(selPinnedOpener).contains('France').should('not.exist'); + + // Check order (without France) + countries.forEach((country: string, index: number) => { + cy.get(selPinnedOpener) + .eq(index) + .contains(country) + .should('have.length', 1); + }); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index d88677a9..6a4c4e38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@angular/router": "^17.1.1", "@europeana/metis-ui-consent-management": "^0.0.16", "@europeana/metis-ui-maintenance-utils": "^0.0.12", + "@europeana/metis-ui-test-utils": "^0.0.18", "cypress-axe": "^1.5.0", "ngx-cookie-service": "^17.0.1", "ngx-matomo-client": "^6.0.2", @@ -3412,6 +3413,18 @@ "@angular/core": "^17.1.1" } }, + "node_modules/@europeana/metis-ui-test-utils": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@europeana/metis-ui-test-utils/-/metis-ui-test-utils-0.0.18.tgz", + "integrity": "sha512-dxgA4jkugPkxgBdSIzdG+PGnaUokEMcrM0PI53Pu1xCL3c1UO1BrN61Is+iEH2vzvvYOLeTzn1vCWAUGUbC+2g==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^17.1.1", + "@angular/core": "^17.1.1" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", diff --git a/package.json b/package.json index 109e9425..33f9bd5e 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@angular/router": "^17.1.1", "@europeana/metis-ui-consent-management": "^0.0.16", "@europeana/metis-ui-maintenance-utils": "^0.0.12", + "@europeana/metis-ui-test-utils": "^0.0.18", "cypress-axe": "^1.5.0", "ngx-cookie-service": "^17.0.1", "ngx-matomo-client": "^6.0.2", diff --git a/src/app/_data/colours.ts b/src/app/_data/colours.ts new file mode 100644 index 00000000..692c101f --- /dev/null +++ b/src/app/_data/colours.ts @@ -0,0 +1,26 @@ +const blue = '#49AECE'; +const blueLight = '#B9DEEF'; + +const green = '#00FF00'; +const greenForest = '#219D31'; +const greenLight = '#A0B468'; + +const pinkBright = '#FF00FF'; +const pinkLilac = '#C697FC'; +const pinkLight = '#FFCCFF'; + +const yellowLight = '#FFC171'; + +const primarySequence = ['#0A72CC', '#E11D53', '#FFAE00']; +const secondarySequence = [greenForest, pinkLight, blue]; +const tertiarySequence = [pinkBright, blueLight, green]; +const lastSequence = [yellowLight, greenLight, pinkLilac]; + +export const colours = [...primarySequence, '#219D31']; + +export const colourGrid = [ + ...primarySequence, + ...secondarySequence, + ...tertiarySequence, + ...lastSequence +]; diff --git a/src/app/_data/index.ts b/src/app/_data/index.ts index 36d2f577..b909fe78 100644 --- a/src/app/_data/index.ts +++ b/src/app/_data/index.ts @@ -1 +1,2 @@ +export * from './colours'; export * from './static-data'; diff --git a/src/app/_data/static-data.ts b/src/app/_data/static-data.ts index d261a713..f407123a 100644 --- a/src/app/_data/static-data.ts +++ b/src/app/_data/static-data.ts @@ -56,8 +56,6 @@ facetNamesFriendly['dates'] = $localize`:@@facetNameDates:Last Updated`; export const portalNamesFriendly = facetNamesFriendly; -export const colours = ['#0a72cc', '#e11d53', '#ffae00', '#219d31']; - export const DiacriticsMap = { A: 'AÁĂẮẶẰẲẴǍÂẤẬẦẨẪÄẠÀẢĀĄÅǺÃÆǼА', B: 'BḄƁʚɞБВ', @@ -221,7 +219,7 @@ export const ISOCountryCodes = { Macao: 'MO', Mongolia: 'MN', 'Marshall Islands': 'MH', - Macedonia: 'MK', + 'North Macedonia': 'MK', Mauritius: 'MU', Malta: 'MT', Malawi: 'MW', @@ -314,6 +312,7 @@ export const ISOCountryCodes = { 'United States': 'US', Uruguay: 'UY', Mayotte: 'YT', + 'United States of America': 'USA', 'United States Minor Outlying Islands': 'UM', Lebanon: 'LB', 'Saint Lucia': 'LC', diff --git a/src/app/_helpers/cache.spec.ts b/src/app/_helpers/cache.spec.ts new file mode 100644 index 00000000..0246c402 --- /dev/null +++ b/src/app/_helpers/cache.spec.ts @@ -0,0 +1,64 @@ +import { Observable, of, throwError } from 'rxjs'; +import { gatherError, gatherValues } from '@europeana/metis-ui-test-utils'; +import { Cache } from '.'; + +function createCacheFn(): () => Observable { + let i = 1; + return jasmine.createSpy().and.callFake(() => of(i++)); +} + +describe('single cache', () => { + it('should get the value', () => { + const fn = createCacheFn(); + const cache = new Cache(fn); + expect(gatherValues(cache.get())).toEqual([1]); + expect(gatherValues(cache.get())).toEqual([1]); + expect(gatherValues(cache.get())).toEqual([1]); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should refresh the value', () => { + const fn = createCacheFn(); + const cache = new Cache(fn); + expect(gatherValues(cache.get())).toEqual([1]); + expect(fn).toHaveBeenCalledTimes(1); + expect(gatherValues(cache.get(true))).toEqual([2]); + expect(fn).toHaveBeenCalledTimes(2); + expect(gatherValues(cache.get())).toEqual([2]); + expect(fn).toHaveBeenCalledTimes(2); + expect(gatherValues(cache.get(true))).toEqual([3]); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should clear the value', () => { + const fn = createCacheFn(); + const cache = new Cache(fn); + expect(gatherValues(cache.get())).toEqual([1]); + cache.clear(); + expect(gatherValues(cache.get())).toEqual([2]); + expect(gatherValues(cache.get())).toEqual([2]); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should not cache an error, but clear the cache', () => { + const error = new Error('wrong'); + const fn = jasmine.createSpy().and.callFake(() => throwError(error)); + const cache = new Cache(fn); + new Array(3).fill(null).map(() => { + expect(gatherError(cache.get())).toEqual(error); + }); + // Must be called 3 times, indicating that error was not cached. + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should peek', () => { + const fn = createCacheFn(); + const cache = new Cache(fn); + expect(gatherValues(cache.peek())).toEqual([undefined]); + expect(gatherValues(cache.get())).toEqual([1]); + expect(gatherValues(cache.peek())).toEqual([1]); + expect(gatherValues(cache.get(true))).toEqual([2]); + expect(gatherValues(cache.peek())).toEqual([2]); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/_helpers/cache.ts b/src/app/_helpers/cache.ts new file mode 100644 index 00000000..0120eded --- /dev/null +++ b/src/app/_helpers/cache.ts @@ -0,0 +1,49 @@ +import { AsyncSubject, connectable, Observable, of } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +export class Cache { + private observable?: Observable; + + constructor(private readonly sourceFn: () => Observable) {} + + /** get + /* accessor for the observable variable + */ + public get(refresh = false): Observable { + // If already cached, return. + if (this.observable && !refresh) { + return this.observable; + } + + // Create new observable. + const observable = connectable( + this.sourceFn().pipe( + tap({ + error: () => this.clear() + }) + ), + { + connector: () => new AsyncSubject(), + resetOnDisconnect: false + } + ); + // Return local variable, as this.observable might be cleared by the connect call. + this.observable = observable; + observable.connect(); + return observable; + } + + /** peek + /* return the observable or undefined as an observable + */ + public peek(): Observable { + return this.observable ?? of(void 0); + } + + /** clear + /* sets the observable to undefined + */ + public clear(): void { + this.observable = undefined; + } +} diff --git a/src/app/_helpers/index.ts b/src/app/_helpers/index.ts index 03052249..8e7e0aeb 100644 --- a/src/app/_helpers/index.ts +++ b/src/app/_helpers/index.ts @@ -1,3 +1,4 @@ export * from './app-date-adapter'; +export * from './cache'; export * from './date-helpers'; export * from './string-helpers'; 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..7db80b3c 100644 --- a/src/app/_mocked/mock-api.service.ts +++ b/src/app/_mocked/mock-api.service.ts @@ -5,8 +5,13 @@ import { BreakdownResults, DimensionName, GeneralResults, + GeneralResultsFormatted, IHash, - RequestFilter + IHashArray, + RequestFilter, + TargetCountryData, + TargetData, + TargetMetaData } from '../_models'; const rightsCategories = [ @@ -658,6 +663,22 @@ export class MockAPIService { ): Observable> { return of([`${rightsCategories[0]}/1.0`, `${rightsCategories[0]}/2.0`]); } + + getTargetMetaData(): Observable>> { + return of({}); + } + + loadCountryData(): Observable> { + return of([]); + } + + getCountryData(): Observable>> { + return of({}); + } + + getGeneralResultsCountry(): Observable { + return of({}); + } } export class MockAPIServiceErrors extends MockAPIService { diff --git a/src/app/_mocked/mock-bar.component.ts b/src/app/_mocked/mock-bar.component.ts index cb958abd..b6e1353d 100644 --- a/src/app/_mocked/mock-bar.component.ts +++ b/src/app/_mocked/mock-bar.component.ts @@ -1,6 +1,6 @@ -import { Component } from '@angular/core'; +import { Component, Input } from '@angular/core'; import * as am4charts from '@amcharts/amcharts4/charts'; -import { ColourSeriesData } from '../_models'; +import { ChartSettings, ColourSeriesData } from '../_models'; @Component({ standalone: true, @@ -10,6 +10,9 @@ import { ColourSeriesData } from '../_models'; export class MockBarComponent { readonly maxNumberBars = 50; + @Input() showPercent: boolean; + @Input() extraSettings: ChartSettings; + browserOnly(_: () => void): void { // mock browserOnly } diff --git a/src/app/_mocked/mock-grid.component.ts b/src/app/_mocked/mock-grid.component.ts index bb17dff5..f29459ae 100644 --- a/src/app/_mocked/mock-grid.component.ts +++ b/src/app/_mocked/mock-grid.component.ts @@ -1,5 +1,11 @@ -import { Component } from '@angular/core'; -import { FmtTableData, SortBy, SortInfo, TableRow } from '../_models'; +import { Component, Input } from '@angular/core'; +import { + DimensionName, + FmtTableData, + SortBy, + SortInfo, + TableRow +} from '../_models'; @Component({ standalone: true, @@ -7,6 +13,10 @@ import { FmtTableData, SortBy, SortInfo, TableRow } from '../_models'; template: '' }) export class MockGridComponent { + @Input() facet: DimensionName; + @Input() isVisible: boolean; + @Input() tierPrefix: string; + sortInfo: SortInfo = { by: SortBy.name, dir: -1 diff --git a/src/app/_mocked/mock-line.component.ts b/src/app/_mocked/mock-line.component.ts new file mode 100644 index 00000000..267e1380 --- /dev/null +++ b/src/app/_mocked/mock-line.component.ts @@ -0,0 +1,127 @@ +import { Component, Input } from '@angular/core'; +import * as am4core from '@amcharts/amcharts4/core'; +import * as am4charts from '@amcharts/amcharts4/charts'; + +import { IHash, IHashArray, TargetFieldName, TargetMetaData } from '../_models'; + +const mockTargetMetaData = { + AT: { + three_d: [], + high_quality: [], + total: [] + }, + DE: { + three_d: [], + high_quality: [], + total: [] + }, + FR: { + three_d: [], + high_quality: [], + total: [] + } +}; + +class MockSeries { + isHidden = false; + + hide(): void { + this.isHidden = true; + } + + show(): void { + this.isHidden = false; + } +} + +@Component({ + standalone: true, + selector: 'app-line-chart', + template: '' +}) +export class MockLineComponent { + @Input() targetMetaData: IHash> = + mockTargetMetaData; + + allSeriesData = ['FR', 'DE'].reduce((ob, code: string) => { + [ + TargetFieldName.THREE_D, + TargetFieldName.HQ, + TargetFieldName.TOTAL + ].forEach((fName: TargetFieldName) => { + ob[`${code}${fName}`] = new MockSeries(); + ob[`${code}${fName}`].fill = am4core.color('#fff'); + }); + return ob; + }, {}) as unknown as IHash; + + valueAxis: am4charts.ValueAxis; + + _colours = [ + am4core.color('#000'), + am4core.color('#FFF'), + am4core.color('#111') + ]; + + chart = { + colors: { + getIndex: (i: number): am4core.Color => { + return this._colours[i]; + }, + reset: (): void => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + }, + list: this._colours, + next: () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + } + } + } as unknown as am4charts.XYChart; + dateAxis: am4charts.DateAxis; + + ngAfterViewInit(): void { + console.log('MockLineComponent ngAfterViewInit'); + } + + addSeries(_, __, ___, ____, _____): void { + console.log('MockLineComponent addSeries'); + } + + removeRange( + country: string, + specificValueName?: string, + specificIndex?: number + ): IHash>> { + const res = {}; + if (specificValueName) { + res[specificValueName] = {}; + res[country] = [specificIndex]; + } + return res as IHash>>; + } + + removeSeries(_: string): void { + console.log('MockLineComponent removeSeries'); + } + + showRange( + _: string, + __: TargetFieldName, + ___: number, + ____: am4core.Color + ): void { + console.log('MockLineComponent showRange'); + } + + hideRange(_: string, __: number): void { + console.log('MockLineComponent hideRange'); + } + + toggleCursor(): void { + console.log('MockLineComponent toggleCursor'); + } + + toggleGridlines(): void { + console.log('MockLineComponent toggleGridlines'); + } +} 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/models.ts b/src/app/_models/models.ts index c0371e05..4d308366 100644 --- a/src/app/_models/models.ts +++ b/src/app/_models/models.ts @@ -74,3 +74,8 @@ export interface NameValue { export interface NamesValuePercent extends NameValue { percent: number; } + +export interface CountryTotalInfo { + code: string; + total: number; +} diff --git a/src/app/_models/targets.ts b/src/app/_models/targets.ts new file mode 100644 index 00000000..ce58ac15 --- /dev/null +++ b/src/app/_models/targets.ts @@ -0,0 +1,36 @@ +import * as am4charts from '@amcharts/amcharts4/charts'; + +export enum TargetFieldName { + THREE_D = 'three_d', + HQ = 'high_quality', + TOTAL = 'total' +} + +interface TargetMetaDataBase { + value: number; + targetYear: number; + isInterim?: boolean; +} + +export interface TargetMetaDataRaw extends TargetMetaDataBase { + country: string; + targetType: TargetFieldName; +} + +export interface TargetMetaData extends TargetMetaDataBase { + range?: am4charts.ValueAxisDataItem; +} + +type TargetFieldNameType = { + [key in TargetFieldName]: string; +}; + +export interface TargetData extends TargetFieldNameType { + date: string; +} + +export interface TargetCountryData extends TargetData { + country: string; +} + +export const TargetSeriesSuffixes = ['3D', 'high_quality', 'total']; diff --git a/src/app/_services/api.service.ts b/src/app/_services/api.service.ts index d250000e..67816317 100644 --- a/src/app/_services/api.service.ts +++ b/src/app/_services/api.service.ts @@ -1,17 +1,33 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { environment } from '../../environments/environment'; import { BreakdownRequest, + BreakdownResult, BreakdownResults, + CountPercentageValue, GeneralResults, - IHash + GeneralResultsFormatted, + IHash, + IHashArray, + TargetCountryData, + TargetData, + TargetMetaData, + TargetMetaDataRaw } from '../_models'; import { ISOCountryCodes } from '../_data'; +import { Cache } from '../_helpers'; @Injectable({ providedIn: 'root' }) export class APIService { + private readonly countries = new Cache(() => this.loadCountryData()); + private readonly generalResults = new Cache(() => this.getGeneralResults()); + public readonly generalResultsCountry = new Cache(() => + this.loadGeneralResultsCountry() + ); + suffixGeneral = 'statistics/europeana/general'; suffixFiltering = 'statistics/filtering'; suffixRightsUrls = 'statistics/rights/urls'; @@ -44,6 +60,28 @@ export class APIService { ); } + loadGeneralResultsCountry(): Observable { + return this.generalResults.get(); + } + + getGeneralResultsCountry(): Observable { + return this.generalResultsCountry.get().pipe( + map((data: GeneralResults) => { + const res: GeneralResultsFormatted = {}; + data.allBreakdowns.forEach((br: BreakdownResult) => { + res[br.breakdownBy] = br.results.map((cpv: CountPercentageValue) => { + return { + name: cpv.value, + value: cpv.count, + percent: cpv.percentage + }; + }); + }); + return res; + }) + ); + } + getRightsCategoryUrls( rightsCategories: Array ): Observable> { @@ -54,4 +92,121 @@ export class APIService { { params: { rightsCategories } } ); } + + /** + * loadTargetMetaData + * + * Expected back-end format: + * { + * "country": "Germany", + * "label": "2025", + * "value": 370, + * "targetType": "three_d" | "high_quality" | "total" + * }... + * + * @return [TargetMetaDataRaw] + **/ + private loadTargetMetaData(): Observable> { + return this.http + .get>( + this.replaceDoubleSlashes( + `${environment.serverAPI}/statistics/europeana/targets` + ) + ) + .pipe( + map((targetData: Array) => { + return targetData.map((tmd: TargetMetaDataRaw) => { + tmd.isInterim = tmd.targetYear !== 2030; + tmd.country = ISOCountryCodes[tmd.country]; + return tmd; + }); + }) + ); + } + + /** + * reduceTargetMetaData + * + * creates hash from raw target data (array) wherein item.county is used as a + * key to a further hash, which in turn uses item.targetType to access arrays + * of TargetMetaData objects + * + * @param { Array } rows - the source data to reduce + **/ + reduceTargetMetaData( + rows: Array + ): IHash> { + return rows.reduce( + (res: IHash>, item: TargetMetaDataRaw) => { + const country = item.country; + if (!res[country]) { + res[country] = {}; + } + + let arr: Array = res[country][item.targetType]; + if (!arr) { + arr = []; + res[country][item.targetType] = arr; + } + + arr.push({ + targetYear: item.targetYear, + value: item.value, + isInterim: item.isInterim + }); + return res; + }, + {} + ); + } + + /** getTargetMetaData + * returns the result of loadTargetMetaData piped / mapped to reduceTargetMetaData + **/ + getTargetMetaData(): Observable>> { + return this.loadTargetMetaData().pipe( + map((rows: Array) => { + return this.reduceTargetMetaData(rows); + }) + ); + } + + loadCountryData(): Observable> { + const res = this.http.get>( + this.replaceDoubleSlashes( + `${environment.serverAPI}/statistics/europeana/target/country/all` + ) + ); + + return res.pipe( + map((rows: Array) => { + rows.forEach((row: TargetCountryData) => { + row.country = ISOCountryCodes[row.country] || row.country; + }); + return rows; + }) + ); + } + + /** getCountryData + * returns the result of countries (the cached loadCountryData) mapped to a hash (key: country) + **/ + getCountryData(): Observable>> { + return this.countries.get().pipe( + map((rows: Array) => { + const res = rows.reduce( + (res: IHash>, item: TargetCountryData) => { + if (!res[item.country]) { + res[item.country] = []; + } + const { country, ...itemNoCountry } = item; + res[country].push(itemNoCountry); + return res; + }, + {} + ); + return res; + }) + ); + } } diff --git a/src/app/_translate/abbreviate-number.pipe.ts b/src/app/_translate/abbreviate-number.pipe.ts new file mode 100644 index 00000000..6ed297e8 --- /dev/null +++ b/src/app/_translate/abbreviate-number.pipe.ts @@ -0,0 +1,24 @@ +/** AbbreviateNumberPipe + * Pipe numbers to short strings, i.e. 1000 ==> "1k" + **/ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'abbreviateNumber', + standalone: true +}) +export class AbbreviateNumberPipe implements PipeTransform { + fmtSettings = { + // ts doesn't understand NumberFormatOptions, but this cast allows it to compile + notation: 'compact' as + | 'standard' + | 'compact' + | 'scientific' + | 'engineering', + maximumFractionDigits: 1 + }; + + transform(value: number): string { + return Intl.NumberFormat('en-US', this.fmtSettings).format(value); + } +} diff --git a/src/app/_translate/index.ts b/src/app/_translate/index.ts index 9b0d302a..fbea4bbc 100644 --- a/src/app/_translate/index.ts +++ b/src/app/_translate/index.ts @@ -1,3 +1,6 @@ +export * from './abbreviate-number.pipe'; 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..875736f0 --- /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: 'All', + three_d: '3D', + high_quality: 'HQ' +}; + +@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..fc13c588 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 { CountryComponent } from './country/country.component'; const routes: Routes = [ + { + path: 'country/:country', + component: CountryComponent + }, { path: 'data/:facet', component: OverviewComponent diff --git a/src/app/app.component.html b/src/app/app.component.html index 671c2394..6eb20de2 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -6,6 +6,7 @@ { spyOn(app, 'loadLandingData'); + // load Landing app.onOutletLoaded(new LandingComponent()); expect(app.showPageTitle).toBeTruthy(); - expect(app.loadLandingData).toHaveBeenCalled(); + expect(app.loadLandingData).not.toHaveBeenCalled(); + // load other app.onOutletLoaded({} as unknown as OverviewComponent); expect(app.showPageTitle).toBeFalsy(); - expect(app.loadLandingData).toHaveBeenCalledTimes(1); - app.onOutletLoaded(new LandingComponent()); + expect(app.loadLandingData).not.toHaveBeenCalled(); + + // load landing + app.getCtrlCTZero().setValue(true); + + const cmp = new LandingComponent(); + app.landingData = {}; + app.onOutletLoaded(cmp); expect(app.showPageTitle).toBeTruthy(); expect(app.loadLandingData).toHaveBeenCalledTimes(1); + expect(app.lastSetContentTierZeroValue).toBeTruthy(); + expect(cmp.landingData).toBeTruthy(); - expect(app.lastSetContentTierZeroValue).toBeFalsy(); - app.lastSetContentTierZeroValue = true; - + app.lastSetContentTierZeroValue = !app.getCtrlCTZero().value; app.onOutletLoaded(new LandingComponent()); - expect(app.showPageTitle).toBeTruthy(); expect(app.loadLandingData).toHaveBeenCalledTimes(2); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1b6e6687..7441e5ef 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -21,11 +21,13 @@ import { import { cookieConsentConfig } from '../environments/eu-cm-settings'; import { maintenanceSettings } from '../environments/maintenance-settings'; import { SubscriptionManager } from './subscription-manager'; +import { ISOCountryCodes } from './_data'; import { AppDateAdapter } from './_helpers'; import { APIService, ClickService } from './_services'; import { BreakdownResult, CountPercentageValue, + CountryTotalInfo, GeneralResults, GeneralResultsFormatted } from './_models'; @@ -59,6 +61,7 @@ export class AppComponent extends SubscriptionManager implements OnInit { skipLocationUpdate = false; maintenanceInfo?: MaintenanceItem = undefined; + @ViewChild('header') header: HeaderComponent; @ViewChild('consentContainer', { read: ViewContainerRef }) consentContainer: ViewContainerRef; @@ -131,13 +134,20 @@ export class AppComponent extends SubscriptionManager implements OnInit { return this.formCTZero.get('contentTierZero') as FormControl; } - /** loadLandingData - /* - loads the general breakdown data - /* - sets data on landingComponentRef - /* @param { boolean: includeCTZero } - request content-tier-zero - */ + /*** loadLandingData + * - resets local landingData object + * - loads the general breakdown data / reconstructs local object + * - sets landingComponentRef landingData to local object + * - derives countryTotalMap data and assigns to header component + * @param { boolean: includeCTZero } - request content-tier-zero + ***/ loadLandingData(includeCTZero: boolean): void { - this.landingComponentRef.isLoading = true; + if (this.landingComponentRef) { + this.landingComponentRef.isLoading = true; + } + + const countryTotalMap: { [key: string]: CountryTotalInfo } = {}; + this.subs.push( this.api .getGeneralResults(includeCTZero) @@ -153,10 +163,24 @@ export class AppComponent extends SubscriptionManager implements OnInit { }; } ); + if (br.breakdownBy === 'country') { + br.results.forEach((result: CountPercentageValue) => { + countryTotalMap[result.value] = { + total: result.count, + code: ISOCountryCodes[result.value] + }; + }); + } }); - this.landingComponentRef.includeCTZero = includeCTZero; - this.landingComponentRef.landingData = this.landingData; - this.landingComponentRef.isLoading = false; + + if (this.landingComponentRef) { + this.landingComponentRef.includeCTZero = includeCTZero; + this.landingComponentRef.landingData = this.landingData; + this.landingComponentRef.isLoading = false; + } + + // assign country data + this.header.countryTotalMap = countryTotalMap; }) ); } @@ -192,20 +216,18 @@ export class AppComponent extends SubscriptionManager implements OnInit { }) ); this.location.subscribe(this.handleLocationPopState.bind(this)); + this.buildForm(); + this.loadLandingData(this.formCTZero.value.contentTierZero); } /** onOutletLoaded /* - invoked when router component loads - /* - handles component data binding (since @Input() is unavailable in router-outlet) + /* - handles component data binding /* - sets showPageTitle - /* - if it's the landing page (first visit): - /* - builds the form - /* - loads the landing data - /* - sets landingComponentRef data - /* - and if it's the first arrival it: - /* - (subsequent vist) - /* - reassigns existing landing data to landingComponentRef unless stale - /* - triggers data reload if form data is stale + /* - if it's the landing page (and ct-zero control value matches): + /* - assigns landing data + /* - if ct-zero control value doesn't match: + /* - updates control (triggers data reload) /* - (OverviewComponent) /* - unassigns landingComponentRef /* @@ -216,16 +238,13 @@ export class AppComponent extends SubscriptionManager implements OnInit { this.showPageTitle = true; this.landingComponentRef = component; this.landingComponentRef.includeCTZero = this.lastSetContentTierZeroValue; - - if (!this.formCTZero) { - this.buildForm(); - this.loadLandingData(this.formCTZero.value.contentTierZero); + const ctrlCTZero = this.getCtrlCTZero(); + if (this.lastSetContentTierZeroValue !== ctrlCTZero.value) { + this.skipLocationUpdate = true; + // trigger the load + ctrlCTZero.setValue(this.lastSetContentTierZeroValue); } else { - const ctrlCTZero = this.getCtrlCTZero(); - if (this.lastSetContentTierZeroValue !== ctrlCTZero.value) { - this.skipLocationUpdate = true; - ctrlCTZero.setValue(this.lastSetContentTierZeroValue); - } else { + if (this.landingComponentRef && this.landingData) { this.landingComponentRef.landingData = this.landingData; } } diff --git a/src/app/appendice-section/appendice-section.component.html b/src/app/appendice-section/appendice-section.component.html new file mode 100644 index 00000000..86cf8b92 --- /dev/null +++ b/src/app/appendice-section/appendice-section.component.html @@ -0,0 +1,213 @@ +
+ + + + + + + + 3D + (2025) + (2030) + + + + + HQ + 2025 + 2030 + + + + + All + 2025 + 2030 + + + + + + + {{ headerValue | number }} + + + + @if(isHeader) { + {{ targets[headerIndex][0] | abbreviateNumber }} + } + + + + @if(isHeader) { + {{ targets[headerIndex][1] | abbreviateNumber }} + } + + + + + @if(wrap){ + + + + + + + } @else { + + + @if(isHeader) { + {{ country | renameCountry }} + } + + + + {{ row["date"] | date: "dd/MM/yyyy" }} + + + + + + + + + + + + + + } + + + + + + + + + + +
diff --git a/src/app/appendice-section/appendice-section.component.scss b/src/app/appendice-section/appendice-section.component.scss new file mode 100644 index 00000000..773309f8 --- /dev/null +++ b/src/app/appendice-section/appendice-section.component.scss @@ -0,0 +1,185 @@ +@import "../../scss/_variables"; + +$appendice-grid-num-cols: 14; +$row-height: 1.6rem; +$grid-height: 20em; +$window-size: $row-height; + +.appendice-grid { + display: grid; + grid-template-columns: + minmax(10em, 1fr) + auto + 1.6rem + auto + auto + auto + 1.6rem + auto + auto + auto + 1.6rem + auto + auto + auto; + margin-top: -1rem; + text-align: right; + + .cell-bold { + font-weight: 600; + } + + .cell-header { + transform: translateY(2rem); + z-index: 2; + + &:not(.window) { + background-color: #fff; + } + &.window { + content: ""; + max-width: $window-size; + width: $window-size; + height: $window-size; + padding: 4px 0; + + .window-frame { + box-sizing: border-box; + border: 6px solid #fff; + width: $window-size; + height: $window-size; + display: block; + } + + &::after { + // mask + background: #fff; + content: ""; + display: block; + height: $grid-height; + left: 0; + position: absolute; + top: $window-size; + width: $window-size; + } + } + } + + .cell-left { + text-align: left; + } + + .cell-line { + border-left-style: solid; + border-left-width: 1px; + border-left-color: transparent; + } + + .cell-live { + background-color: #fff; + color: #777; + z-index: 1; + } + + .diminished { + color: #777; + line-height: calc(4px + 1.6rem); + } + + .cell-target, + .diminished { + font-size: 10px; + font-weight: bold; + } + + .cell-target { + height: $row-height; + line-height: $row-height; + } + + :not(.cell-header) { + font-style: italic; + color: #777; + } +} + +.appendice-grid-sticky-row { + background-color: #fff; + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / calc($appendice-grid-num-cols + 1); + position: sticky; + top: 0; + + .cell-live { + z-index: 0; + } +} + +.appendice-grid-scrollable-section { + display: grid; + grid-column: 1 / calc($appendice-grid-num-cols + 1); + grid-template-columns: subgrid; + max-height: $grid-height; + overflow-y: auto; + padding-top: $window-size; + + @media (min-width: $bp-xxl) { + font-size: 14px; + } +} + +.cell-header, +.cell-left, +.cell-live, +.cell-target { + padding: 4px 1em; +} + +.cell-pad-right { + padding-right: 1rem; + + &.cell-header { + padding-right: 2rem; + } +} + +.double { + grid-template-columns: + minmax(10em, 1fr) + auto + 1.6rem + auto + auto + auto + 1.6rem + auto + auto + auto; + + .appendice-grid-scrollable-section, + .appendice-grid-sticky-row { + grid-column: 1 / calc(($appendice-grid-num-cols - 4) + 1); + } +} + +.single { + &.appendice-grid { + grid-template-columns: + minmax(10em, 1fr) + auto + 1.6rem + auto + auto + auto; + } + + .appendice-grid-scrollable-section, + .appendice-grid-sticky-row { + grid-column: 1 / calc(($appendice-grid-num-cols - 8) + 1); + } +} + +.window-pane { + transform: translateY(-2.5em); +} diff --git a/src/app/appendice-section/appendice-section.component.spec.ts b/src/app/appendice-section/appendice-section.component.spec.ts new file mode 100644 index 00000000..953f2a29 --- /dev/null +++ b/src/app/appendice-section/appendice-section.component.spec.ts @@ -0,0 +1,17 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AppendiceSectionComponent } from '.'; + +describe('AppendiceSectionComponent', () => { + let component: AppendiceSectionComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(AppendiceSectionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/appendice-section/appendice-section.component.ts b/src/app/appendice-section/appendice-section.component.ts new file mode 100644 index 00000000..d1f014cc --- /dev/null +++ b/src/app/appendice-section/appendice-section.component.ts @@ -0,0 +1,74 @@ +import { CommonModule, KeyValuePipe } from '@angular/common'; +import { Component, Input } from '@angular/core'; + +import { + IHash, + IHashArray, + TargetData, + TargetFieldName, + TargetMetaData, + TargetSeriesSuffixes +} from '../_models'; +import { AbbreviateNumberPipe, RenameCountryPipe } from '../_translate'; + +@Component({ + imports: [ + AbbreviateNumberPipe, + CommonModule, + KeyValuePipe, + RenameCountryPipe + ], + selector: 'app-appendice-section', + standalone: true, + styleUrls: ['./appendice-section.component.scss'], + templateUrl: './appendice-section.component.html' +}) +export class AppendiceSectionComponent { + public TargetFieldName = TargetFieldName; + public TargetSeriesSuffixes = TargetSeriesSuffixes; + + @Input() pinnedCountries: IHash = {}; + @Input() countryData: IHash> = {}; + @Input() targetMetaData: IHash>; + @Input() colourMap: IHash<{ fill?: string }>; + + _columnEnabled3D = true; + _columnEnabledHQ = true; + _columnEnabledALL = true; + columnsEnabledCount = 3; + + @Input() set columnEnabled3D(value: boolean) { + this._columnEnabled3D = value; + this.calculateColumnsEnabledCount(); + } + + get columnEnabled3D(): boolean { + return this._columnEnabled3D; + } + + @Input() set columnEnabledHQ(value: boolean) { + this._columnEnabledHQ = value; + this.calculateColumnsEnabledCount(); + } + + get columnEnabledHQ(): boolean { + return this._columnEnabledHQ; + } + + @Input() set columnEnabledALL(value: boolean) { + this._columnEnabledALL = value; + this.calculateColumnsEnabledCount(); + } + + get columnEnabledALL(): boolean { + return this._columnEnabledALL; + } + + calculateColumnsEnabledCount(): void { + this.columnsEnabledCount = [ + this.columnEnabled3D, + this.columnEnabledHQ, + this.columnEnabledALL + ].filter((val: boolean) => !!val).length; + } +} diff --git a/src/app/appendice-section/index.ts b/src/app/appendice-section/index.ts new file mode 100644 index 00000000..ca7f460a --- /dev/null +++ b/src/app/appendice-section/index.ts @@ -0,0 +1 @@ +export * from './appendice-section.component'; 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..8d69ba8c --- /dev/null +++ b/src/app/chart/line/line.component.scss @@ -0,0 +1,4 @@ +#lineChart { + height: 338px; // exactly 4 items + 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..de2a9c7c --- /dev/null +++ b/src/app/chart/line/line.component.spec.ts @@ -0,0 +1,140 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import * as am4charts from '@amcharts/amcharts4/charts'; +import * as am4core from '@amcharts/amcharts4/core'; + +import { TargetFieldName, TargetMetaData } from '../../_models'; + +import { LineComponent } from './line.component'; + +describe('LineComponent', () => { + let component: LineComponent; + let fixture: ComponentFixture; + + const mockTargetMetaData = { + DE: { + three_d: [ + { + isInterim: true, + targetYear: 2025, + value: 1 + } as TargetMetaData + ], + high_quality: [ + { + isInterim: true, + targetYear: 2025, + value: 2 + } as TargetMetaData + ], + total: [] + }, + FR: { + three_d: [] + } + }; + + 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(); + }); + + it('should remove the range', () => { + component.targetMetaData = structuredClone(mockTargetMetaData); + + const deData = component.targetMetaData['DE']; + + Object.keys(deData).forEach((key: string) => { + deData[key].forEach((ob: TargetMetaData) => { + ob.range = { + // eslint-disable-next-line @typescript-eslint/no-empty-function + dispose: () => {} + } as unknown as am4charts.ValueAxisDataItem; + }); + }); + + spyOn(component.valueAxis.axisRanges, 'removeValue'); + + component.removeRange('DE', TargetFieldName.HQ); + expect(component.valueAxis.axisRanges.removeValue).toHaveBeenCalledTimes(1); + + component.removeRange('DE'); + expect(component.valueAxis.axisRanges.removeValue).toHaveBeenCalledTimes(2); + + component.removeRange('DE', TargetFieldName.HQ); + expect(component.valueAxis.axisRanges.removeValue).toHaveBeenCalledTimes(2); + }); + + it('should show the range', () => { + const paddingRight = component.chart.paddingRight as number; + component.targetMetaData = structuredClone(mockTargetMetaData); + component.showRange('DE', TargetFieldName.HQ, 0, am4core.color('#0c529c')); + expect(component.chart.paddingRight as number).toBeGreaterThan( + paddingRight + ); + }); + + it('should create the range', () => { + const data = structuredClone(mockTargetMetaData); + expect((data as unknown as TargetMetaData).range).toBeFalsy(); + component.createRange( + data as unknown as TargetMetaData, + am4core.color('#0c529c') + ); + expect((data as unknown as TargetMetaData).range).toBeTruthy(); + }); + + it('should add the series', () => { + component.chart.data = []; + expect(component.allSeriesData['myVal']).toBeFalsy(); + component.addSeries('My Series', 'myVal', TargetFieldName.HQ, [ + { + date: '14/12/2001', + three_d: '12', + total: '40', + high_quality: '10' + } + ]); + expect(component.allSeriesData['myVal']).toBeTruthy(); + expect(component.chart.data.length).toBeTruthy(); + }); + + /* + it('should toggle the grid lines', () => { + expect(component.valueAxis.renderer.grid.template.disabled).toBeTruthy(); + component.toggleGridlines(); + expect(component.valueAxis.renderer.grid.template.disabled).toBeFalsy(); + component.toggleGridlines(); + expect(component.valueAxis.renderer.grid.template.disabled).toBeTruthy(); + }); + */ + + it('should toggle the cursor', () => { + expect(component.chart.cursor).toBeFalsy(); + component.toggleCursor(); + expect(component.chart.cursor).toBeTruthy(); + component.toggleCursor(); + expect(component.chart.cursor).toBeFalsy(); + }); + + it('should toggle the scrollbar', () => { + expect(component.chart.scrollbarX).toBeFalsy(); + component.toggleScrollbar(); + expect(component.chart.scrollbarX).toBeTruthy(); + component.toggleScrollbar(); + expect(component.chart.scrollbarX).toBeFalsy(); + }); +}); diff --git a/src/app/chart/line/line.component.ts b/src/app/chart/line/line.component.ts new file mode 100644 index 00000000..5f4363ff --- /dev/null +++ b/src/app/chart/line/line.component.ts @@ -0,0 +1,310 @@ +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 { colourGrid } from '../../_data'; +import { + IHash, + IHashArray, + TargetData, + TargetFieldName, + TargetMetaData +} 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: IHash = {}; + chart: am4charts.XYChart; + dateAxis: am4charts.DateAxis; + padding = { + top: 21, + bottom: 6, + left: 0, + rightDefault: 14, + rightWide: 30 + }; + valueAxis: am4charts.ValueAxis; + + @Input() targetMetaData: IHash>; + + constructor( + @Inject(PLATFORM_ID) private readonly platformId, + private readonly zone: NgZone + ) { + am4core.options.autoDispose = true; + } + + ngAfterViewInit(): void { + this.drawChart(); + } + + /** removeRange + * Removes either a single range or a set of ranges + **/ + removeRange( + country: string, + specificValueName?: string, + specificIndex?: number + ): IHash>> { + const allRemovals = {} as IHash>>; + + Object.keys(TargetFieldName).forEach((seriesValueName: string) => { + if ( + !specificValueName || + specificValueName === TargetFieldName[seriesValueName] + ) { + const targetDataType = + this.targetMetaData[country][TargetFieldName[seriesValueName]]; + if (targetDataType) { + targetDataType.forEach((td: TargetMetaData, tdIndex: number) => { + if (parseInt(`${specificIndex}`) > -1) { + if (specificIndex !== tdIndex) { + return; + } + } + + if (td.range) { + if (!allRemovals[seriesValueName]) { + allRemovals[seriesValueName] = {}; + allRemovals[seriesValueName][country] = []; + } + allRemovals[seriesValueName][country].push(tdIndex); + this.valueAxis.axisRanges.removeValue(td.range); + td.range.dispose(); + delete td.range; + } + }); + } + } + }); + + if (this.valueAxis.axisRanges.values.length === 0) { + this.chart.paddingRight = this.padding.rightDefault; + } + + return allRemovals; + } + + showRange( + country: string, + seriesValueName: TargetFieldName, + index: number, + colour: am4core.Color + ): void { + this.createRange( + this.targetMetaData[country][seriesValueName][index], + colour + ); + this.chart.paddingRight = this.padding.rightWide; + } + + /** createRange + * creates and styles a (pinned) axisRange + * assigns reference for open / closing behaviour + **/ + createRange(targetData: TargetMetaData, colour: am4core.Color): void { + const colourPin = am4core.color('#0c529c'); // eu-flag colour + const range = this.valueAxis.axisRanges.create(); + + targetData.range = range; + + range.axisFill.fill = colour; + range.axisFill.fillOpacity = 0.3; + + if (targetData.isInterim) { + range.grid.strokeDasharray = '3,3'; + } + + range.grid.above = true; + range.grid.location = 0; + 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.text = `${targetData.targetYear}`; + range.label.verticalCenter = 'bottom'; + range.value = targetData.value; + + const pin = range.label.createChild(am4plugins_bullets.PinBullet); + + pin.background.radius = 10; + pin.background.fill = colourPin; + pin.cursorOverStyle = am4core.MouseCursorStyle.pointer; + pin.dy = 28; + 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 = 34; + range.endValue = range.value; + pin.background.pointerAngle = 180; + pin.dx = defaultDx; + }; + + setRangeAndPinDefaults(); + + // 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 = 41; + } else { + setRangeAndPinDefaults(); + } + }); + } + + /** + * addSeries + * adds a LineSeries object to the chart / stores ref to this.allSeriesData + * adds the (renmed) series data to the chart data + * @param { string } axisLabel - axis label + * @param { string } seriesValueY - unique per-series per-country series key + * @param { TargetFieldName } valueY + * @param { Array } seriesData: + **/ + addSeries( + axisLabel: string, + seriesValueY: string, + valueY: TargetFieldName, + 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 = `${axisLabel} {${seriesValueY}}`; + series.tooltip.pointerOrientation = 'vertical'; + series.tooltip.getFillFromObject = true; + + const chartData = this.chart.data; + + seriesData.forEach((sd: TargetData, rowIndex: number) => { + const val = sd[valueY]; + if (rowIndex >= chartData.length) { + chartData.push(sd); + } + chartData[rowIndex][seriesValueY] = val; + }); + this.allSeriesData[seriesValueY] = series; + } + + /** drawChart + * initialises chart object + **/ + drawChart(): void { + am4core.useTheme(am4themes_animated); + + // Create chart instance + const chart = am4core.create('lineChart', am4charts.XYChart); + this.chart = chart; + chart.seriesContainer.zIndex = -1; + + chart.paddingTop = this.padding.top; + chart.paddingBottom = this.padding.bottom; + chart.paddingLeft = this.padding.left; + chart.paddingRight = this.padding.rightDefault; + + chart.colors.list = colourGrid.map((colour: string) => { + return am4core.color(colour); + }); + + chart.data = [{}]; + + const colourAxis = am4core.color('#4d4d4d'); + + // Create date axis + const dateAxis = chart.xAxes.push(new am4charts.DateAxis()); + 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 + const valueAxis = chart.yAxes.push(new am4charts.ValueAxis()); + this.valueAxis = valueAxis; + valueAxis.extraMax = 0.1; + valueAxis.min = 0; + valueAxis.includeRangesInMinMax = true; + valueAxis.renderer.labels.template.fill = colourAxis; + valueAxis.renderer.labels.template.fontSize = 14; + this.valueAxis.renderer.grid.template.disabled = true; + this.dateAxis.renderer.grid.template.disabled = true; + } + + toggleGridlines(): void { + // disable grid lines + if (this.dateAxis.renderer.grid.template.disabled) { + this.valueAxis.renderer.grid.template.disabled = false; + this.dateAxis.renderer.grid.template.disabled = false; + } else { + this.valueAxis.renderer.grid.template.disabled = true; + this.dateAxis.renderer.grid.template.disabled = true; + } + this.chart.invalidateData(); + } + + 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(); + 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/country/country.component.html b/src/app/country/country.component.html new file mode 100644 index 00000000..1e442020 --- /dev/null +++ b/src/app/country/country.component.html @@ -0,0 +1,619 @@ +
+

+ {{ country }}: Target Data +

+ + +
+ + {{ label }} + {{ target | number }} + +
+
+ + {{ percent | number: "1.0-2" }}% + {{ percent | number: "1.0-1" }}% + +
+
+
+ + + + + + + Top + {{ dimension | renameApiFacetShort | lowercase }} + + + Data + + + {{ name }} + + + + + + Top + {{ dimension | renameApiFacetShort | lowercase }} records count + + + + + Total {{ name }} records count + Records + + + + {{ value | number }} + {{ + value | abbreviateNumber + }} + + + + + + Corresponding percentage + + + Percent + + + + + {{ percent | number: "1.0-2" }}% + {{ percent | number: "1.0-1" }}% + + + + + + + + + + + + +
+
+ {{ dimension | renameApiFacet | uppercase }} + RECORDS PROVIDED +
+
+ + {{ row.value | number }} + {{ row.percent }}% +
+
+
+ + + >> + + +
+
+ + +
+ + 3D Data + +
+ + + + View (3D data) by content tier +
+
+ + + +
+ + HQ Data + +
+ + + + + + View (HQ data) by type +
+
+ + + +
+ All Data + +
+ + + + + View all data {{ country }} +
+ + +
+
+ + + +
+ Countries By Target Progress + + +
+
+ + + +
+ + + Hide data + Show data + + + +
+ +
+
+
+ +
+
+ Explore by type of content +
+ + + + + View by type +
+
+
+ Explore by rights category +
+
+ +
+ View by rights category +
+
+
+ +
+
+ Explore by data provider +
+
+ +
+ View by data provider +
+
+
+ Explore by provider +
+
+ +
+ View by provider +
+
+
+
+ +
diff --git a/src/app/country/country.component.scss b/src/app/country/country.component.scss new file mode 100644 index 00000000..7e70b499 --- /dev/null +++ b/src/app/country/country.component.scss @@ -0,0 +1,213 @@ +@import "../../scss/_variables"; +@import "../../scss/functions/_icon_urls"; +@import "../../scss/functions/_replace"; + +.appendice-grid-wrapper { + $timeTransition: 0.5s; + + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows $timeTransition linear; + + &.is-open { + grid-template-rows: 1fr; + } + + & > * { + overflow-y: hidden; + } +} + +.appendice-toggle { + display: flex; + justify-self: flex-end; + flex-direction: row; + + font-size: 14px; + font-weight: 600; + + margin: 14px 22px 0 auto; + + position: relative; + color: $gray-med; + .save { + position: absolute; + left: auto; + right: -24px; + transform: rotate(90deg); + } + &:hover .save { + transform: rotate(0deg); + } +} + +.column-close { + align-items: center; + align-self: flex-start; + display: flex; + white-space: nowrap; + + .x { + margin-left: 0.6em; + opacity: 0; + } + + &:hover .x { + opacity: 1; + } + + &:active + .entry-card-content { + transform: rotate(1deg); + } +} + +.column-close, +.column-restore { + color: $gray-med; +} + +.column-restore { + font-weight: bold; + position: absolute; + right: 1em; + top: 1em; +} + +.entry-card, +.entry-card-header { + position: relative; +} + +.entry-card-multi > .entry-card:first-child:last-child { + width: 100%; +} + +.line-and-legend { + position: relative; + + .entry-card-multi { + justify-content: space-evenly; + & > :first-child { + flex-grow: 1; + } + } +} + +.powerbar, +.powerbar-charge { + height: 3em; +} + +.powerbar { + position: relative; + background-color: transparent; + border: 1px solid $gray-5; +} + +.powerbar-charge { + background-color: $stats-blue; + display: inline-block; + max-width: 100%; +} + +.powerbar-charge-label { + color: $stats-blue; + font-size: 22px; + font-weight: bold; + line-height: 150%; + margin-left: 1rem; + position: absolute; + top: 6px; + + &.zeroed { + color: #fff; + left: 0; + } +} + +.powerbar-label-left, +.powerbar-label-right { + font-size: 20px; + margin: 0 0.6em; +} + +.powerbar-label-left { + font-weight: 600; +} + +.powerbar-label-right { + float: right; +} + +.powerbar-wrapper { + display: flex; + flex-direction: column; + margin: 1.6em 0.6em; + position: relative; + + &:not(.labels-above) { + flex-direction: column-reverse; + + .powerbar-target-labels { + margin-top: 0.6em; + } + } + + &.labels-above { + .powerbar-target-labels { + margin-bottom: 0.6em; + } + } +} + +.powerbar-charge-label, +.total, +.total-title { + .hide-on-triple { + display: inline; + } + .show-on-triple { + display: none; + } + + &.triple { + .hide-on-triple { + display: none; + } + + .show-on-triple { + display: inline; + } + } +} + +// show only the last restore link +.entry-card:not(:last-child):not(.line-and-legend) .column-restore { + display: none; +} + +// prevent user from deleting everything +.entry-card:first-child:last-child .column-close { + pointer-events: none; +} + +.entry-card-content.entry-card-totals { + .totals { + & > :last-child { + display: none; + } + } +} + +.entry-card { + display: flex; + flex-direction: column; +} + +.page-title { + margin: 1em 0.6em; +} + +.save { + opacity: 1; +} diff --git a/src/app/country/country.component.spec.ts b/src/app/country/country.component.spec.ts new file mode 100644 index 00000000..659a3c98 --- /dev/null +++ b/src/app/country/country.component.spec.ts @@ -0,0 +1,128 @@ +import { + ApplicationRef, + ComponentRef, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef +} from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; + +import { of } from 'rxjs'; + +import { APIService } from '../_services'; +import { MockAPIService, MockLineComponent } from '../_mocked'; +import { TargetFieldName } from '../_models'; +import { LineComponent } from '../chart'; + +import { CountryComponent } from '.'; + +describe('CountryComponent', () => { + let component: CountryComponent; + let fixture: ComponentFixture; + + const configureTestBed = (): void => { + TestBed.configureTestingModule({ + imports: [CountryComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { params: of({ country: 'France' }) } + }, + { provide: APIService, useClass: MockAPIService } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }) + .overrideComponent(CountryComponent, { + remove: { imports: [LineComponent] }, + add: { imports: [MockLineComponent] } + }) + .compileComponents(); + }; + + let appRef: ApplicationRef; + + beforeEach(waitForAsync(() => { + configureTestBed(); + appRef = TestBed.get(ApplicationRef) as ApplicationRef; + })); + + beforeEach(() => { + const header = { + activeCountry: 'France', + countryTotalMap: { + France: { + total: 1, + code: 'FR' + } + } + }; + + appRef.components.push({ + header: header + } as unknown as ComponentRef); + + fixture = TestBed.createComponent(CountryComponent); + component = fixture.componentInstance; + + component.headerRef = header as unknown as ElementRef; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set the country data', () => { + const datum = { + date: new Date().toISOString(), + three_d: '111', + high_quality: '222', + total: '333', + label: 'last' + }; + component.countryData = { + FR: [datum] + }; + component.setCountryToParam('France'); + expect(component.latestCountryData).toEqual(datum); + }); + + it('should toggle the appendice', () => { + expect(component.appendiceExpanded).toBeFalsy(); + component.toggleAppendice(); + expect(component.appendiceExpanded).toBeTruthy(); + component.toggleAppendice(); + expect(component.appendiceExpanded).toBeFalsy(); + }); + + it('should toggle the column', () => { + expect(component.nextColToEnable()).toBeFalsy(); + expect(component.columnToEnable).toBeFalsy(); + expect(component.columnsEnabledCount).toEqual(3); + + component.toggleColumn(); + + expect(component.nextColToEnable()).toBeFalsy(); + expect(component.columnToEnable).toBeFalsy(); + expect(component.columnsEnabledCount).toEqual(3); + + component.toggleColumn(TargetFieldName.TOTAL); + + expect(component.nextColToEnable()).toEqual(TargetFieldName.TOTAL); + expect(component.columnToEnable).toEqual(TargetFieldName.TOTAL); + expect(component.columnsEnabledCount).toEqual(2); + }); + + it('should find the next column to enable', () => { + expect(component.nextColToEnable()).toBeFalsy(); + component.columnsEnabled[TargetFieldName.TOTAL] = false; + expect(component.nextColToEnable()).toEqual(TargetFieldName.TOTAL); + + component.columnsEnabled[TargetFieldName.HQ] = false; + expect(component.nextColToEnable()).toEqual(TargetFieldName.HQ); + + component.columnsEnabled[TargetFieldName.THREE_D] = false; + expect(component.nextColToEnable()).toEqual(TargetFieldName.THREE_D); + }); +}); diff --git a/src/app/country/country.component.ts b/src/app/country/country.component.ts new file mode 100644 index 00000000..8feae347 --- /dev/null +++ b/src/app/country/country.component.ts @@ -0,0 +1,213 @@ +import { + DatePipe, + DecimalPipe, + JsonPipe, + KeyValuePipe, + LowerCasePipe, + NgClass, + NgFor, + NgIf, + NgTemplateOutlet, + UpperCasePipe +} from '@angular/common'; +import { + ApplicationRef, + Component, + ElementRef, + inject, + Input, + ViewChild +} from '@angular/core'; +import { ActivatedRoute, RouterLink, RouterOutlet } from '@angular/router'; +import { combineLatest, map } from 'rxjs'; + +import { colours, externalLinks, ISOCountryCodes } from '../_data'; +import { + CountryTotalInfo, + DimensionName, + GeneralResultsFormatted, + IHash, + IHashArray, + TargetData, + TargetFieldName, + TargetMetaData +} from '../_models'; +import { APIService } from '../_services'; +import { + AbbreviateNumberPipe, + RenameApiFacetPipe, + RenameApiFacetShortPipe, + RenameCountryPipe, + RenameTargetTypePipe +} from '../_translate'; + +import { AppendiceSectionComponent } from '../appendice-section'; +import { BarComponent, LineComponent } from '../chart'; +import { LegendGridComponent } from '../legend-grid'; +import { ResizeComponent } from '../resize'; +import { SubscriptionManager } from '../subscription-manager'; +import { TruncateComponent } from '../truncate'; + +@Component({ + templateUrl: './country.component.html', + styleUrls: ['../landing/landing.component.scss', './country.component.scss'], + standalone: true, + imports: [ + AbbreviateNumberPipe, + AppendiceSectionComponent, + RouterOutlet, + DatePipe, + JsonPipe, + NgClass, + ResizeComponent, + NgIf, + NgFor, + TruncateComponent, + NgTemplateOutlet, + BarComponent, + KeyValuePipe, + LineComponent, + LegendGridComponent, + RouterLink, + UpperCasePipe, + LowerCasePipe, + DecimalPipe, + RenameCountryPipe, + RenameApiFacetPipe, + RenameApiFacetShortPipe, + RenameTargetTypePipe + ] +}) +export class CountryComponent extends SubscriptionManager { + public externalLinks = externalLinks; + public DimensionName = DimensionName; + public TargetFieldName = TargetFieldName; + public colours = colours; + + // Used to parameterise links to the data page + @Input() includeCTZero = false; + @ViewChild('lineChart') lineChart: LineComponent; + @ViewChild('legendGrid') legendGrid: LegendGridComponent; + + private readonly route = inject(ActivatedRoute); + private readonly api = inject(APIService); + public countryCodes = ISOCountryCodes; + + columnsEnabledCount = 3; + columnsEnabled: IHash = {}; + columnToEnable?: TargetFieldName; + + country: string; + countryCode: string; + countryLandingData: GeneralResultsFormatted = {}; + + targetMetaData: IHash>; + countryData: IHash> = {}; + countryTotalMap: IHash; + latestCountryData: TargetData; + appendiceExpanded = false; + + public headerRef: ElementRef; + + constructor(private applicationRef: ApplicationRef) { + super(); + + Object.values(TargetFieldName).forEach((key: string) => { + this.columnsEnabled[key] = true; + }); + + const rootRef = this.applicationRef.components[0].instance; + if (rootRef) { + this.headerRef = rootRef['header']; + } + + this.subs.push( + combineLatest([ + this.api.getTargetMetaData(), + this.api.getCountryData(), + this.route.params + ]) + .pipe( + map((results) => { + return { + targetMetaData: results[0], + countryData: results[1], + params: results[2] + }; + }) + ) + .subscribe({ + next: (combined) => { + this.targetMetaData = combined.targetMetaData; + this.countryData = combined.countryData; + const country = combined.params['country']; + this.setCountryToParam(country); + this.setCountryInHeaderMenu(country); + }, + error: (e: Error) => { + console.log(e); + } + }) + ); + + this.subs.push( + this.api + .getGeneralResultsCountry() + .subscribe((data: GeneralResultsFormatted) => { + this.countryLandingData = data; + }) + ); + } + + setCountryInHeaderMenu(country?: string): void { + (this.headerRef as unknown as { activeCountry: string }).activeCountry = + country; + } + + setCountryToParam(country: string): void { + this.country = country; + this.countryCode = ISOCountryCodes[this.country]; + + const specificCountryData = this.countryData[this.countryCode]; + if (specificCountryData.length) { + this.latestCountryData = + specificCountryData[specificCountryData.length - 1]; + } + } + + toggleAppendice(): void { + this.appendiceExpanded = !this.appendiceExpanded; + this.lineChart.toggleCursor(); + } + + /** nextColToEnable + * + * @return TargetFieldName + **/ + nextColToEnable(): TargetFieldName { + return Object.values(TargetFieldName).find((tfn: TargetFieldName) => { + return !this.columnsEnabled[tfn]; + }); + } + + /** toggleColumn + * + * @param { TargetFieldName } column + **/ + toggleColumn(column?: TargetFieldName): void { + column = column || this.nextColToEnable(); + this.columnsEnabled[column] = !this.columnsEnabled[column]; + + this.columnsEnabledCount = Object.values(TargetFieldName).filter( + (tfn: TargetFieldName) => { + return this.columnsEnabled[tfn]; + } + ).length; + + this.columnToEnable = this.nextColToEnable(); + } + + ngOnDestroy(): void { + this.setCountryInHeaderMenu(); + } +} diff --git a/src/app/country/index.ts b/src/app/country/index.ts new file mode 100644 index 00000000..3876df90 --- /dev/null +++ b/src/app/country/index.ts @@ -0,0 +1 @@ +export * from './country.component'; diff --git a/src/app/dates/dates.component.spec.ts b/src/app/dates/dates.component.spec.ts index 15f19840..d709f0b9 100644 --- a/src/app/dates/dates.component.spec.ts +++ b/src/app/dates/dates.component.spec.ts @@ -41,7 +41,6 @@ describe('DatesComponent', () => { } }, provideAnimations(), - // TODO: are these needed? MatDatepickerModule, MatFormFieldModule ] diff --git a/src/app/doc-arrows/doc-arrows.component.html b/src/app/doc-arrows/doc-arrows.component.html new file mode 100644 index 00000000..64cfb3e0 --- /dev/null +++ b/src/app/doc-arrows/doc-arrows.component.html @@ -0,0 +1,11 @@ +
+ +
+ + +
+
diff --git a/src/app/doc-arrows/doc-arrows.component.scss b/src/app/doc-arrows/doc-arrows.component.scss new file mode 100644 index 00000000..b3ca989d --- /dev/null +++ b/src/app/doc-arrows/doc-arrows.component.scss @@ -0,0 +1,139 @@ +.documentation { + $arrowHeadSize: 10px; + $sideIndent: 100px; + + font-size: 1.75em; + height: 100vh; + pointer-events: none; + position: fixed; + left: 0; + top: 0; + width: 100%; + z-index: 10; + + * { + pointer-events: all; + } + + input { + background-color: transparent; + border: 0; + border-radius: 0; + color: red; + font-weight: bold; + padding: 0; + outline: 0; + width: 1em; + text-align: center; + } + + .arrow { + display: flex; + position: absolute; + + // vertical + &.bottom, + &.top { + align-items: center; + flex-direction: column; + height: 5em; + left: 50%; + + .inner { + width: 100%; + height: 100%; + + position: relative; + + &::before, + &::after { + content: " "; + position: absolute; + background-color: red; + } + &::after { + left: 50%; + width: 2px; + height: 100%; + transform: translateX(-1px); + } + &::before { + border-radius: 50%; + bottom: 0; + height: $arrowHeadSize; + margin-left: 50%; + transform: translateX(-50%); + width: $arrowHeadSize; + } + } + } + + &.bottom { + flex-direction: column-reverse; + bottom: 50px; + top: auto; + + .inner { + &::before { + bottom: auto; + top: 0; + } + } + } + + &.top { + left: auto; + } + + // horizontal + + &.left, + &.right { + height: 2.5em; + line-height: 2.5em; + top: 50%; + width: 5em; + + .inner { + display: flex; + align-items: center; + position: relative; + width: 100%; + + &::before, + &::after { + content: " "; + background-color: red; + position: absolute; + } + &::after { + height: 2px; + width: 100%; + } + &::before { + border-radius: 50%; + height: $arrowHeadSize; + width: $arrowHeadSize; + } + } + } + + &.left { + left: $sideIndent; + right: auto; + + .inner { + flex-direction: row-reverse; + &::before { + left: auto; + } + } + } + + &.right { + flex-direction: row-reverse; + left: auto; + right: $sideIndent; + } + } +} diff --git a/src/app/doc-arrows/doc-arrows.component.spec.ts b/src/app/doc-arrows/doc-arrows.component.spec.ts new file mode 100644 index 00000000..7304a0d1 --- /dev/null +++ b/src/app/doc-arrows/doc-arrows.component.spec.ts @@ -0,0 +1,215 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ArrowType, DocArrowsComponent } from './doc-arrows.component'; + +describe('DocArrowsComponent', () => { + let component: DocArrowsComponent; + let fixture: ComponentFixture; + + const defaultIndents = { + bottom: '', + top: '', + side: '' + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [DocArrowsComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DocArrowsComponent); + component = fixture.componentInstance; + + defaultIndents.bottom = component.bottomIndent; + defaultIndents.top = component.topIndent; + defaultIndents.side = component.sideIndent; + + fixture.detectChanges(); + }); + + const getKeyEvent = ( + key: string, + shift = false, + arrowType: ArrowType = 'top', + ctrl = false + ): KeyboardEvent => { + return { + key: key, + // eslint-disable-next-line @typescript-eslint/no-empty-function + preventDefault: (): void => {}, + shiftKey: shift, + ctrlKey: ctrl, + // eslint-disable-next-line @typescript-eslint/no-empty-function + stopPropagation: (): void => {}, + target: { + closest: () => { + return { + style: {}, + classList: { + // eslint-disable-next-line @typescript-eslint/no-empty-function + add: (_: string): void => {}, + contains: (className: string): boolean => { + return className === arrowType; + }, + // eslint-disable-next-line @typescript-eslint/no-empty-function + remove: (_: string): void => {} + } + }; + } + } + } as unknown as KeyboardEvent; + }; + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should handle the up arrow event', () => { + expect(component.topIndent).toEqual(defaultIndents.top); + expect(component.bottomIndent).toEqual(defaultIndents.bottom); + + const customEvent = getKeyEvent('ArrowUp'); + delete ( + customEvent as unknown as { target?: { closest: () => HTMLElement } } + ).target; + component.arrowActiveKey(customEvent); + expect(component.topIndent).toEqual(defaultIndents.top); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (customEvent as unknown as { target: any }).target = { + closest: (): HTMLElement => { + return undefined as unknown as HTMLElement; + } + }; + component.arrowActiveKey(customEvent); + expect(component.topIndent).toEqual(defaultIndents.top); + + component.arrowActiveKey(getKeyEvent('ArrowUp')); + expect(component.topIndent).toEqual(defaultIndents.top); + + component.arrowActiveKey(getKeyEvent('ArrowUp', true, 'left')); + expect(component.topIndent).toEqual(defaultIndents.top); + + component.arrowActiveKey(getKeyEvent('ArrowUp', true, 'right')); + expect(component.topIndent).toEqual(defaultIndents.top); + + component.arrowActiveKey(getKeyEvent('ArrowUp', true)); + expect(component.topIndent).toEqual('0px'); + expect(component.bottomIndent).toEqual(defaultIndents.bottom); + + expect(component.arrowDefaults['bottom' as ArrowType].bottom).toEqual( + defaultIndents.bottom + ); + component.arrowActiveKey(getKeyEvent('ArrowUp', true, 'bottom')); + expect(component.arrowDefaults.bottom.bottom).not.toEqual( + defaultIndents.bottom + ); + }); + + it('should handle the left arrow event', () => { + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowLeft')); + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowLeft', true)); + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowLeft', false, 'left')); + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowLeft', false, 'left', true)); + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowLeft', true, 'left')); + expect(component.sideIndent).not.toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowLeft', true, 'right')); + expect(component.sideIndent).toEqual(defaultIndents.side); + }); + + it('should handle the down arrow event', () => { + expect(component.topIndent).toEqual(defaultIndents.top); + expect(component.bottomIndent).toEqual(defaultIndents.bottom); + + component.arrowActiveKey(getKeyEvent('ArrowDown')); + expect(component.topIndent).toEqual(defaultIndents.top); + + component.arrowActiveKey(getKeyEvent('ArrowDown', true, 'left')); + expect(component.topIndent).toEqual(defaultIndents.top); + + component.arrowActiveKey(getKeyEvent('ArrowDown', true)); + expect(component.topIndent).not.toEqual(defaultIndents.top); + expect(component.bottomIndent).toEqual(defaultIndents.bottom); + + component.arrowActiveKey(getKeyEvent('ArrowDown')); + expect(component.bottomIndent).toEqual(defaultIndents.bottom); + + component.arrowActiveKey(getKeyEvent('ArrowDown', false, 'bottom')); + expect(component.bottomIndent).toEqual(defaultIndents.bottom); + + component.arrowActiveKey(getKeyEvent('ArrowDown', true, 'bottom')); + expect(component.bottomIndent).not.toEqual(defaultIndents.bottom); + }); + + it('should handle the right arrow event', () => { + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowRight')); + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowRight', true)); + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowRight', false, 'left')); + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowRight', false, 'right')); + expect(component.sideIndent).toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowRight', true, 'left')); + expect(component.sideIndent).not.toEqual(defaultIndents.side); + + component.arrowActiveKey(getKeyEvent('ArrowRight', true, 'right')); + expect(component.sideIndent).toEqual(defaultIndents.side); + }); + + it('should handle rotation', () => { + spyOn(component, 'rotateArrow').and.callThrough(); + + component.arrowActiveKey(getKeyEvent('r', true, 'top', true)); + expect(component.rotateArrow).not.toHaveBeenCalled(); + + component.arrowActiveKey(getKeyEvent('r', true)); + expect(component.rotateArrow).toHaveBeenCalled(); + + component.arrowActiveKey(getKeyEvent('r', false)); + expect(component.rotateArrow).toHaveBeenCalledTimes(1); + + component.arrowActiveKey(getKeyEvent('R', false)); + expect(component.rotateArrow).toHaveBeenCalledTimes(1); + + component.arrowActiveKey(getKeyEvent('R', true)); + expect(component.rotateArrow).toHaveBeenCalledTimes(2); + + component.arrowActiveKey( + getKeyEvent('R', true, 'irrelevant-class' as ArrowType) + ); + expect(component.rotateArrow).toHaveBeenCalledTimes(3); + + component.arrowActiveKey(getKeyEvent('R', true, 'left')); + expect(component.rotateArrow).toHaveBeenCalledTimes(4); + }); + + it('should handle deletion', () => { + component.documentationArrows.push(1); + expect(component.documentationArrows.length).toEqual(2); + + component.arrowActiveKey(getKeyEvent('Delete')); + expect(component.documentationArrows.length).toEqual(1); + + component.arrowActiveKey(getKeyEvent('Delete')); + expect(component.documentationArrows.length).toEqual(1); + }); +}); diff --git a/src/app/doc-arrows/doc-arrows.component.ts b/src/app/doc-arrows/doc-arrows.component.ts new file mode 100644 index 00000000..591131c6 --- /dev/null +++ b/src/app/doc-arrows/doc-arrows.component.ts @@ -0,0 +1,299 @@ +import { CommonModule, Location } from '@angular/common'; +import { Component, inject, Renderer2, RendererFactory2 } from '@angular/core'; + +export type ArrowType = 'top' | 'right' | 'bottom' | 'left'; + +@Component({ + selector: 'app-arrow', + imports: [CommonModule], + standalone: true, + templateUrl: './doc-arrows.component.html', + styleUrls: ['./doc-arrows.component.scss'] +}) +export class DocArrowsComponent { + private readonly location = inject(Location); + private readonly rendererFactory = inject(RendererFactory2); + renderer: Renderer2; + + constructor() { + console.log('constructed arrow'); + this.renderer = this.rendererFactory.createRenderer(null, null); + this.location.go('/'); + } + + ngOnInit(): void { + console.log('in arrows'); + } + + public documentationArrows = [1]; + + sideIndent = '350px'; + topIndent = '50px'; + bottomIndent = '300px'; + + arrowDefaults = { + top: { + bottom: 'auto', + left: '50%', + right: 'auto', + top: this.topIndent, + height: '5em', + width: '2.5em' + }, + right: { + bottom: 'auto', + left: 'auto', + right: this.sideIndent, + top: '50%', + height: '2.5em', + width: '5em' + }, + bottom: { + bottom: this.bottomIndent, + left: '50%', + right: 'auto', + top: 'auto', + height: '5em', + width: '2.5em' + }, + left: { + bottom: 'auto', + left: this.sideIndent, + right: 'auto', + top: '50%', + height: '2.5em', + width: '5em' + } + }; + + rotateArrow(arrow: HTMLElement): void { + const arrowTypes: Array = ['top', 'right', 'bottom', 'left']; + + arrowTypes.every((arrowType: ArrowType, index: number) => { + if (arrow.classList.contains(arrowType)) { + const newArrowType = + arrowTypes[index + 1 >= arrowTypes.length ? 0 : index + 1]; + const defaults = this.arrowDefaults[newArrowType]; + + this.renderer.removeClass(arrow, arrowType); + this.renderer.addClass(arrow, newArrowType); + + this.renderer.setStyle(arrow, 'bottom', defaults.bottom); + this.renderer.setStyle(arrow, 'top', defaults.top); + this.renderer.setStyle(arrow, 'left', defaults.left); + this.renderer.setStyle(arrow, 'right', defaults.right); + this.renderer.setStyle(arrow, 'width', defaults.width); + this.renderer.setStyle(arrow, 'height', defaults.height); + + return false; + } + return true; + }); + } + + arrowActiveKey(event: KeyboardEvent): void { + const tgt = event.target as HTMLElement; + let arrow: HTMLElement; + + if (tgt) { + arrow = tgt.closest('.arrow') as HTMLElement; + if (!arrow) { + return; + } + } else { + return; + } + + // rotation + if (['r', 'R'].includes(event.key)) { + if (event.ctrlKey) { + event.stopPropagation(); + event.preventDefault(); + return; + } + if (event.shiftKey) { + event.stopPropagation(); + event.preventDefault(); + this.rotateArrow(arrow); + } + } + + // removal + if (['Backspace', 'Delete'].includes(event.key)) { + if (this.documentationArrows.length > 1) { + this.documentationArrows.pop(); + this.renderer.removeChild(arrow.parentNode, arrow); + return; + } + } // end removal + + // read arrow position + const arrowLeft = parseInt(arrow.style.left); + const arrowWidth = parseFloat(arrow.style.width); + const arrowHeight = parseFloat(arrow.style.height); + const arrowRight = parseInt(arrow.style.right); + const arrowTop = parseInt(arrow.style.top); + const arrowBottom = parseInt(arrow.style.bottom); + + let multiplier = 1; + + if (event.ctrlKey) { + multiplier = 10; + } + + if (event.key === 'ArrowLeft') { + const moveLeft = + arrow.classList.contains('top') || arrow.classList.contains('bottom'); + const shiftIndent = event.shiftKey; + + if (shiftIndent) { + if (!moveLeft) { + // side margin + let indent = parseInt(this.sideIndent); + if (arrow.classList.contains('left')) { + indent = indent - 50; + } else { + indent = indent + 50; + } + indent = Math.max(indent, 0); + this.sideIndent = `${indent}px`; + this.arrowDefaults.left.left = this.sideIndent; + this.arrowDefaults.right.right = this.sideIndent; + + if (arrow.classList.contains('right')) { + this.renderer.setStyle(arrow, 'right', `${arrowRight + 50}px`); + } else { + this.renderer.setStyle(arrow, 'left', `${arrowLeft - 50}px`); + } + return; + } + } + + if (moveLeft) { + this.renderer.setStyle(arrow, 'left', arrowLeft - multiplier + '%'); + } else { + let val = 0.1; + if (arrow.classList.contains('left')) { + val = val * -1; + } + this.renderer.setStyle( + arrow, + 'width', + arrowWidth + val * multiplier + 'em' + ); + } + } + if (event.key === 'ArrowRight') { + const moveRight = + arrow.classList.contains('top') || arrow.classList.contains('bottom'); + const shiftIndent = event.shiftKey; + if (shiftIndent) { + if (!moveRight) { + // side margin + let indent = parseInt(this.sideIndent); + if (arrow.classList.contains('left')) { + indent = indent + 50; + } else { + indent = indent - 50; + } + indent = Math.max(indent, 0); + this.sideIndent = `${indent}px`; + this.arrowDefaults.left.left = this.sideIndent; + this.arrowDefaults.right.right = this.sideIndent; + if (arrow.classList.contains('right')) { + this.renderer.setStyle(arrow, 'right', `${arrowRight - 50}px`); + } else { + this.renderer.setStyle(arrow, 'left', `${arrowLeft + 50}px`); + } + return; + } + } + + if (moveRight) { + this.renderer.setStyle(arrow, 'left', arrowLeft + multiplier + '%'); + } else { + let val = 0.1; + if (arrow.classList.contains('right')) { + val = val * -1; + } + this.renderer.setStyle( + arrow, + 'width', + arrowWidth + val * multiplier + 'em' + ); + } + } + if (event.key === 'ArrowUp') { + const moveUp = + arrow.classList.contains('left') || arrow.classList.contains('right'); + const shiftIndent = event.shiftKey; + + if (shiftIndent && !moveUp) { + // top / bottom margins + if (arrow.classList.contains('top')) { + this.topIndent = `${parseInt(this.topIndent) - 50}px`; + this.arrowDefaults.top.top = this.topIndent; + } else { + this.bottomIndent = `${parseInt(this.bottomIndent) + 50}px`; + this.arrowDefaults.bottom.bottom = this.bottomIndent; + } + + if (arrow.classList.contains('top')) { + this.renderer.setStyle(arrow, 'top', `${arrowTop - 50}px`); + } else { + this.renderer.setStyle(arrow, 'bottom', `${arrowBottom + 50}px`); + } + return; + } + if (moveUp) { + this.renderer.setStyle(arrow, 'top', arrowTop - multiplier + '%'); + } else { + let val = 0.1; + if (arrow.classList.contains('top')) { + val = val * -1; + } + this.renderer.setStyle( + arrow, + 'height', + arrowHeight + val * multiplier + 'em' + ); + } + } + if (event.key === 'ArrowDown') { + const moveDown = + arrow.classList.contains('left') || arrow.classList.contains('right'); + const shiftIndent = event.shiftKey; + + if (shiftIndent && !moveDown) { + // top / bottom margins + if (arrow.classList.contains('top')) { + this.topIndent = `${parseInt(this.topIndent) + 50}px`; + this.arrowDefaults.top.top = this.topIndent; + } else { + this.bottomIndent = `${parseInt(this.bottomIndent) - 50}px`; + this.arrowDefaults.bottom.bottom = this.bottomIndent; + } + + if (arrow.classList.contains('top')) { + this.renderer.setStyle(arrow, 'top', `${arrowTop + 50}px`); + } else { + this.renderer.setStyle(arrow, 'bottom', `${arrowBottom - 50}px`); + } + return; + } + if (moveDown) { + this.renderer.setStyle(arrow, 'top', arrowTop + multiplier + '%'); + } else { + let val = 0.1; + if (arrow.classList.contains('bottom')) { + val = val * -1; + } + this.renderer.setStyle( + arrow, + 'height', + arrowHeight + val * multiplier + 'em' + ); + } + } + } +} diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index d611c749..d0e8608b 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -14,6 +14,45 @@
dashboard + + + 2025 +
2030 +
+ + + + 2025 / 2030 Targets + @if(activeCountry) { - {{ activeCountry }} + } + + + + + + + {{ country.key }} + + + + +
diff --git a/src/app/header/header.component.scss b/src/app/header/header.component.scss index 9fb7f1e2..9ced09a3 100644 --- a/src/app/header/header.component.scss +++ b/src/app/header/header.component.scss @@ -6,6 +6,46 @@ src: url("/assets/fonts/chevin/ChevinMedium.otf") format("opentype"); } +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes spinIn { + from { + transform: rotateX(90deg); + } + to { + transform: rotateX(0deg); + } +} + +@keyframes spinOut { + from { + transform: rotateX(0deg); + } + to { + transform: rotateX(90deg); + } +} + +$targetAnimationDelay: 1250ms; +$targetAnimationTime: 1750ms; +$targetAnimationTimeSlow: 3750ms; + .header, .logo { display: flex; @@ -59,12 +99,102 @@ } } + .text, + & + .text { + font-family: Chevin-Medium, serif; + margin-left: 14px; + } + .text { display: flex; flex-direction: column; - font-family: Chevin-Medium, serif; font-size: 25px; line-height: 30px; - margin-left: 14px; } } + +.country-link { + animation: $targetAnimationTime spinOut $targetAnimationDelay forwards, + $targetAnimationTime fadeOut $targetAnimationDelay forwards; + color: $linkblue; + font-size: 20px; + font-weight: 900; + line-height: 30px; + margin-right: -30px; + + &:hover { + color: darken($textcolour_link, 15%); + } +} + +.active-country { + animation: $targetAnimationTime spinIn $targetAnimationDelay forwards, + $targetAnimationTime fadeIn $targetAnimationDelay forwards; + color: $linkblue; + cursor: pointer; + font-weight: 600; + opacity: 0; + padding-left: 0.4em; +} + +.target-menu-wrapper { + align-items: center; + display: flex; + flex-direction: row-reverse; +} + +.target-select { + background-color: #fff; + box-shadow: 0 2px 8px 0 rgba(26, 26, 26, 0.25); + border-width: 0; + display: grid; + + //font-size: 12px; + + grid-template-columns: 1fr 1fr 1fr 1fr; + height: 0; + left: 4px; + line-height: 18px; + opacity: 0; + overflow: hidden; + position: absolute; + transition: opacity 0.1s linear; + top: 24px; + z-index: 0; + + .target-select-item { + padding: 10px 16px; + } + + a { + //color: $near-black; + white-space: nowrap; + + &[disabled] { + //color: $gray-light; + cursor: default; + pointer-events: none; + font-weight: bold; + } + } +} + +.target-menu-wrapper:hover, +.target-menu-wrapper.open { + .active-country { + color: darken($textcolour_link, 15%); + } +} + +.target-menu-wrapper.open { + .target-select { + border-width: 1px; + opacity: 1; + height: auto; + } +} + +.target { + opacity: 0; + animation: $targetAnimationTimeSlow fadeIn $targetAnimationDelay forwards; +} diff --git a/src/app/header/header.component.spec.ts b/src/app/header/header.component.spec.ts new file mode 100644 index 00000000..87e9b53f --- /dev/null +++ b/src/app/header/header.component.spec.ts @@ -0,0 +1,59 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { HeaderComponent } from '.'; + +describe('HeaderComponent', () => { + let component: HeaderComponent; + let fixture: ComponentFixture; + + const configureTestBed = (): void => { + TestBed.configureTestingModule({ + imports: [HeaderComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + providers: [ + { + provide: ActivatedRoute, + useValue: {} + } + ] + }).compileComponents(); + }; + + beforeEach(waitForAsync(() => { + configureTestBed(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HeaderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should toggle the menu', () => { + const isDisabled = false; + const e = { + target: { + getAttribute: () => { + return isDisabled; + } + } as unknown as HTMLElement + }; + expect(component.menuIsOpen).toBeFalsy(); + component.toggleMenu(e); + expect(component.menuIsOpen).toBeTruthy(); + component.toggleMenu(e); + expect(component.menuIsOpen).toBeFalsy(); + }); + + it('should close the menu when activeCountry is set', () => { + component.menuIsOpen = true; + expect(component.menuIsOpen).toBeTruthy(); + component.activeCountry = 'France'; + expect(component.menuIsOpen).toBeFalsy(); + }); +}); diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index 37845e72..1200176f 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -1,18 +1,48 @@ import { Component, Input } from '@angular/core'; +import { NgIf } from '@angular/common'; import { FormGroup } from '@angular/forms'; import { CTZeroControlComponent } from '../ct-zero-control/ct-zero-control.component'; -import { NgIf } from '@angular/common'; +import { KeyValuePipe, NgClass, NgFor } from '@angular/common'; import { RouterLink } from '@angular/router'; +import { ClickAwareDirective } from '../_directives/click-aware/click-aware.directive'; +import { CountryTotalInfo, IHash } from '../_models'; @Component({ selector: 'app-header', templateUrl: './header.component.html', styleUrls: ['./header.component.scss'], standalone: true, - imports: [RouterLink, NgIf, CTZeroControlComponent] + imports: [ + ClickAwareDirective, + CTZeroControlComponent, + KeyValuePipe, + NgClass, + NgIf, + NgFor, + RouterLink + ] }) export class HeaderComponent { @Input() form?: FormGroup; @Input() includeCTZero: boolean; @Input() showPageTitle = false; + + countryTotalMap: IHash = {}; + menuIsOpen = false; + _activeCountry?: string; + + @Input() set activeCountry(activeCountry: string | undefined) { + this.menuIsOpen = false; + this._activeCountry = activeCountry; + } + + get activeCountry(): string | undefined { + return this._activeCountry; + } + + toggleMenu(e: { target: HTMLElement }): void { + if (!e.target.getAttribute('disabled')) { + this.menuIsOpen = !this.menuIsOpen; + } + } } diff --git a/src/app/landing/landing.component.html b/src/app/landing/landing.component.html index 8504af9e..ec1ccb82 100644 --- a/src/app/landing/landing.component.html +++ b/src/app/landing/landing.component.html @@ -42,7 +42,11 @@ - +
{{ dimension | renameApiFacet | uppercase }} @@ -51,7 +55,13 @@ >
+ @if (countryLink){ + + {{ row.name }} + + } @else { + } {{ row.value | number }} {{ row.percent }}%
@@ -148,8 +158,8 @@ Explore by providing countries + >Explore by providing countries
@@ -158,7 +168,7 @@
@@ -178,13 +188,13 @@ Explore by type of content + >Explore by type of content
Explore by data provider + >Explore by data provider
diff --git a/src/app/legend-grid/legend-grid.component.scss b/src/app/legend-grid/legend-grid.component.scss new file mode 100644 index 00000000..68bcd456 --- /dev/null +++ b/src/app/legend-grid/legend-grid.component.scss @@ -0,0 +1,274 @@ +@import "../../scss/_variables"; + +$disabled-colour: $gray-5; +$forced-space: 10px; +$timeTransitionRollDown: 0.25s; + +li { + list-style: none; +} + +.legend-grid-wrapper, +.legend-grid, +.legend-grid-inner { + font-size: 14px; + background: #fff; +} + +.legend-grid, +.legend-grid-inner { + display: grid; + + & > :not(.legend-grid-inner) { + padding: 0.6em; + } +} + +.bulleted { + list-style: disc; +} + +.indented { + margin-left: 1rem; + &::marker { + color: inherit; + } +} + +.legend-grid { + 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; + } + + .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-column: 2/5; + grid-template-columns: subgrid; + margin: 0; + column-gap: 4px; + & > * { + border-left: 1px solid $disabled-colour; + } +} + +.legend-grid-wrapper { + display: flex; + flex-direction: column; + margin-top: 4em; +} + +.legend-item-country-toggle { + position: relative; + + &.stick-left { + font-weight: bold; + } + a { + color: $gray-med; + display: flex; + position: relative; + margin: 0 0.4em 0 2em; + white-space: nowrap; + } +} + +.legend-item-series-toggle, +.legend-item-target-toggle { + color: $gray-med; + white-space: nowrap; + + &.rolled-up { + color: $disabled-colour; + } +} + +.legend-item-target-toggle { + font-size: 12px; + max-width: 8em; + min-width: 8em; + display: flex; + justify-content: space-between; +} + +.target-total { + flex-grow: 1; + color: #777; + font-weight: normal; + margin-left: 1.4em; + text-align: right; + + &.range-value { + font-style: italic; + margin-right: 0.4em; + } +} + +.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; + + * { + line-height: 1.6em; + } + + // legend marker + .marker { + border: 1px solid $disabled-colour; + content: ""; + display: block; + height: 12px; + margin-right: 0.4em; + width: 12px; + } +} + +// push scrollbar away from data +.pad-right { + padding-right: 0.6em; +} + +.top-pin { + background: #fff; + display: flex; + flex-direction: column-reverse; + margin-bottom: -20px; + transform: translateY(-20px); + + > ul { + //padding: 0.6em; + //border-left: 1px solid red; + } +} + +.perma-pin-left { + top: $forced-space; + position: relative; + + // hide content scrolling below visible in the gap above + &::before { + content: ""; + background: #fff; + display: block; + width: 100%; + position: absolute; + top: -$forced-space; + height: $forced-space; + } +} + +.perma-pin { + display: block; + height: 20px; + opacity: 0; + text-align: center; + + .x { + display: block; + margin: auto; + &::after, + &::before { + background: #777; + } + &:hover { + &::after, + &::before { + background: $gray-med; + } + } + } +} + +.perma-pin:hover, +ul:hover ~ .perma-pin { + opacity: 1; +} + +.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; + .legend-item-series-toggle { + font-weight: bold; + } +} + +.roll-down-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows $timeTransitionRollDown linear, + margin-bottom $timeTransitionRollDown linear; + + &:not(.is-open) > * { + grid-template-rows: 1fr; + } + + &.is-open { + grid-template-rows: 1fr; + } + + .roll-down { + overflow-y: hidden; + margin: 0.2em 0 0.3em 0; + } +} + +@media (min-width: $bp-desktop) { + .legend-grid-wrapper { + margin-top: -10px; + } +} diff --git a/src/app/legend-grid/legend-grid.component.spec.ts b/src/app/legend-grid/legend-grid.component.spec.ts new file mode 100644 index 00000000..7e90fed7 --- /dev/null +++ b/src/app/legend-grid/legend-grid.component.spec.ts @@ -0,0 +1,367 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync +} from '@angular/core/testing'; + +import * as am4charts from '@amcharts/amcharts4/charts'; + +import { MockLineComponent } from '../_mocked'; +import { TargetFieldName, TargetSeriesSuffixes } from '../_models'; +import { LineComponent } from '../chart'; +import { LegendGridComponent } from '.'; + +const mockTargetMetaData = { + DE: { + three_d: [], + high_quality: [] + }, + FR: { + three_d: [] + } +}; + +describe('LegendGridComponent', () => { + let component: LegendGridComponent; + let fixture: ComponentFixture; + + const configureTestBed = (): void => { + TestBed.configureTestingModule({ + imports: [LegendGridComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }) + .overrideComponent(LegendGridComponent, { + remove: { imports: [LineComponent] }, + add: { imports: [MockLineComponent] } + }) + .compileComponents(); + }; + + beforeEach(waitForAsync(() => { + configureTestBed(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LegendGridComponent); + component = fixture.componentInstance; + component.lineChart = new MockLineComponent() as unknown as LineComponent; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should show the series set', () => { + const showSpy = jasmine.createSpy(); + spyOn(component, 'togglePin'); + + component.hiddenSeriesSetData[0] = { + Italy: { + show: showSpy + } as unknown as am4charts.LineSeries + }; + + component.showSeriesSet(0); + expect(showSpy).toHaveBeenCalled(); + expect(component.togglePin).toHaveBeenCalled(); + }); + + it('should hide the series set', () => { + const hideSpy = jasmine.createSpy(); + + spyOn(component, 'togglePin'); + + component.hideSeriesSet(0); + + expect(hideSpy).not.toHaveBeenCalled(); + expect(component.togglePin).not.toHaveBeenCalled(); + + component.pinnedCountries['FR'] = 12; + + const setData = (indexes: Array): void => { + TargetSeriesSuffixes.forEach((suffix: string, suffixIndex: number) => { + component.lineChart.allSeriesData['FR' + suffix] = !indexes.includes( + suffixIndex + ) + ? undefined + : ({ + hide: hideSpy + } as unknown as am4charts.LineSeries); + }); + }; + setData([0, 1]); + + component.hideSeriesSet(0); + expect(hideSpy).toHaveBeenCalled(); + expect(component.togglePin).not.toHaveBeenCalled(); + + setData([0]); + component.hideSeriesSet(0); + expect(hideSpy).toHaveBeenCalledTimes(2); + expect(component.togglePin).toHaveBeenCalled(); + }); + + it('should get the enabled columns', () => { + expect(component.columnsEnabledCount).toEqual(3); + + component.columnEnabled3D = false; + expect(component.columnsEnabledCount).toEqual(2); + component.columnEnabledHQ = false; + expect(component.columnsEnabledCount).toEqual(1); + component.columnEnabledALL = false; + expect(component.columnsEnabledCount).toEqual(0); + + component.columnEnabled3D = true; + expect(component.columnsEnabledCount).toEqual(1); + component.columnEnabledHQ = true; + expect(component.columnsEnabledCount).toEqual(2); + component.columnEnabledALL = true; + expect(component.columnsEnabledCount).toEqual(3); + }); + + it('should get the country series', () => { + component.targetMetaData = mockTargetMetaData; + + expect(component.getCountrySeries('FR')).toBeTruthy(); + expect(component.getCountrySeries('FR').length).toBeGreaterThan(0); + }); + + it('should toggle the pin', () => { + component.targetMetaData = mockTargetMetaData; + component.pinnedCountries = { AU: 0, DE: 1, FR: 2 }; + + component.togglePin('AU'); + fixture.detectChanges(); + + expect(component.pinnedCountries['DE']).toEqual( + 0 * LegendGridComponent.itemHeight + ); + expect(component.pinnedCountries['FR']).toEqual( + 1 * LegendGridComponent.itemHeight + ); + expect(Object.keys(component.pinnedCountries).length).toEqual(2); + + component.togglePin('DE'); + + expect(component.pinnedCountries['FR']).toEqual(0); + expect(Object.keys(component.pinnedCountries).length).toEqual(1); + + // order check + + component.togglePin('DE'); + component.togglePin('AU'); + + let countries = Object.keys(component.pinnedCountries); + + expect(countries[0]).toEqual('FR'); + expect(countries[1]).toEqual('DE'); + expect(countries[2]).toEqual('AU'); + + const sortSequence = ['TOP', 'DE', 'AU', 'FR']; + + component.togglePin('TOP', false, sortSequence); + countries = Object.keys(component.pinnedCountries); + + sortSequence.forEach((item: string, index: number) => { + expect(countries[index]).toEqual(item); + }); + }); + + it('should handle scrolling', () => { + spyOn(component.legendGrid.nativeElement.classList, 'toggle'); + component.gridScroll(); + expect( + component.legendGrid.nativeElement.classList.toggle + ).toHaveBeenCalled(); + }); + + it('should hide ranges by column', () => { + component.targetMetaData = mockTargetMetaData; + component.countryCode = 'FR'; + component.pinnedCountries = { FR: 0 }; + expect(Object.keys(component.hiddenColumnRanges).length).toBeFalsy(); + component.hideRangesByColumn(TargetFieldName.THREE_D); + expect(Object.keys(component.hiddenColumnRanges).length).toBeTruthy(); + }); + + it('should show ranges by column', () => { + component.targetMetaData = mockTargetMetaData; + component.countryCode = 'FR'; + component.pinnedCountries = { FR: 0 }; + component.hiddenColumnRanges = { THREE_D: { FR: [0] } }; + component.lineChart.allSeriesData['FR' + '3D'] = { + fill: 'xxx', + hide: jasmine.createSpy() + } as unknown as am4charts.LineSeries; + component.showHiddenRangesByColumn(TargetFieldName.THREE_D); + expect(Object.keys(component.hiddenColumnRanges).length).toBeFalsy(); + }); + + it('should toggle the range', () => { + const colour = component.lineChart.chart.colors.list[0]; + + spyOn(component.lineChart, 'showRange'); + spyOn(component.lineChart, 'removeRange'); + + component.toggleRange('FR', TargetFieldName.THREE_D, 0); + + expect(component.lineChart.removeRange).toHaveBeenCalled(); + expect(component.lineChart.showRange).not.toHaveBeenCalled(); + + component.toggleRange('FR', TargetFieldName.THREE_D, 0, colour); + + expect(component.lineChart.showRange).toHaveBeenCalled(); + }); + + it('should addSeriesSetAndPin', () => { + component.targetMetaData = mockTargetMetaData; + spyOn(component.lineChart, 'addSeries'); + component.addSeriesSetAndPin( + 'FR', + mockTargetMetaData['FR'][TargetFieldName.THREE_D] + ); + expect(component.lineChart.addSeries).toHaveBeenCalledTimes(3); + }); + + it('should toggle the country', () => { + component.targetMetaData = mockTargetMetaData; + + spyOn(component, 'togglePin'); + spyOn(component, 'addSeriesSetAndPin').and.callFake(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + }); + + component.toggleCountry('FR'); + expect(component.togglePin).toHaveBeenCalled(); + + component.toggleCountry('FR'); + expect(component.togglePin).toHaveBeenCalledTimes(2); + + component.toggleCountry('DE'); + expect(component.togglePin).toHaveBeenCalledTimes(3); + + expect(component.addSeriesSetAndPin).not.toHaveBeenCalled(); + + spyOn(component, 'getCountrySeries').and.callFake(() => { + return []; + }); + component.toggleCountry('DE'); + + expect(component.togglePin).toHaveBeenCalledTimes(3); + expect(component.addSeriesSetAndPin).toHaveBeenCalled(); + }); + + it('should toggle the series', () => { + const seriesItemHidden = { + isHidden: true, + show: () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + }, + hide: () => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + } + } as unknown as am4charts.LineSeries; + + const seriesItemShowing = { + ...seriesItemHidden, + isHidden: false + } as unknown as am4charts.LineSeries; + + const seriesArray = [seriesItemShowing]; + + spyOn(component, 'getCountrySeries').and.callFake((_) => { + return seriesArray; + }); + spyOn(component, 'togglePin'); + spyOn(component.lineChart, 'addSeries'); + + component.pinnedCountries['DE'] = 1; + + component.toggleSeries('DE', TargetFieldName.THREE_D); + + expect(component.lineChart.addSeries).toHaveBeenCalled(); + expect(component.togglePin).not.toHaveBeenCalled(); + + // swap the fake series for the hidden fake series + seriesArray.pop(); + seriesArray.push(seriesItemHidden); + + component.toggleSeries('DE', TargetFieldName.THREE_D); + expect(component.togglePin).toHaveBeenCalled(); + + // supply the series parameter + spyOn(seriesItemShowing, 'hide'); + spyOn(seriesItemHidden, 'show'); + + component.toggleSeries('DE', TargetFieldName.THREE_D, seriesItemShowing); + + expect(seriesItemShowing.hide).toHaveBeenCalled(); + expect(component.togglePin).toHaveBeenCalledTimes(1); + + component.toggleSeries('FR', TargetFieldName.THREE_D, seriesItemHidden); + expect(seriesItemHidden.show).toHaveBeenCalled(); + expect(component.togglePin).toHaveBeenCalledTimes(2); + }); + + it('should call toggleCountry when the countryCode is set', fakeAsync(() => { + component.targetMetaData = mockTargetMetaData; + + spyOn(component, 'toggleCountry').and.callFake(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + }); + + // set initial code and a pinned country + component.countryCode = 'FR'; + component.pinnedCountries = { FR: 0 }; + + expect(component.toggleCountry).not.toHaveBeenCalled(); + tick(0); + expect(component.toggleCountry).toHaveBeenCalled(); + + tick(component.timeoutAnimation); + expect(component.toggleCountry).toHaveBeenCalledTimes(1); + component.pinnedCountries = { FR: 0 }; + + // set again + component.countryCode = 'DE'; + + expect(component.toggleCountry).toHaveBeenCalledTimes(2); + tick(component.timeoutAnimation); + expect(component.toggleCountry).toHaveBeenCalledTimes(3); + tick(component.timeoutAnimation); + expect(component.toggleCountry).toHaveBeenCalledTimes(3); + + // set again + component.countryCode = 'FR'; + expect(component.toggleCountry).toHaveBeenCalledTimes(4); + tick(component.timeoutAnimation); + expect(component.toggleCountry).toHaveBeenCalledTimes(5); + tick(component.timeoutAnimation); + expect(component.toggleCountry).toHaveBeenCalledTimes(5); + + component.countryCode = undefined; + tick(component.timeoutAnimation); + expect(component.countryCode).toBeFalsy(); + })); + + it('should sort the pins', () => { + const desiredOrder = ['NL', 'IT']; + const testArray = ['ES', 'IT', 'CH', 'NL']; + + component.sortPins(testArray, desiredOrder); + + expect(testArray[0]).toEqual('NL'); + expect(testArray[1]).toEqual('IT'); + expect(testArray[testArray.length - 1]).toEqual('CH'); + }); + + it('should fire the unpin event', () => { + spyOn(component.unpinColumn, 'emit'); + component.fireUnpinColumn(TargetFieldName.THREE_D); + expect(component.unpinColumn.emit).toHaveBeenCalled(); + }); +}); diff --git a/src/app/legend-grid/legend-grid.component.ts b/src/app/legend-grid/legend-grid.component.ts new file mode 100644 index 00000000..f01d5dc3 --- /dev/null +++ b/src/app/legend-grid/legend-grid.component.ts @@ -0,0 +1,535 @@ +import { + DatePipe, + DecimalPipe, + JsonPipe, + KeyValuePipe, + NgClass, + NgFor, + NgIf, + NgStyle, + NgTemplateOutlet +} from '@angular/common'; +import { + Component, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, + EventEmitter, + Input, + Output, + ViewChild +} from '@angular/core'; +import * as am4charts from '@amcharts/amcharts4/charts'; +import * as am4core from '@amcharts/amcharts4/core'; + +import { + IHash, + IHashArray, + TargetData, + TargetFieldName, + TargetMetaData, + TargetSeriesSuffixes +} from '../_models'; +import { RenameCountryPipe, RenameTargetTypePipe } from '../_translate'; +import { LineComponent } from '../chart'; + +@Component({ + selector: 'app-legend-grid', + templateUrl: './legend-grid.component.html', + schemas: [CUSTOM_ELEMENTS_SCHEMA], + styleUrls: ['./legend-grid.component.scss'], + standalone: true, + imports: [ + DatePipe, + DecimalPipe, + JsonPipe, + NgClass, + NgIf, + NgFor, + NgTemplateOutlet, + NgStyle, + KeyValuePipe, + LineComponent, + RenameCountryPipe, + RenameTargetTypePipe + ] +}) +export class LegendGridComponent { + targetCountries: Array; + targetCountriesOO: Array; + timeoutAnimation = 800; + static itemHeight = 84.5; + + _columnEnabled3D = true; + _columnEnabledHQ = true; + _columnEnabledALL = true; + columnsEnabledCount = 3; + + @Input() set columnEnabled3D(value: boolean) { + if (value) { + this.showSeriesSet(0); + this.showHiddenRangesByColumn(TargetFieldName.THREE_D); + } else { + this.hideRangesByColumn(TargetFieldName.THREE_D); + this.hideSeriesSet(0); + } + this._columnEnabled3D = value; + this.calculateColumnsEnabledCount(); + } + + get columnEnabled3D(): boolean { + return this._columnEnabled3D; + } + + @Input() set columnEnabledHQ(value: boolean) { + if (value) { + this.showSeriesSet(1); + this.showHiddenRangesByColumn(TargetFieldName.HQ); + } else { + this.hideRangesByColumn(TargetFieldName.HQ); + this.hideSeriesSet(1); + } + this._columnEnabledHQ = value; + this.calculateColumnsEnabledCount(); + } + + get columnEnabledHQ(): boolean { + return this._columnEnabledHQ; + } + + @Input() set columnEnabledALL(value: boolean) { + if (value) { + this.showSeriesSet(2); + this.showHiddenRangesByColumn(TargetFieldName.TOTAL); + } else { + this.hideRangesByColumn(TargetFieldName.TOTAL); + this.hideSeriesSet(2); + } + this._columnEnabledALL = value; + this.calculateColumnsEnabledCount(); + } + + get columnEnabledALL(): boolean { + return this._columnEnabledALL; + } + + _countryCode: string; + _targetMetaData: IHash>; + + @Input() set countryCode(countryCode: string) { + if (this.lineChart) { + this.lineChart.chart.colors.reset(); + } + + let timeout = 0; + + // remove old chart lines + + if (this._countryCode) { + const pinned = Object.keys(this.pinnedCountries); + if (pinned.length) { + timeout = this.timeoutAnimation; + } + pinned.forEach((countryCode: string) => { + this.toggleCountry(countryCode); + }); + } + + // set new country + + this._countryCode = countryCode; + setTimeout(() => { + this.toggleCountry(this.countryCode); + this.toggleRange( + this.countryCode, + TargetFieldName.THREE_D, + 0, + this.lineChart.chart.colors.getIndex(0) + ); + }, timeout); + } + + get countryCode(): string { + return this._countryCode; + } + + @Input() set targetMetaData(data: IHash>) { + this._targetMetaData = data; + this.targetCountries = Object.keys(data); + this.targetCountriesOO = Object.keys(data); + } + get targetMetaData(): IHash> { + return this._targetMetaData; + } + + @Input() countryData: IHash> = {}; + @Input() lineChart: LineComponent; + + @Output() unpinColumn: EventEmitter = new EventEmitter(); + @ViewChild('legendGrid') legendGrid: ElementRef; + + // country names mapped to pin offset + pinnedCountries: IHash = {}; + hiddenColumnRanges: IHash>> = {}; + hiddenColumnPinData: Array> = [[], [], []]; + hiddenSeriesSetData: Array> = [{}, {}, {}]; + + public TargetSeriesSuffixes = TargetSeriesSuffixes; + public seriesSuffixesFmt = [' (3D)', ' (hq)', ' (total)']; + public seriesValueNames = Object.keys(TargetFieldName); + public TargetFieldName = TargetFieldName; + + calculateColumnsEnabledCount(): void { + this.columnsEnabledCount = [ + this.columnEnabled3D, + this.columnEnabledHQ, + this.columnEnabledALL + ].filter((val: boolean) => !!val).length; + } + + /** getCountrySeries + * @param { string } country + * @returns country mapped to the series suffix + **/ + getCountrySeries(country: string): Array { + const res = TargetSeriesSuffixes.map((seriesSuffix: string) => { + return this.lineChart.allSeriesData[`${country}${seriesSuffix}`]; + }).filter((x) => { + return x; + }); + return res; + } + + /** sortPins + * sorts string array based on order of strings in separate array + * @param { Array } strings + * @param { Array } desiredOrder + **/ + sortPins(strings: Array, desiredOrder: Array): void { + desiredOrder = structuredClone(desiredOrder).reverse(); + strings.sort((a: string, b: string) => { + const indexA = desiredOrder.indexOf(a); + const indexB = desiredOrder.indexOf(b); + if (indexA < indexB) { + return 1; + } + if (indexA > indexB) { + return -1; + } + return 0; + }); + } + + /** showSeriesSet + * + * - calls show() on the series referenced in hiddenCountrySeries + * - optionally pins the country associated with the series + **/ + showSeriesSet(colIndex: number): void { + const seriesSet = this.hiddenSeriesSetData[colIndex]; + const pinOrder = this.hiddenColumnPinData[colIndex]; + + // re-enable pins + Object.keys(seriesSet).forEach((country: string) => { + seriesSet[country].show(); + if (!(country in this.pinnedCountries)) { + this.togglePin(country, false, pinOrder); + } + }); + + this.hiddenSeriesSetData[colIndex] = {}; + this.hiddenColumnPinData[colIndex] = []; + } + + /** hideSeriesSet + * + * - stores the country/object in hiddenCountrySeries + * - optionally unpins the series' associated country + * + * @param { TargetFieldName } setType - the type to hide + **/ + hideSeriesSet(colIndex: number): void { + const countries = Object.keys(this.pinnedCountries); + + countries.forEach((country: string) => { + const countrySeriesKeys = TargetSeriesSuffixes.map((suffix: string) => { + return `${country}${suffix}`; + }); + + const countrySeriesObjects = countrySeriesKeys.map((key: string) => { + return this.lineChart.allSeriesData[key]; + }); + + const targetSeries = countrySeriesObjects[colIndex]; + + if (targetSeries && !targetSeries.isHidden) { + targetSeries.hide(); + this.hiddenSeriesSetData[colIndex][country] = targetSeries; + if ( + countrySeriesObjects.filter((item) => { + return item && !item.isHidden; + }).length === 1 + ) { + this.hiddenColumnPinData[colIndex] = structuredClone(countries); + this.togglePin(country, false); + } + } + }); + } + + hideRangesByColumn(column?: TargetFieldName): void { + const all = this.hiddenColumnRanges; + Object.keys(this.pinnedCountries).forEach((country: string) => { + const removed = this.lineChart.removeRange(country, column); + Object.keys(removed).forEach((key: string) => { + all[key] = Object.assign(all[key] ? all[key] : {}, removed[key]); + }); + }); + } + + showHiddenRangesByColumn(column?: TargetFieldName): void { + const hidden = this.hiddenColumnRanges; + + Object.keys(hidden) + .filter((key: string) => { + return column ? TargetFieldName[key] === column : true; + }) + .forEach((targetFieldName: string) => { + Object.keys(hidden[targetFieldName]).forEach((country: string) => { + // get the range's colour + const colour = this.lineChart.allSeriesData[ + country + + TargetSeriesSuffixes[ + this.seriesValueNames.indexOf(targetFieldName) + ] + ].fill as am4core.Color; + + hidden[targetFieldName][country].forEach((index: number) => { + this.lineChart.showRange( + country, + TargetFieldName[targetFieldName], + index, + colour + ); + }); + }); + delete hidden[targetFieldName]; + }); + } + + /** resetChartColors + * aligns the chart's internal color index with the visible series count + **/ + resetChartColors(countryPinIndex?: number, seriesIndex?: number): void { + if (countryPinIndex === undefined) { + countryPinIndex = Object.keys(this.pinnedCountries).length; + } + const skips = countryPinIndex * 3 + (seriesIndex || 0); + if (skips) { + this.lineChart.chart.colors.reset(); + for (let i = 0; i < skips; i++) { + this.lineChart.chart.colors.next(); + } + } + } + + /** toggleCountry + * shows existing (hidden) data or loads and creates series + * @param { string } country - the target series + * @return boolean + **/ + 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: am4charts.LineSeries) => { + return !series.isHidden; + }).length > 0; + + if (hasVisible) { + countrySeries.forEach((series: am4charts.LineSeries) => { + series.hide(); + }); + // remove associated ranges + this.lineChart.removeRange(country); + this.togglePin(country); + } else { + countrySeries.forEach((series: am4charts.LineSeries) => { + series.show(); + }); + this.togglePin(country); + } + } + } + + addSeriesSetAndPin(country: string, data: Array): void { + this.resetChartColors(); + + // add pin + this.togglePin(country); + + // add relevant series + [this.columnEnabled3D, this.columnEnabledHQ, this.columnEnabledALL].forEach( + (colEnabled: boolean, i: number) => { + if (colEnabled) { + this.lineChart.addSeries( + country + this.seriesSuffixesFmt[i], + country + TargetSeriesSuffixes[i], + TargetFieldName[this.seriesValueNames[i]], + data + ); + } + } + ); + } + + toggleRange( + country: string, + type: TargetFieldName, + index: number, + colour?: am4core.Color + ): void { + if (colour) { + this.lineChart.showRange(country, type, index, colour); + } else { + 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 + * @param { boolean } purgePinData - flag pin data deletion + **/ + togglePin( + country: string, + purgePinData = true, + reorder?: Array + ): void { + if (country in this.pinnedCountries) { + delete this.pinnedCountries[country]; + + if (purgePinData) { + [0, 1, 2].forEach((colIndex: number) => { + const pinOrder = this.hiddenColumnPinData[colIndex]; + if (pinOrder) { + this.hiddenColumnPinData[colIndex] = pinOrder.filter( + (elem) => elem !== country + ); + delete this.hiddenSeriesSetData[colIndex][country]; + } + }); + } + } else { + this.pinnedCountries[country] = 1; + } + + // re-assign pin keys + if (reorder) { + const sortTarget = structuredClone(Object.keys(this.pinnedCountries)); + this.sortPins(sortTarget, reorder); + this.pinnedCountries = sortTarget.reduce( + (res: IHash, item: string) => { + res[item] = 0; + return res; + }, + {} + ); + } + + // re-assign pin values + Object.keys(this.pinnedCountries).forEach((key: string, i: number) => { + this.pinnedCountries[key] = i * LegendGridComponent.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); + }) + ); + } + + 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 } + * @param { string } + * @param { LineSeries } + **/ + toggleSeries( + country: string, + type: TargetFieldName, + series?: am4charts.LineSeries + ): void { + const typeIndex = Object.values(TargetFieldName).indexOf(type); + + if (!series) { + // create from existing data + + const visibleSiblingCount = this.getCountrySeries(country).filter( + (series: am4charts.LineSeries) => { + return !series.isHidden; + } + ).length; + + let countryPinIndex: number = undefined; + + if (visibleSiblingCount > 0) { + countryPinIndex = Object.keys(this.pinnedCountries).indexOf(country); + } + this.resetChartColors(countryPinIndex, typeIndex); + + this.lineChart.addSeries( + country + this.seriesSuffixesFmt[typeIndex], + country + TargetSeriesSuffixes[typeIndex], + TargetFieldName[this.seriesValueNames[typeIndex]], + this.countryData[country] + ); + + if (visibleSiblingCount === 0) { + this.togglePin(country); + } + } else 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; + TargetSeriesSuffixes.forEach((suffix: string) => { + const sd = this.lineChart.allSeriesData[country + suffix]; + if (sd && !sd.isHidden) { + visCount += 1; + } + }); + // we can unpin (it will be 0 on animation completion) + if (visCount === 1) { + this.togglePin(country); + } + } + } + + /** fireUnpinColumn + * + * invokes emit unpinColumn + * @param { TargetFieldName } column + ***/ + fireUnpinColumn(column: TargetFieldName): void { + this.unpinColumn.emit(column); + } +} diff --git a/src/app/overview/overview.component.html b/src/app/overview/overview.component.html index bb8d3c1a..7fdbeb34 100644 --- a/src/app/overview/overview.component.html +++ b/src/app/overview/overview.component.html @@ -268,10 +268,27 @@

+ + + + +
+ + + No results found!
+ + + + + +
diff --git a/src/app/overview/overview.component.scss b/src/app/overview/overview.component.scss index 0af48150..98eb24b9 100644 --- a/src/app/overview/overview.component.scss +++ b/src/app/overview/overview.component.scss @@ -331,7 +331,7 @@ label { .no-data { font-size: 24px; - ul { + ul:not(.target-links) { font-size: 14px; list-style: disc; margin: 12px 0 0 20px; @@ -379,6 +379,55 @@ label { } } +.target-links { + display: flex; + flex-direction: column; + position: absolute; + right: 12px; + top: 12px; + list-style: none; + + &:hover { + .target-link { + opacity: 1; + } + } + + .target-link { + align-items: center; + display: flex; + padding: 0 0 0 1em; + flex-direction: row-reverse; + justify-content: flex-start; + + opacity: 0; + + :last-child { + margin-right: 0.4rem; + //opacity: 0; + transition: opacity 0.3s linear; + } + + :last-child:hover, + :first-child:hover + :last-child { + //opacity: 1; + // background-color: red;// #fff; + } + } + + .target { + opacity: 0; + } + + > :first-child > :first-child { + opacity: 1; + + .target { + opacity: 1; + } + } +} + @media (min-width: $bp-xl) { .statistics-dashboard { flex-direction: row; diff --git a/src/app/overview/overview.component.ts b/src/app/overview/overview.component.ts index 3e01f74b..43ed3dad 100644 --- a/src/app/overview/overview.component.ts +++ b/src/app/overview/overview.component.ts @@ -6,7 +6,14 @@ import { TemplateRef, ViewChild } from '@angular/core'; -import { DatePipe, formatDate, NgClass, NgFor, NgIf } from '@angular/common'; +import { + DatePipe, + formatDate, + NgClass, + NgFor, + NgIf, + NgTemplateOutlet +} from '@angular/common'; import { FormControl, FormGroup, @@ -87,6 +94,7 @@ import { ResizeComponent } from '../resize/resize.component'; CTZeroControlComponent, NgIf, NgFor, + NgTemplateOutlet, FilterComponent, IsScrollableDirective, CheckboxComponent, @@ -178,6 +186,7 @@ export class OverviewComponent extends SubscriptionManager implements OnInit { queryParamsRaw: Params = {}; dataServerData: BreakdownResults; + targetLinkAvailable = false; /** * constructor @@ -278,6 +287,21 @@ export class OverviewComponent extends SubscriptionManager implements OnInit { const params = combined.params; const queryParams = combined.queryParams; + this.targetLinkAvailable = + Object.keys(queryParams).length === 2 && + !!queryParams['country'] && + `${queryParams['type']}` === '3D' && + queryParams['type'].length === 1; + + if (!this.targetLinkAvailable) { + this.targetLinkAvailable = + !!queryParams['country'] && + !!queryParams['metadataTier'] && + queryParams['metadataTier'].indexOf('0') === -1 && + !!queryParams['contentTier'] && + queryParams['contentTier'].indexOf('1') === -1; + } + // checkbox representation of (split) datasetId this.form.addControl('datasetIds', this.fb.group({})); diff --git a/src/app/snapshots/snapshots.component.scss b/src/app/snapshots/snapshots.component.scss index 560be39b..0f5e4174 100644 --- a/src/app/snapshots/snapshots.component.scss +++ b/src/app/snapshots/snapshots.component.scss @@ -57,33 +57,3 @@ flex-direction: column; } } - -.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 { - opacity: 0; -} - -.saved { - opacity: 1; -} 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/locale/messages.en-GB.xlf b/src/locale/messages.en-GB.xlf index 092db5df..bd325fbe 100644 --- a/src/locale/messages.en-GB.xlf +++ b/src/locale/messages.en-GB.xlf @@ -62,28 +62,28 @@ EDM Documentation src/app/_data/static-data.ts - 100 + 96 Tier Documentation src/app/_data/static-data.ts - 104 + 100 Provider Documentation src/app/_data/static-data.ts - 108 + 104 Rights Documentation src/app/_data/static-data.ts - 112 + 108 @@ -184,6 +184,181 @@ 62 + + Target Data + + src/app/country/country.component.html + 3 + + + + Data + + src/app/country/country.component.html + 63,65 + + landing chart summary percentage + + + Total records count + + src/app/country/country.component.html + 87 + + landing chart summary percentage + + + Records + + src/app/country/country.component.html + 93 + + landing chart summary percentage + + + Corresponding percentage + + src/app/country/country.component.html + 120,122 + + landing chart summary percentage + + + Percent + + src/app/country/country.component.html + 128,130 + + landing chart summary + percentage + + + RECORDS PROVIDED + + src/app/country/country.component.html + 178 + + landing chart header + + + 3D Data + + src/app/country/country.component.html + 211 + + + + View (3D data) by content tier + + src/app/country/country.component.html + 269 + + + + HQ Data + + src/app/country/country.component.html + 284 + + + + View (HQ data) by type + + src/app/country/country.component.html + 343 + + + + All Data + + src/app/country/country.component.html + 357 + + + + View all data + + src/app/country/country.component.html + 412 + + + + Countries By Target Progress + + src/app/country/country.component.html + 425 + + + + Hide data + + src/app/country/country.component.html + 448 + + + + Show data + + src/app/country/country.component.html + 451 + + + + Explore by type of content + + src/app/country/country.component.html + 476 + + + + View by type + + src/app/country/country.component.html + 509 + + + + Explore by rights category + + src/app/country/country.component.html + 515 + + + + View by rights category + + src/app/country/country.component.html + 544 + + + + Explore by data provider + + src/app/country/country.component.html + 553 + + + + View by data provider + + src/app/country/country.component.html + 576 + + + + Explore by provider + + src/app/country/country.component.html + 582 + + + + View by provider + + src/app/country/country.component.html + 611 + + Enable content tier 0 @@ -339,7 +514,7 @@ Europeana Website src/app/footer/footer.component.html - 10 + 19 Footer link text @@ -347,7 +522,7 @@ Europeana Professional src/app/footer/footer.component.html - 19 + 28 Footer link text @@ -355,38 +530,39 @@ Europeana API src/app/footer/footer.component.html - 28 + 37 Footer link text - - Privacy Policy + + Cookies Policy src/app/footer/footer.component.html - 46 + 43 Footer link text - - Cookies Policy + + Privacy Policy + + src/app/footer/footer.component.html + 51 + Footer link text Privacy Settings - - - Co-financed by the Connecting Europe Facility of the European Union src/app/footer/footer.component.html - 54,55 + 57 - Footer flag text 1 + Footer link text Europeana is an initiative of the European Union, financed by the European Union's Connecting Europe Facility and European Union Member States. The Europeana services, including this website, are operated by a consortium led by the Europeana Foundation under a service contract with the European Commission. src/app/footer/footer.component.html - 58,64 + 64,70 Footer legal text 1 @@ -394,7 +570,7 @@ The European Commission does not guarantee the accuracy of the information and accepts no responsibility or liability whatsoever with regard to the information on this website. Neither the European Commission, nor any person acting on the European Commission's behalf, is responsible or liable for the accuracy or use of the information on this website. src/app/footer/footer.component.html - 65,71 + 71,77 Footer legal text 2 @@ -469,35 +645,43 @@ Records by src/app/grid/grid.component.ts - 52 + 76 Count src/app/grid/grid.component.ts - 53 + 77 Percent src/app/grid/grid.component.ts - 54 + 78 View in Europeana src/app/grid/grid.component.ts - 55 + 79 + + + + 2025 / 2030 Targets + + src/app/header/header.component.html + 31 + header nav opener Europeana complete dataset src/app/header/header.component.html - 20,22 + 59,61 landing page title @@ -529,7 +713,7 @@ RECORDS PROVIDED src/app/landing/landing.component.html - 50 + 54 landing data header @@ -543,7 +727,7 @@ >"/> src/app/landing/landing.component.html - 70,76 + 80,86 landing section title edm @@ -551,7 +735,7 @@ View by content tier src/app/landing/landing.component.html - 99 + 109 landing section link edm @@ -565,7 +749,7 @@ >"/> src/app/landing/landing.component.html - 110,116 + 120,126 landing section title metadata @@ -573,16 +757,15 @@ View by metadata tier src/app/landing/landing.component.html - 138 + 148 landing section link metadata - Explore by providing countries + Explore by providing countries src/app/landing/landing.component.html - 151,152 + 161 landing section title countries @@ -590,16 +773,15 @@ View by countries src/app/landing/landing.component.html - 171 + 181 landing section link countries - Explore by type of content + Explore by type of content src/app/landing/landing.component.html - 181,182 + 191 landing section title type @@ -607,7 +789,7 @@ View by type src/app/landing/landing.component.html - 201 + 211 landing section link type @@ -621,7 +803,7 @@ >"/> src/app/landing/landing.component.html - 209,215 + 219,225 landing section title rights @@ -629,16 +811,15 @@ View by rights category src/app/landing/landing.component.html - 233 + 243 landing section link rights - Explore by data provider + Explore by data provider src/app/landing/landing.component.html - 245,246 + 255 landing section title data provider @@ -646,7 +827,7 @@ View by data provider src/app/landing/landing.component.html - 265 + 275 landing section link data provider @@ -660,7 +841,7 @@ >"/> src/app/landing/landing.component.html - 273,279 + 283,289 landing section title provider @@ -668,7 +849,7 @@ View by provider src/app/landing/landing.component.html - 297 + 307 landing section link provider @@ -676,7 +857,7 @@ The data is not live and may be a few days old src/app/overview/overview.component.html - 15 + 16 Warning data recency @@ -726,7 +907,7 @@ No results found! src/app/overview/overview.component.html - 275 + 292 Warn no results @@ -734,7 +915,7 @@ try removing the dataset id parameter src/app/overview/overview.component.html - 282,284 + 299,301 No results detail dataset id @@ -742,7 +923,7 @@ try removing the date parameters src/app/overview/overview.component.html - 291,293 + 308,310 No results detail date @@ -750,7 +931,7 @@ As percentages src/app/overview/overview.component.html - 328 + 351 As percentages @@ -758,7 +939,7 @@ Export data src/app/overview/overview.component.html - 363 + 386 Share Report Opener @@ -766,112 +947,183 @@ Tier src/app/overview/overview.component.ts - 76 + 131 All () src/app/overview/overview.component.ts - 541 + 617 or src/app/overview/overview.component.ts - 544 + 620 and src/app/overview/overview.component.ts - 545 + 621 CT-Zero src/app/overview/overview.component.ts - 553 + 629 Period src/app/overview/overview.component.ts - 563 + 637 Dataset Id src/app/overview/overview.component.ts - 577 + 649 + + + + Collects anonymous statistics on how visitors interact with the website. + + src/environments/eu-cm-settings.ts + 27 + + + + Remembers what consent you have given for the use of services on this web application. + + src/environments/eu-cm-settings.ts + 34 - - - always required + + src/environments/eu-cm-settings.ts + 39 + Services we would like to use + + src/environments/eu-cm-settings.ts + 40 + - Here you can see and customise the services that we'd like to use on this website. To learn more please read our + Here you can see and customise the services that we'd like to use on this website. To learn more please read our + + src/environments/eu-cm-settings.ts + 41 + - + cookies policy + + src/environments/eu-cm-settings.ts + 42 + one service + + src/environments/eu-cm-settings.ts + 43 + services + + src/environments/eu-cm-settings.ts + 44 + Services to capture website usage and feedback + + src/environments/eu-cm-settings.ts + 49 + These services collect the information to help us better understand how the website gets used + + src/environments/eu-cm-settings.ts + 50 + Essential services for security and customization + + src/environments/eu-cm-settings.ts + 53 + These services are essential for the correct functioning of this website + + src/environments/eu-cm-settings.ts + 54 + I decline + + src/environments/eu-cm-settings.ts + 56 + Accept Selected + + src/environments/eu-cm-settings.ts + 57 + Accept All + + src/environments/eu-cm-settings.ts + 58 + Hi! We’d like your permission to collect anonymous usage statistics that will help us improve our applications. You can always change or withdraw your consent later. + + src/environments/eu-cm-settings.ts + 62 + Let me choose + + src/environments/eu-cm-settings.ts + 63 + Okay + + src/environments/eu-cm-settings.ts + 64 + I decline - - - - - Collects anonymous statistics on how visitors interact with the website - - - Remembers what consent you have given for the use of services on this web application + + src/environments/eu-cm-settings.ts + 65 + diff --git a/src/locale/messages.it.xlf b/src/locale/messages.it.xlf index da75ba70..cfcaf305 100644 --- a/src/locale/messages.it.xlf +++ b/src/locale/messages.it.xlf @@ -56,7 +56,79 @@ Aggregatori + + Dati Target + + + Dati + + + Totale conteggio record + + + Totale + + + Percentuale corrispondente + + + Percentuale + + + RECORD FORNITI + + + Dati 3D + + + Dati HQ + + + Tutti Dati + + + Esplora per tipo di media + + + Esplora per categoria di diritti + + + Esplora per aggregatore + + + Esplora per istituzione + + + Visualizza (dati 3D) per livello di contenuto + + + Visualizza (dati HQ) per tipo di media + + + Visualizza tutti i dati + + + Visualizza per tipo di media + + + Visualizza per categoria di diritti + + + Visualizza per istituzione + + + Visualizza per aggregatore + + + Paesi per vicinita' ai bersagli + + + Nascondi i dati + + + Mostri i dati + Livello di Contenuto @@ -204,6 +276,9 @@ Visualizza in Europeana + + 2025 / 2030 Bersagli + Europeana dataset completo @@ -248,8 +323,7 @@ landing section link metadata - Esplora fornendo paesi + Esplora fornendo paesi landing section title countries @@ -257,8 +331,7 @@ landing section link countries - Esplora per tipo di media + Esplora per tipo di media landing section title type @@ -280,8 +353,7 @@ landing section link rights - Esplora per istituzione + Esplora per istituzione landing section title data provider diff --git a/src/scss/_main.scss b/src/scss/_main.scss index 06e732a8..cd7421f9 100644 --- a/src/scss/_main.scss +++ b/src/scss/_main.scss @@ -152,6 +152,40 @@ body { margin: 30vh auto 0 auto; } +.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: -24px; + padding: 12px; + + position: absolute; + top: 0px; + transform: rotate(270deg); + transition: transform 20ms; + width: 12px; + + &:hover { + transform: rotate(360deg); + } +} + +:hover + .save { + transform: rotate(360deg); +} + +.save { + opacity: 0; +} + +.saved { + opacity: 1; +} + .total { font-size: 32px; line-height: 1.4; diff --git a/src/scss/_target.scss b/src/scss/_target.scss new file mode 100644 index 00000000..b5b30354 --- /dev/null +++ b/src/scss/_target.scss @@ -0,0 +1,37 @@ +.target { + $dmOuter: 16px; + $dmInner: 5px; + + background-color: white; + border-radius: 50%; + border: 3px solid $linkblue; + display: inline-block; + height: $dmOuter; + min-height: $dmOuter; + min-width: $dmOuter; + position: relative; + width: $dmOuter; + z-index: 1; + + &::after { + content: ""; + display: block; + background-color: red; + border-radius: 50%; + position: absolute; + left: 50%; + height: $dmInner; + top: 50%; + transform: translate(-50%, -50%); + width: $dmInner; + } +} + +:hover + .target, +:active .target, +.target:hover { + border-color: darken($textcolour_link, 15%); + &::after { + background-color: darken($textcolour_link, 15%); + } +} diff --git a/src/styles.scss b/src/styles.scss index 178ab12d..8474d201 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -7,6 +7,7 @@ @import "scss/links"; @import "scss/headings"; @import "scss/headings_simple"; +@import "scss/target"; @import "scss/main"; :root { diff --git a/test-data/index-x.ts b/test-data/index-x.ts index 2173e014..2dfe614b 100644 --- a/test-data/index-x.ts +++ b/test-data/index-x.ts @@ -14,6 +14,7 @@ import { } from '../src/app/_models'; import { facetNames } from '../src/app/_data'; import { CHO, IHashBoolean } from './_models/test-models'; +import { countryTargetData, targetData } from './static-country-data'; import { DataGenerator } from './data-generator'; new (class extends TestDataServer { @@ -114,6 +115,12 @@ new (class extends TestDataServer { request.on('end', () => { this.sendResponse(response, JSON.parse(body) as BreakdownRequest); }); + } else if ( + (request.url as string) === '/statistics/europeana/target/country/all' + ) { + response.end(JSON.stringify(countryTargetData)); + } else if ((request.url as string) === '/statistics/europeana/targets') { + response.end(JSON.stringify(targetData)); } else { const route = request.url as string; const params = url.parse(route, true).query; diff --git a/test-data/static-country-data.ts b/test-data/static-country-data.ts new file mode 100644 index 00000000..96db74ee --- /dev/null +++ b/test-data/static-country-data.ts @@ -0,0 +1,184 @@ +import { + IHash, + IHashArray, + TargetCountryData, + TargetFieldName, + TargetMetaData, + TargetMetaDataRaw +} from '../src/app/_models'; +import { ISOCountryCodes } from '../src/app/_data'; + +const dateTicks: Array = []; +const targetCountries = [ + 'Albania', + 'Austria', + 'Azerbaijan', + 'Belarus', + 'Belgium', + 'Bosnia and Herzegovina', + 'Bulgaria', + 'Croatia', + 'Cyprus', + 'Czech Republic', + 'Denmark', + 'Estonia', + 'Finland', + 'France', + 'Georgia', + 'Germany', + 'Greece', + 'Hungary', + 'Iceland', + 'Ireland', + 'Israel', + 'Italy', + 'Latvia', + 'Lithuania', + 'Luxembourg', + 'Malta', + 'Montenegro', + 'Moldova', + 'Netherlands', + 'North Macedonia', + 'Norway', + 'Poland', + 'Portugal', + 'Romania', + 'Russia', + 'Serbia', + 'Slovakia', + 'Slovenia', + 'Spain', + 'Sweden', + 'Switzerland', + 'Turkey', + 'Ukraine', + 'United Kingdom', + 'United States of America' +]; + +for (let i = 0; i < 24; i++) { + const date = new Date(); + date.setHours(0, 0, 0, 0); + date.setDate(i); + dateTicks.push(date.toISOString()); +} + +/** + * reduceTargetMetaData + * + * creates hash from raw target data (array) wherein item.county is used as a + * key to a further hash, which in turn uses item.targetType to access arrays + * of TargetMetaData objects + * + * @param { Array } rows - the source data to reduce + **/ +const reduceTargetMetaData = ( + rows: Array +): IHash> => { + return rows.reduce( + (res: IHash>, item: TargetMetaDataRaw) => { + const country = item.country; + + if (!res[country]) { + res[country] = {}; + } + + let arr: Array = res[country][item.targetType]; + if (!arr) { + arr = []; + res[country][item.targetType] = arr; + } + + arr.push({ + targetYear: item.targetYear, + value: item.value, + isInterim: item.isInterim + }); + + return res; + }, + {} + ); +}; + +export const targetData = [].concat( + ...targetCountries.map((country: string, index: number) => { + const resLabel = [2025, 2030].map((year: number) => { + // make values larger for later targets + let value = year * (index + 1); + + return Object.keys(TargetFieldName).map((targetType: TargetFieldName) => { + // make subtarget values smaller than total + value -= 123; + const fieldName = TargetFieldName[targetType]; + return { + country, + targetType: fieldName, + targetYear: year, + isInterim: year === 2025, + value: + fieldName === TargetFieldName.TOTAL + ? value * (year === 2025 ? 9 : 12) + : year === 2025 + ? Math.floor(value * 0.7) + : value + }; + }); + }); + return [].concat(...resLabel); + }) +); + +const fnCountryTargetData = (): Array => { + const numDateTicks = dateTicks.length; + const res = []; + const tgtDataRef = reduceTargetMetaData(targetData); + + targetCountries.forEach((country: string) => { + const countryName = country; + const countryCode = ISOCountryCodes[country]; + + if (!countryCode) { + console.log('missing code for ' + country); + } + + const countryRandom = Math.max(1.5, (countryName.length * 12) % 5); + + const baseValue3D = + tgtDataRef[country][TargetFieldName.THREE_D][1].value / countryRandom; + const basevalueHQ = + tgtDataRef[country][TargetFieldName.HQ][1].value / countryRandom; + + let value3D = baseValue3D * 1.2; + let valueHQ = basevalueHQ * 0.9; + + dateTicks.forEach((dateTick: string, dateTickIndex: number) => { + const random1 = + (value3D % (numDateTicks + 1)) - (value3D % (numDateTicks / 2)); + + value3D -= random1; + valueHQ += random1; + + const random2 = + (valueHQ % (numDateTicks + 1)) + (valueHQ % (numDateTicks / 2)); + + value3D -= 0.8 * (random2 % random1); + valueHQ -= 1 * (random2 * random1 * 5); + + const resultItem = { + country: countryName, + date: dateTicks[dateTicks.length - (dateTickIndex + 1)], + three_d: isNaN(value3D) ? 0 : Math.floor(value3D), + high_quality: isNaN(valueHQ) ? 0 : Math.floor(valueHQ), + total: 0 + }; + + resultItem.total = + Math.max(resultItem.three_d, resultItem.high_quality) * 12; + res.push(resultItem); + }); + }); + return res.reverse(); +}; +export const countryTargetData = fnCountryTargetData();