From f110eaec4437667ac6c0f60a720e83d052d30aa9 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 16 Jul 2024 15:35:07 +0100 Subject: [PATCH 01/37] Create tab-item component --- src/app/tab/tab-item/tab-item.component.css | 0 src/app/tab/tab-item/tab-item.component.html | 3 +++ .../tab/tab-item/tab-item.component.spec.ts | 23 +++++++++++++++++++ src/app/tab/tab-item/tab-item.component.ts | 13 +++++++++++ 4 files changed, 39 insertions(+) create mode 100644 src/app/tab/tab-item/tab-item.component.css create mode 100644 src/app/tab/tab-item/tab-item.component.html create mode 100644 src/app/tab/tab-item/tab-item.component.spec.ts create mode 100644 src/app/tab/tab-item/tab-item.component.ts diff --git a/src/app/tab/tab-item/tab-item.component.css b/src/app/tab/tab-item/tab-item.component.css new file mode 100644 index 00000000..e69de29b diff --git a/src/app/tab/tab-item/tab-item.component.html b/src/app/tab/tab-item/tab-item.component.html new file mode 100644 index 00000000..751f66c4 --- /dev/null +++ b/src/app/tab/tab-item/tab-item.component.html @@ -0,0 +1,3 @@ +@if (active()) { + +} diff --git a/src/app/tab/tab-item/tab-item.component.spec.ts b/src/app/tab/tab-item/tab-item.component.spec.ts new file mode 100644 index 00000000..204f325f --- /dev/null +++ b/src/app/tab/tab-item/tab-item.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabItemComponent } from './tab-item.component'; + +describe('TabItemComponent', () => { + let component: TabItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TabItemComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TabItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/tab/tab-item/tab-item.component.ts b/src/app/tab/tab-item/tab-item.component.ts new file mode 100644 index 00000000..e7cb8b69 --- /dev/null +++ b/src/app/tab/tab-item/tab-item.component.ts @@ -0,0 +1,13 @@ +import { Component, input, model } from '@angular/core'; + +@Component({ + selector: 'tab-item', + standalone: true, + imports: [], + templateUrl: './tab-item.component.html', + styleUrl: './tab-item.component.css', +}) +export class TabItemComponent { + public active = model(false); + public title = input.required(); +} From d827bba98fd5c9624b1deaef173b413513b163e1 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 16 Jul 2024 15:35:24 +0100 Subject: [PATCH 02/37] Create tabs component --- src/app/tab/tabs/tabs.component.css | 0 src/app/tab/tabs/tabs.component.html | 8 ++++++++ src/app/tab/tabs/tabs.component.spec.ts | 23 +++++++++++++++++++++++ src/app/tab/tabs/tabs.component.ts | 18 ++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 src/app/tab/tabs/tabs.component.css create mode 100644 src/app/tab/tabs/tabs.component.html create mode 100644 src/app/tab/tabs/tabs.component.spec.ts create mode 100644 src/app/tab/tabs/tabs.component.ts diff --git a/src/app/tab/tabs/tabs.component.css b/src/app/tab/tabs/tabs.component.css new file mode 100644 index 00000000..e69de29b diff --git a/src/app/tab/tabs/tabs.component.html b/src/app/tab/tabs/tabs.component.html new file mode 100644 index 00000000..1f25a50a --- /dev/null +++ b/src/app/tab/tabs/tabs.component.html @@ -0,0 +1,8 @@ +
    + @for (tab of tabs; track tab.title) { +
  • + {{ tab.title() }} +
  • + } +
+ diff --git a/src/app/tab/tabs/tabs.component.spec.ts b/src/app/tab/tabs/tabs.component.spec.ts new file mode 100644 index 00000000..c2822189 --- /dev/null +++ b/src/app/tab/tabs/tabs.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TabsComponent } from './tabs.component'; + +describe('TabsComponent', () => { + let component: TabsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TabsComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(TabsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/tab/tabs/tabs.component.ts b/src/app/tab/tabs/tabs.component.ts new file mode 100644 index 00000000..a7115583 --- /dev/null +++ b/src/app/tab/tabs/tabs.component.ts @@ -0,0 +1,18 @@ +import { Component, ContentChildren, QueryList } from '@angular/core'; +import { TabItemComponent } from '../tab-item/tab-item.component'; + +@Component({ + selector: 'tabs', + standalone: true, + imports: [TabItemComponent], + templateUrl: './tabs.component.html', + styleUrl: './tabs.component.css', +}) +export class TabsComponent { + @ContentChildren(TabItemComponent) tabs!: QueryList; + + selectTab(selectedTab: TabItemComponent) { + this.tabs.filter(tab => tab.active()).forEach(tab => tab.active.set(false)); + selectedTab.active.set(true); + } +} From c247e9c881807ac1cbf4cde398494e468ed86c3e Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 16 Jul 2024 15:37:23 +0100 Subject: [PATCH 03/37] Create treemap component and refactor chart logic from estimator into it --- .../carbon-estimation-treemap.component.css | 0 .../carbon-estimation-treemap.component.html | 12 ++ ...arbon-estimation-treemap.component.spec.ts | 23 +++ .../carbon-estimation-treemap.component.ts | 182 ++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.css create mode 100644 src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.html create mode 100644 src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts create mode 100644 src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.css b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.css new file mode 100644 index 00000000..e69de29b diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.html b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.html new file mode 100644 index 00000000..245f5c95 --- /dev/null +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.html @@ -0,0 +1,12 @@ +
+ +
diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts new file mode 100644 index 00000000..5e930058 --- /dev/null +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CarbonEstimationTreemapComponent } from './carbon-estimation-treemap.component'; + +describe('CarbonEstimationTreemapComponent', () => { + let component: CarbonEstimationTreemapComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CarbonEstimationTreemapComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CarbonEstimationTreemapComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts new file mode 100644 index 00000000..d4cbfd86 --- /dev/null +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts @@ -0,0 +1,182 @@ +import { Component, OnInit, ViewChild, effect, input } from '@angular/core'; +import { ApexAxisChartSeries, ChartComponent, NgApexchartsModule } from 'ng-apexcharts'; +import { CarbonEstimation, ChartOptions } from '../types/carbon-estimator'; +import { + chartOptions, + tooltipFormatter, + EmissionsColours, + EmissionsLabels, + SVG, +} from '../carbon-estimation/carbon-estimation.constants'; +import { NumberObject, sumValues } from '../utils/number-object'; +import { startCase } from 'lodash-es'; + +type ApexChartDataItem = { x: string; y: number; meta: { svg: string; parent: string } }; + +type ApexChartSeries = { + name: string; + color: string; + data: ApexChartDataItem[]; +}; + +@Component({ + selector: 'carbon-estimation-treemap', + standalone: true, + imports: [NgApexchartsModule], + templateUrl: './carbon-estimation-treemap.component.html', + styleUrl: './carbon-estimation-treemap.component.css', +}) +export class CarbonEstimationTreemapComponent implements OnInit { + public carbonEstimation = input.required(); + public chartHeight = input.required(); + + public emissions: ApexAxisChartSeries = []; + public emissionAriaLabel = 'Estimations of emissions.'; + + public chartOptions: ChartOptions = chartOptions; + private tooltipFormatter = tooltipFormatter; + + @ViewChild('chart') chart: ChartComponent | undefined; + + constructor() { + effect(() => { + this.emissions = this.getOverallEmissionPercentages(this.carbonEstimation()); + this.emissionAriaLabel = this.getAriaLabel(this.emissions); + const chartHeight = this.chartHeight(); + if (chartHeight !== this.chartOptions.chart.height) { + this.chart?.updateOptions({ chart: { height: chartHeight } }); + } + }); + } + + public ngOnInit(): void { + const chartHeight = this.chartHeight(); + if (chartHeight > 0) { + this.chartOptions.chart.height = chartHeight; + } + } + + private getOverallEmissionPercentages(carbonEstimation: CarbonEstimation): ApexAxisChartSeries { + return [ + { + name: `${EmissionsLabels.Upstream} - ${this.getOverallPercentageLabel(carbonEstimation.upstreamEmissions)}`, + color: EmissionsColours.Upstream, + data: this.getEmissionPercentages(carbonEstimation.upstreamEmissions, EmissionsLabels.Upstream), + }, + { + name: `${EmissionsLabels.Direct} - ${this.getOverallPercentageLabel(carbonEstimation.directEmissions)}`, + color: EmissionsColours.Direct, + data: this.getEmissionPercentages(carbonEstimation.directEmissions, EmissionsLabels.Direct), + }, + { + name: `${EmissionsLabels.Indirect} - ${this.getOverallPercentageLabel(carbonEstimation.indirectEmissions)}`, + color: EmissionsColours.Indirect, + data: this.getEmissionPercentages(carbonEstimation.indirectEmissions, EmissionsLabels.Indirect), + }, + { + name: `${EmissionsLabels.Downstream} - ${this.getOverallPercentageLabel(carbonEstimation.downstreamEmissions)}`, + color: EmissionsColours.Downstream, + data: this.getEmissionPercentages(carbonEstimation.downstreamEmissions, EmissionsLabels.Downstream), + }, + ].filter(entry => entry.data.length !== 0); + } + + private getAriaLabel(emission: ApexAxisChartSeries): string { + return `Estimation of emissions. ${emission.map(entry => this.getAriaLabelForCategory(entry as ApexChartSeries)).join(' ')}`; + } + + private getAriaLabelForCategory(series: ApexChartSeries): string { + const category = series.name.replace('-', 'are'); + return `${category}${this.getEmissionMadeUp(series.data)}`; + } + + private getEmissionMadeUp(emission: ApexChartDataItem[]): string { + if (emission.length === 0) { + return '.'; + } + return `, made up of ${emission.map(item => `${item.x} ${this.tooltipFormatter(item.y)}`).join(', ')}.`; + } + + private getOverallPercentageLabel = (emissions: NumberObject): string => { + const percentage = sumValues(emissions); + return percentage < 1 ? '<1%' : Math.round(percentage) + '%'; + }; + + private getEmissionPercentages(emissions: NumberObject, parent: string): ApexChartDataItem[] { + return ( + Object.entries(emissions) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_key, value]) => value !== 0) + .map(([_key, value]) => this.getDataItem(_key, value, parent)) + ); + } + + private getDataItem(key: string, value: number, parent: string): ApexChartDataItem { + switch (key) { + case 'software': + return this.getDataItemObject('Software - Off the Shelf', value, SVG.WEB, parent); + case 'saas': + return this.getDataItemObject('SaaS', value, SVG.WEB, parent); + case 'employee': + return this.getDataItemObject(this.getEmployeeLabel(parent), value, SVG.DEVICES, parent); + case 'endUser': + return this.getDataItemObject('End-User Devices', value, SVG.DEVICES, parent); + case 'network': + return this.getDataItemObject(this.getNetworkLabel(parent), value, SVG.ROUTER, parent); + case 'server': + return this.getDataItemObject(this.getServerLabel(parent), value, SVG.STORAGE, parent); + case 'managed': + return this.getDataItemObject('Managed Services', value, SVG.STORAGE, parent); + case 'cloud': + return this.getDataItemObject('Cloud Services', value, SVG.CLOUD, parent); + case 'networkTransfer': + return this.getDataItemObject('Network Data Transfer', value, SVG.CELL_TOWER, parent); + default: + return this.getDataItemObject(startCase(key), value, '', parent); + } + } + + private getDataItemObject(x: string, y: number, svg: string, parent: string): ApexChartDataItem { + return { + x, + y, + meta: { + svg, + parent, + }, + }; + } + + private getEmployeeLabel(key: string): string { + switch (key) { + case 'Upstream Emissions': + return 'Employee Hardware'; + case 'Direct Emissions': + return 'Employee Devices'; + default: + return startCase(key); + } + } + + private getNetworkLabel(key: string): string { + switch (key) { + case 'Upstream Emissions': + return 'Networking and Infrastructure Hardware'; + case 'Direct Emissions': + return 'Networking and Infrastructure'; + default: + return startCase(key); + } + } + + private getServerLabel(key: string): string { + switch (key) { + case 'Upstream Emissions': + return 'Servers and Storage Hardware'; + case 'Direct Emissions': + return 'Servers and Storage'; + default: + return startCase(key); + } + } +} From effaf054c20249b1a997bb3ec47cb3c240f95cb7 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Wed, 17 Jul 2024 15:24:26 +0100 Subject: [PATCH 04/37] Create estimations util service and move functions from treemap --- .../carbon-estimation-treemap.component.ts | 77 +++---------------- .../carbon-estimation-util.service.spec.ts | 55 +++++++++++++ .../carbon-estimation-util.service.ts | 76 ++++++++++++++++++ 3 files changed, 140 insertions(+), 68 deletions(-) create mode 100644 src/app/services/carbon-estimation-util.service.spec.ts create mode 100644 src/app/services/carbon-estimation-util.service.ts diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts index d4cbfd86..e58bda3d 100644 --- a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts @@ -6,10 +6,9 @@ import { tooltipFormatter, EmissionsColours, EmissionsLabels, - SVG, } from '../carbon-estimation/carbon-estimation.constants'; -import { NumberObject, sumValues } from '../utils/number-object'; -import { startCase } from 'lodash-es'; +import { NumberObject } from '../utils/number-object'; +import { CarbonEstimationUtilService } from '../services/carbon-estimation-util.service'; type ApexChartDataItem = { x: string; y: number; meta: { svg: string; parent: string } }; @@ -38,7 +37,7 @@ export class CarbonEstimationTreemapComponent implements OnInit { @ViewChild('chart') chart: ChartComponent | undefined; - constructor() { + constructor(private carbonEstimationUtilService: CarbonEstimationUtilService) { effect(() => { this.emissions = this.getOverallEmissionPercentages(this.carbonEstimation()); this.emissionAriaLabel = this.getAriaLabel(this.emissions); @@ -59,22 +58,22 @@ export class CarbonEstimationTreemapComponent implements OnInit { private getOverallEmissionPercentages(carbonEstimation: CarbonEstimation): ApexAxisChartSeries { return [ { - name: `${EmissionsLabels.Upstream} - ${this.getOverallPercentageLabel(carbonEstimation.upstreamEmissions)}`, + name: `${EmissionsLabels.Upstream} - ${this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.upstreamEmissions)}`, color: EmissionsColours.Upstream, data: this.getEmissionPercentages(carbonEstimation.upstreamEmissions, EmissionsLabels.Upstream), }, { - name: `${EmissionsLabels.Direct} - ${this.getOverallPercentageLabel(carbonEstimation.directEmissions)}`, + name: `${EmissionsLabels.Direct} - ${this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.directEmissions)}`, color: EmissionsColours.Direct, data: this.getEmissionPercentages(carbonEstimation.directEmissions, EmissionsLabels.Direct), }, { - name: `${EmissionsLabels.Indirect} - ${this.getOverallPercentageLabel(carbonEstimation.indirectEmissions)}`, + name: `${EmissionsLabels.Indirect} - ${this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.indirectEmissions)}`, color: EmissionsColours.Indirect, data: this.getEmissionPercentages(carbonEstimation.indirectEmissions, EmissionsLabels.Indirect), }, { - name: `${EmissionsLabels.Downstream} - ${this.getOverallPercentageLabel(carbonEstimation.downstreamEmissions)}`, + name: `${EmissionsLabels.Downstream} - ${this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.downstreamEmissions)}`, color: EmissionsColours.Downstream, data: this.getEmissionPercentages(carbonEstimation.downstreamEmissions, EmissionsLabels.Downstream), }, @@ -97,11 +96,6 @@ export class CarbonEstimationTreemapComponent implements OnInit { return `, made up of ${emission.map(item => `${item.x} ${this.tooltipFormatter(item.y)}`).join(', ')}.`; } - private getOverallPercentageLabel = (emissions: NumberObject): string => { - const percentage = sumValues(emissions); - return percentage < 1 ? '<1%' : Math.round(percentage) + '%'; - }; - private getEmissionPercentages(emissions: NumberObject, parent: string): ApexChartDataItem[] { return ( Object.entries(emissions) @@ -112,28 +106,8 @@ export class CarbonEstimationTreemapComponent implements OnInit { } private getDataItem(key: string, value: number, parent: string): ApexChartDataItem { - switch (key) { - case 'software': - return this.getDataItemObject('Software - Off the Shelf', value, SVG.WEB, parent); - case 'saas': - return this.getDataItemObject('SaaS', value, SVG.WEB, parent); - case 'employee': - return this.getDataItemObject(this.getEmployeeLabel(parent), value, SVG.DEVICES, parent); - case 'endUser': - return this.getDataItemObject('End-User Devices', value, SVG.DEVICES, parent); - case 'network': - return this.getDataItemObject(this.getNetworkLabel(parent), value, SVG.ROUTER, parent); - case 'server': - return this.getDataItemObject(this.getServerLabel(parent), value, SVG.STORAGE, parent); - case 'managed': - return this.getDataItemObject('Managed Services', value, SVG.STORAGE, parent); - case 'cloud': - return this.getDataItemObject('Cloud Services', value, SVG.CLOUD, parent); - case 'networkTransfer': - return this.getDataItemObject('Network Data Transfer', value, SVG.CELL_TOWER, parent); - default: - return this.getDataItemObject(startCase(key), value, '', parent); - } + const { label, svg } = this.carbonEstimationUtilService.getLabelAndSvg(key, parent); + return this.getDataItemObject(label, value, svg, parent); } private getDataItemObject(x: string, y: number, svg: string, parent: string): ApexChartDataItem { @@ -146,37 +120,4 @@ export class CarbonEstimationTreemapComponent implements OnInit { }, }; } - - private getEmployeeLabel(key: string): string { - switch (key) { - case 'Upstream Emissions': - return 'Employee Hardware'; - case 'Direct Emissions': - return 'Employee Devices'; - default: - return startCase(key); - } - } - - private getNetworkLabel(key: string): string { - switch (key) { - case 'Upstream Emissions': - return 'Networking and Infrastructure Hardware'; - case 'Direct Emissions': - return 'Networking and Infrastructure'; - default: - return startCase(key); - } - } - - private getServerLabel(key: string): string { - switch (key) { - case 'Upstream Emissions': - return 'Servers and Storage Hardware'; - case 'Direct Emissions': - return 'Servers and Storage'; - default: - return startCase(key); - } - } } diff --git a/src/app/services/carbon-estimation-util.service.spec.ts b/src/app/services/carbon-estimation-util.service.spec.ts new file mode 100644 index 00000000..7b85b60c --- /dev/null +++ b/src/app/services/carbon-estimation-util.service.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; + +import { CarbonEstimationUtilService } from './carbon-estimation-util.service'; + +describe('CarbonEstimationUtilService', () => { + let service: CarbonEstimationUtilService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CarbonEstimationUtilService); + }); + + it('should round numbers and add a percentage sign', () => { + expect(service.getPercentageLabel(1)).toBe('1%'); + expect(service.getPercentageLabel(1.5)).toBe('2%'); + expect(service.getPercentageLabel(1.499)).toBe('1%'); + }); + + it('should return <1% when the percentage is less than 1', () => { + expect(service.getPercentageLabel(0.999)).toBe('<1%'); + }); + + it('should sum the values of an object and return a percentage string', () => { + const emissions = { a: 10, b: 25, c: 1 }; + expect(service.getOverallPercentageLabel(emissions)).toBe('36%'); + + const emissions2 = { a: 0.1, b: 0.2, c: 0.5 }; + expect(service.getOverallPercentageLabel(emissions2)).toBe('<1%'); + }); + + it('should return the correct label and svg for a given key', () => { + expect(service.getLabelAndSvg('software')).toEqual({ label: 'Software - Off the Shelf', svg: 'web-logo' }); + expect(service.getLabelAndSvg('saas')).toEqual({ label: 'SaaS', svg: 'web-logo' }); + expect(service.getLabelAndSvg('employee', 'Upstream Emissions')).toEqual({ + label: 'Employee Hardware', + svg: 'devices-logo', + }); + expect(service.getLabelAndSvg('endUser')).toEqual({ label: 'End-User Devices', svg: 'devices-logo' }); + expect(service.getLabelAndSvg('network', 'Direct Emissions')).toEqual({ + label: 'Networking and Infrastructure', + svg: 'router-logo', + }); + expect(service.getLabelAndSvg('server', 'Direct Emissions')).toEqual({ + label: 'Servers and Storage', + svg: 'storage-logo', + }); + expect(service.getLabelAndSvg('managed')).toEqual({ label: 'Managed Services', svg: 'storage-logo' }); + expect(service.getLabelAndSvg('cloud')).toEqual({ label: 'Cloud Services', svg: 'cloud-logo' }); + expect(service.getLabelAndSvg('networkTransfer')).toEqual({ + label: 'Network Data Transfer', + svg: 'cell-tower-logo', + }); + expect(service.getLabelAndSvg('unknown')).toEqual({ label: 'Unknown', svg: '' }); + }); +}); diff --git a/src/app/services/carbon-estimation-util.service.ts b/src/app/services/carbon-estimation-util.service.ts new file mode 100644 index 00000000..cfb2b06b --- /dev/null +++ b/src/app/services/carbon-estimation-util.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; +import { NumberObject, sumValues } from '../utils/number-object'; +import { startCase } from 'lodash-es'; +import { SVG } from '../carbon-estimation/carbon-estimation.constants'; + +@Injectable({ + providedIn: 'root', +}) +export class CarbonEstimationUtilService { + constructor() {} + + public getOverallPercentageLabel = (emissions: NumberObject): string => { + const percentage = sumValues(emissions); + return this.getPercentageLabel(percentage); + }; + + public getPercentageLabel = (percentage: number): string => (percentage < 1 ? '<1%' : Math.round(percentage) + '%'); + + public getLabelAndSvg(key: string, parent: string = ''): { label: string; svg: string } { + switch (key) { + case 'software': + return { label: 'Software - Off the Shelf', svg: SVG.WEB }; + case 'saas': + return { label: 'SaaS', svg: SVG.WEB }; + case 'employee': + return { label: this.getEmployeeLabel(parent), svg: SVG.DEVICES }; + case 'endUser': + return { label: 'End-User Devices', svg: SVG.DEVICES }; + case 'network': + return { label: this.getNetworkLabel(parent), svg: SVG.ROUTER }; + case 'server': + return { label: this.getServerLabel(parent), svg: SVG.STORAGE }; + case 'managed': + return { label: 'Managed Services', svg: SVG.STORAGE }; + case 'cloud': + return { label: 'Cloud Services', svg: SVG.CLOUD }; + case 'networkTransfer': + return { label: 'Network Data Transfer', svg: SVG.CELL_TOWER }; + default: + return { label: startCase(key), svg: '' }; + } + } + + private getEmployeeLabel(key: string): string { + switch (key) { + case 'Upstream Emissions': + return 'Employee Hardware'; + case 'Direct Emissions': + return 'Employee Devices'; + default: + return startCase(key); + } + } + + private getNetworkLabel(key: string): string { + switch (key) { + case 'Upstream Emissions': + return 'Networking and Infrastructure Hardware'; + case 'Direct Emissions': + return 'Networking and Infrastructure'; + default: + return startCase(key); + } + } + + private getServerLabel(key: string): string { + switch (key) { + case 'Upstream Emissions': + return 'Servers and Storage Hardware'; + case 'Direct Emissions': + return 'Servers and Storage'; + default: + return startCase(key); + } + } +} From 2bd22a5783d59aab4c979da9dd0a7f03ecfa5e3d Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Wed, 17 Jul 2024 15:47:31 +0100 Subject: [PATCH 05/37] Create table view for emissions data --- .../carbon-estimation-table.component.html | 41 ++++++ .../carbon-estimation-table.component.spec.ts | 76 ++++++++++ .../carbon-estimation-table.component.ts | 130 ++++++++++++++++++ .../carbon-estimation.constants.ts | 3 + 4 files changed, 250 insertions(+) create mode 100644 src/app/carbon-estimation-table/carbon-estimation-table.component.html create mode 100644 src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts create mode 100644 src/app/carbon-estimation-table/carbon-estimation-table.component.ts diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html new file mode 100644 index 00000000..ec114c9a --- /dev/null +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -0,0 +1,41 @@ + + + + + + + + + @for (emissionsItem of emissions; track $index) { + + @if (!emissionsItem.parent) { + + + } @else if (emissionsItem.display) { + + + } + + } + +
CategoryEmissions
+ + {{ emissionsItem.category }} + {{ emissionsItem.emissions }} +
+
+
+ + {{ emissionsItem.category }} + +
+ {{ emissionsItem.emissions }} +
diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts new file mode 100644 index 00000000..e88bc210 --- /dev/null +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CarbonEstimationTableComponent } from './carbon-estimation-table.component'; +import { CarbonEstimationUtilService } from '../services/carbon-estimation-util.service'; +import { CarbonEstimation } from '../types/carbon-estimator'; + +describe('CarbonEstimationTableComponent', () => { + let component: CarbonEstimationTableComponent; + let fixture: ComponentFixture; + const utilSpy = jasmine.createSpyObj('CarbonEstimationUtilService', [ + 'getOverallPercentageLabel, getPercentageLabel, getLabelAndSvg', + ]); + + utilSpy.getOverallPercentageLabel.and.returnValue('7%'); + utilSpy.getPercentageLabel.and.returnValue('7%'); + utilSpy.getLabelAndSvg.and.returnValue({ label: 'Emissions', svg: 'svg' }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CarbonEstimationTableComponent], + providers: [{ provide: CarbonEstimationUtilService, useValue: utilSpy }], + }).compileComponents(); + + fixture = TestBed.createComponent(CarbonEstimationTableComponent); + component = fixture.componentInstance; + const carbonEstimation: CarbonEstimation = { + version: '1.0', + upstreamEmissions: { + software: 7, + employee: 6, + network: 6, + server: 6, + }, + directEmissions: { + employee: 9, + network: 8, + server: 8, + }, + indirectEmissions: { + cloud: 9, + saas: 8, + managed: 8, + }, + downstreamEmissions: { + endUser: 13, + networkTransfer: 12, + }, + }; + + fixture.componentRef.setInput('carbonEstimation', carbonEstimation); + fixture.detectChanges(); + }); + + it('should toggle display value of child emissions when toggle called', () => { + component.toggle('Upstream'); + expect(component.emissions[0].display).toBeFalse(); + component.emissions.forEach(emission => { + if (emission.parent === 'Upstream') { + expect(emission.display).toBeFalse(); + } + }); + }); + + it('should set child emissions to display by default', () => { + component.emissions.forEach(emission => { + if (emission.parent) { + expect(emission.display).toBeFalse(); + } + }); + }); + + it('should get emissions when getEmissions called', () => { + const emissions = component.getEmissions(component.carbonEstimation()); + expect(emissions.length).toBe(13); + }); +}); diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts new file mode 100644 index 00000000..2bb5f999 --- /dev/null +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -0,0 +1,130 @@ +import { Component, effect, input } from '@angular/core'; +import { CarbonEstimation } from '../types/carbon-estimator'; +import { EmissionsColours, EmissionsLabels } from '../carbon-estimation/carbon-estimation.constants'; +import { CarbonEstimationUtilService } from '../services/carbon-estimation-util.service'; +import { NumberObject } from '../utils/number-object'; +import { NgClass, NgStyle } from '@angular/common'; + +type TableItem = { + category: string; + emissions: string; + parent?: string; + svg?: string; + colour: ItemColour; + display?: boolean; +}; + +type ItemColour = { + svg?: string; + background: string; +}; + +@Component({ + selector: 'carbon-estimation-table', + standalone: true, + imports: [NgStyle, NgClass], + templateUrl: './carbon-estimation-table.component.html', +}) +export class CarbonEstimationTableComponent { + public carbonEstimation = input.required(); + public emissions: TableItem[] = []; + + public expanded: { [key: string]: boolean } = { + [EmissionsLabels.Upstream]: true, + [EmissionsLabels.Direct]: true, + [EmissionsLabels.Indirect]: true, + [EmissionsLabels.Downstream]: true, + }; + + constructor(private carbonEstimationUtilService: CarbonEstimationUtilService) { + effect(() => { + this.emissions = this.getEmissions(this.carbonEstimation()); + }); + } + + public toggle(category: string): void { + this.emissions.forEach(emission => { + if (emission.parent === category) { + emission.display = !emission.display; + } + }); + this.expanded[category] = !this.expanded[category]; + } + + public getEmissions(carbonEstimation: CarbonEstimation): TableItem[] { + return [ + { + category: EmissionsLabels.Upstream, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.upstreamEmissions), + colour: { background: EmissionsColours.Upstream }, + }, + ...this.getEmissionsBreakdown( + carbonEstimation.upstreamEmissions, + EmissionsLabels.Upstream, + EmissionsColours.Upstream, + EmissionsColours.UpstreamLight + ), + { + category: EmissionsLabels.Direct, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.directEmissions), + colour: { background: EmissionsColours.Direct }, + }, + ...this.getEmissionsBreakdown( + carbonEstimation.directEmissions, + EmissionsLabels.Direct, + EmissionsColours.Direct, + EmissionsColours.OperationLight + ), + { + category: EmissionsLabels.Indirect, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.indirectEmissions), + colour: { background: EmissionsColours.Indirect }, + }, + ...this.getEmissionsBreakdown( + carbonEstimation.indirectEmissions, + EmissionsLabels.Indirect, + EmissionsColours.Indirect, + EmissionsColours.OperationLight + ), + { + category: EmissionsLabels.Downstream, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.downstreamEmissions), + colour: { background: EmissionsColours.Downstream }, + }, + ...this.getEmissionsBreakdown( + carbonEstimation.downstreamEmissions, + EmissionsLabels.Downstream, + EmissionsColours.Downstream, + EmissionsColours.DownstreamLight + ), + ]; + } + + private getEmissionsBreakdown( + emissions: NumberObject, + parent: string, + svgColour: string, + backgroundColour: string + ): TableItem[] { + return ( + Object.entries(emissions) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .filter(([_key, value]) => value !== 0) + .map(([_key, value]) => + this.getTableItem(_key, value, parent, { background: backgroundColour, svg: svgColour }) + ) + ); + } + + private getTableItem(key: string, value: number, parent: string, colour: ItemColour): TableItem { + const { label, svg } = this.carbonEstimationUtilService.getLabelAndSvg(key, parent); + return { + category: label, + emissions: this.carbonEstimationUtilService.getPercentageLabel(value), + parent, + svg, + colour, + display: true, + }; + } +} diff --git a/src/app/carbon-estimation/carbon-estimation.constants.ts b/src/app/carbon-estimation/carbon-estimation.constants.ts index b34da436..9be3a1a5 100644 --- a/src/app/carbon-estimation/carbon-estimation.constants.ts +++ b/src/app/carbon-estimation/carbon-estimation.constants.ts @@ -2,9 +2,12 @@ import { ChartOptions } from '../types/carbon-estimator'; export enum EmissionsColours { Upstream = '#40798C', + UpstreamLight = '#bfdae2', Direct = '#CB3775', Indirect = '#91234C', + OperationLight = '#f2afd1', Downstream = '#4B7E56', + DownstreamLight = '#c1d9c3', } export enum PlaceholderEmissionsColours { From b875d03ca3362977cdbd794ef7d42726d607e7d3 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Wed, 17 Jul 2024 16:09:00 +0100 Subject: [PATCH 06/37] Remove unused css file --- .../carbon-estimation-treemap.component.css | 0 .../carbon-estimation-treemap.component.ts | 1 - 2 files changed, 1 deletion(-) delete mode 100644 src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.css diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.css b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts index e58bda3d..c11d60c3 100644 --- a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts @@ -23,7 +23,6 @@ type ApexChartSeries = { standalone: true, imports: [NgApexchartsModule], templateUrl: './carbon-estimation-treemap.component.html', - styleUrl: './carbon-estimation-treemap.component.css', }) export class CarbonEstimationTreemapComponent implements OnInit { public carbonEstimation = input.required(); From e12afa4dc93ff3f5f74ef96add25bd7fc75bb7a9 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Wed, 17 Jul 2024 16:09:12 +0100 Subject: [PATCH 07/37] Add treemap unit tests --- ...arbon-estimation-treemap.component.spec.ts | 296 +++++++++++++++++- 1 file changed, 290 insertions(+), 6 deletions(-) diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts index 5e930058..c9feae59 100644 --- a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CarbonEstimationTreemapComponent } from './carbon-estimation-treemap.component'; +import { CarbonEstimation } from '../types/carbon-estimator'; describe('CarbonEstimationTreemapComponent', () => { let component: CarbonEstimationTreemapComponent; @@ -8,16 +9,299 @@ describe('CarbonEstimationTreemapComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [CarbonEstimationTreemapComponent] - }) - .compileComponents(); - + imports: [CarbonEstimationTreemapComponent], + }).compileComponents(); + fixture = TestBed.createComponent(CarbonEstimationTreemapComponent); component = fixture.componentInstance; + + const carbonEstimation: CarbonEstimation = { + version: '1.0', + upstreamEmissions: { + software: 7, + employee: 6, + network: 6, + server: 6, + }, + directEmissions: { + employee: 9, + network: 8, + server: 8, + }, + indirectEmissions: { + cloud: 9, + saas: 8, + managed: 8, + }, + downstreamEmissions: { + endUser: 13, + networkTransfer: 12, + }, + }; + + fixture.componentRef.setInput('carbonEstimation', carbonEstimation); + fixture.componentRef.setInput('chartHeight', 700); + + fixture.detectChanges(); + }); + + it('should set emissions with total % and category breakdown', () => { + const expectedEmissions = [ + { + name: 'Upstream Emissions - 25%', + color: '#40798C', + data: [ + { + x: 'Software - Off the Shelf', + y: 7, + meta: { svg: 'web-logo', parent: 'Upstream Emissions' }, + }, + { + x: 'Employee Hardware', + y: 6, + meta: { svg: 'devices-logo', parent: 'Upstream Emissions' }, + }, + { + x: 'Networking and Infrastructure Hardware', + y: 6, + meta: { svg: 'router-logo', parent: 'Upstream Emissions' }, + }, + { + x: 'Servers and Storage Hardware', + y: 6, + meta: { svg: 'storage-logo', parent: 'Upstream Emissions' }, + }, + ], + }, + { + name: 'Direct Emissions - 25%', + color: '#CB3775', + data: [ + { + x: 'Employee Devices', + y: 9, + meta: { svg: 'devices-logo', parent: 'Direct Emissions' }, + }, + { + x: 'Networking and Infrastructure', + y: 8, + meta: { svg: 'router-logo', parent: 'Direct Emissions' }, + }, + { + x: 'Servers and Storage', + y: 8, + meta: { svg: 'storage-logo', parent: 'Direct Emissions' }, + }, + ], + }, + { + name: 'Indirect Emissions - 25%', + color: '#91234C', + data: [ + { + x: 'Cloud Services', + y: 9, + meta: { svg: 'cloud-logo', parent: 'Indirect Emissions' }, + }, + { + x: 'SaaS', + y: 8, + meta: { svg: 'web-logo', parent: 'Indirect Emissions' }, + }, + { + x: 'Managed Services', + y: 8, + meta: { svg: 'storage-logo', parent: 'Indirect Emissions' }, + }, + ], + }, + { + name: 'Downstream Emissions - 25%', + color: '#4B7E56', + data: [ + { + x: 'End-User Devices', + y: 13, + meta: { svg: 'devices-logo', parent: 'Downstream Emissions' }, + }, + { + x: 'Network Data Transfer', + y: 12, + meta: { svg: 'cell-tower-logo', parent: 'Downstream Emissions' }, + }, + ], + }, + ]; + + expect(component.emissions).toEqual(expectedEmissions); + }); + + it('should have detailed aria label', () => { + expect(component.emissionAriaLabel.length).toBeGreaterThan(25); + }); + + it('should set label to <1% if emission is less than 1', () => { + const carbonEstimation: CarbonEstimation = { + version: '1.0', + upstreamEmissions: { + software: 0.2, + employee: 0.1, + network: 0.1, + server: 0.1, + }, + directEmissions: { + employee: 34.5, + network: 8, + server: 8, + }, + indirectEmissions: { + cloud: 9, + saas: 8, + managed: 8, + }, + downstreamEmissions: { + endUser: 13, + networkTransfer: 12, + }, + }; + fixture.componentRef.setInput('carbonEstimation', carbonEstimation); + + fixture.detectChanges(); + + expect(component.emissions[0].name).toBe('Upstream Emissions - <1%'); + }); + + it('should remove categories when they are 0', () => { + const carbonEstimation: CarbonEstimation = { + version: '1.0', + upstreamEmissions: { + software: 25, + employee: 0, + network: 0, + server: 0, + }, + directEmissions: { + employee: 25, + network: 0, + server: 0, + }, + indirectEmissions: { + cloud: 25, + saas: 0, + managed: 0, + }, + downstreamEmissions: { + endUser: 25, + networkTransfer: 0, + }, + }; + fixture.componentRef.setInput('carbonEstimation', carbonEstimation); + fixture.detectChanges(); + + const expectedEmissions = [ + { + name: 'Upstream Emissions - 25%', + color: '#40798C', + data: [ + { + x: 'Software - Off the Shelf', + y: 25, + meta: { svg: 'web-logo', parent: 'Upstream Emissions' }, + }, + ], + }, + { + name: 'Direct Emissions - 25%', + color: '#CB3775', + data: [ + { + x: 'Employee Devices', + y: 25, + meta: { svg: 'devices-logo', parent: 'Direct Emissions' }, + }, + ], + }, + { + name: 'Indirect Emissions - 25%', + color: '#91234C', + data: [ + { + x: 'Cloud Services', + y: 25, + meta: { svg: 'cloud-logo', parent: 'Indirect Emissions' }, + }, + ], + }, + { + name: 'Downstream Emissions - 25%', + color: '#4B7E56', + data: [ + { + x: 'End-User Devices', + y: 25, + meta: { svg: 'devices-logo', parent: 'Downstream Emissions' }, + }, + ], + }, + ]; + + expect(component.emissions).toEqual(expectedEmissions); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should remove parent categories when all values are 0', () => { + const carbonEstimation: CarbonEstimation = { + version: '1.0', + upstreamEmissions: { + software: 50, + employee: 0, + network: 0, + server: 0, + }, + directEmissions: { + employee: 50, + network: 0, + server: 0, + }, + indirectEmissions: { + cloud: 0, + saas: 0, + managed: 0, + }, + downstreamEmissions: { + endUser: 0, + networkTransfer: 0, + }, + }; + fixture.componentRef.setInput('carbonEstimation', carbonEstimation); + + fixture.detectChanges(); + + const expectedEmissions = [ + { + name: 'Upstream Emissions - 50%', + color: '#40798C', + data: [ + { + x: 'Software - Off the Shelf', + y: 50, + meta: { svg: 'web-logo', parent: 'Upstream Emissions' }, + }, + ], + }, + { + name: 'Direct Emissions - 50%', + color: '#CB3775', + data: [ + { + x: 'Employee Devices', + y: 50, + meta: { svg: 'devices-logo', parent: 'Direct Emissions' }, + }, + ], + }, + ]; + + expect(component.emissions).toEqual(expectedEmissions); }); }); From 7647138c551cc3a7ff5e84086dbbe1e49e23cc27 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Wed, 17 Jul 2024 16:10:11 +0100 Subject: [PATCH 08/37] Refactor carbon-estimation to use new treemap and table components --- .../carbon-estimation-treemap.component.html | 18 +- .../carbon-estimation-treemap.component.ts | 40 ++- .../carbon-estimation.component.html | 22 +- .../carbon-estimation.component.spec.ts | 293 +----------------- .../carbon-estimation.component.ts | 195 ++---------- .../carbon-estimation.constants.ts | 31 +- 6 files changed, 91 insertions(+), 508 deletions(-) diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.html b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.html index 245f5c95..e78a293d 100644 --- a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.html +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.html @@ -1,12 +1,12 @@ -
+
-
+ [series]="chartData()" + [chart]="chartOptions().chart" + [plotOptions]="chartOptions().plotOptions" + [legend]="chartOptions().legend" + [states]="chartOptions().states" + [dataLabels]="chartOptions().dataLabels" + [tooltip]="chartOptions().tooltip"> + diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts index c11d60c3..5cf1e376 100644 --- a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.ts @@ -1,11 +1,12 @@ -import { Component, OnInit, ViewChild, effect, input } from '@angular/core'; +import { Component, ViewChild, computed, effect, input } from '@angular/core'; import { ApexAxisChartSeries, ChartComponent, NgApexchartsModule } from 'ng-apexcharts'; -import { CarbonEstimation, ChartOptions } from '../types/carbon-estimator'; +import { CarbonEstimation } from '../types/carbon-estimator'; import { - chartOptions, tooltipFormatter, EmissionsColours, EmissionsLabels, + getBaseChartOptions, + placeholderData, } from '../carbon-estimation/carbon-estimation.constants'; import { NumberObject } from '../utils/number-object'; import { CarbonEstimationUtilService } from '../services/carbon-estimation-util.service'; @@ -24,34 +25,35 @@ type ApexChartSeries = { imports: [NgApexchartsModule], templateUrl: './carbon-estimation-treemap.component.html', }) -export class CarbonEstimationTreemapComponent implements OnInit { - public carbonEstimation = input.required(); +export class CarbonEstimationTreemapComponent { + public carbonEstimation = input(); public chartHeight = input.required(); - public emissions: ApexAxisChartSeries = []; - public emissionAriaLabel = 'Estimations of emissions.'; + public chartData = computed(() => this.getChartData(this.carbonEstimation())); + public emissionAriaLabel = computed(() => this.getAriaLabel(this.chartData(), !this.carbonEstimation())); - public chartOptions: ChartOptions = chartOptions; + public chartOptions = computed(() => this.getChartOptions(!this.carbonEstimation())); private tooltipFormatter = tooltipFormatter; @ViewChild('chart') chart: ChartComponent | undefined; constructor(private carbonEstimationUtilService: CarbonEstimationUtilService) { effect(() => { - this.emissions = this.getOverallEmissionPercentages(this.carbonEstimation()); - this.emissionAriaLabel = this.getAriaLabel(this.emissions); const chartHeight = this.chartHeight(); - if (chartHeight !== this.chartOptions.chart.height) { + if (chartHeight !== this.chartOptions().chart.height) { this.chart?.updateOptions({ chart: { height: chartHeight } }); } }); } - public ngOnInit(): void { - const chartHeight = this.chartHeight(); - if (chartHeight > 0) { - this.chartOptions.chart.height = chartHeight; - } + private getChartOptions(isPlaceholder: boolean) { + const chartOptions = getBaseChartOptions(isPlaceholder); + chartOptions.chart.height = this.chartHeight(); + return chartOptions; + } + + private getChartData(estimation?: CarbonEstimation): ApexAxisChartSeries { + return estimation ? this.getOverallEmissionPercentages(estimation) : placeholderData; } private getOverallEmissionPercentages(carbonEstimation: CarbonEstimation): ApexAxisChartSeries { @@ -79,7 +81,11 @@ export class CarbonEstimationTreemapComponent implements OnInit { ].filter(entry => entry.data.length !== 0); } - private getAriaLabel(emission: ApexAxisChartSeries): string { + private getAriaLabel(chartData: ApexAxisChartSeries, isPlaceholder: boolean) { + return isPlaceholder ? 'Placeholder for estimation of emissions' : this.getEmissionAriaLabel(chartData); + } + + private getEmissionAriaLabel(emission: ApexAxisChartSeries): string { return `Estimation of emissions. ${emission.map(entry => this.getAriaLabelForCategory(entry as ApexChartSeries)).join(' ')}`; } diff --git a/src/app/carbon-estimation/carbon-estimation.component.html b/src/app/carbon-estimation/carbon-estimation.component.html index 2e28eaba..b19aaabb 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.html +++ b/src/app/carbon-estimation/carbon-estimation.component.html @@ -21,16 +21,14 @@

Estimations

-
- -
+ + + + + + + diff --git a/src/app/carbon-estimation/carbon-estimation.component.spec.ts b/src/app/carbon-estimation/carbon-estimation.component.spec.ts index 15d38e03..fc3dc84c 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.spec.ts +++ b/src/app/carbon-estimation/carbon-estimation.component.spec.ts @@ -2,7 +2,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CarbonEstimationComponent } from './carbon-estimation.component'; import { CarbonEstimation } from '../types/carbon-estimator'; -import { ChartComponent } from 'ng-apexcharts'; import { sumValues } from '../utils/number-object'; import { estimatorHeights } from './carbon-estimation.constants'; @@ -51,76 +50,58 @@ describe('CarbonEstimationComponent', () => { // Check that the height is set to a positive value. // (Checking for a specific value would require spying on the window object before // the test starts for consistent local and CI/CD results.) - expect(component.chartOptions().chart.height).toBeGreaterThan(0); + expect(component.chartHeight).toBeGreaterThan(0); }); it('should subtract the extraHeight input from the chart height on laptop screens', () => { - spyOn(component.chart as ChartComponent, 'updateOptions'); spyOnProperty(component.detailsPanel.nativeElement, 'clientHeight').and.returnValue(200); fixture.componentRef.setInput('extraHeight', '100'); component.onResize(1500, 1500, 2000); - expect(component.chart?.updateOptions).toHaveBeenCalledOnceWith({ - chart: { height: 1500 - estimatorBaseHeight - 200 - 100 }, - }); + expect(component.chartHeight).toBe(1500 - estimatorBaseHeight - 200 - 100); }); it('should recalculate chart height on window resize, for laptop screen', () => { - spyOn(component.chart as ChartComponent, 'updateOptions'); spyOnProperty(component.detailsPanel.nativeElement, 'clientHeight').and.returnValue(200); component.onResize(1500, 2000, 2000); - expect(component.chart?.updateOptions).toHaveBeenCalledOnceWith({ - chart: { height: 1500 - estimatorBaseHeight - 200 }, - }); + expect(component.chartHeight).toBe(1500 - estimatorBaseHeight - 200); }); it('should recalculate chart height on window resize, for mobile screen', () => { - spyOn(component.chart as ChartComponent, 'updateOptions'); spyOnProperty(component.detailsPanel.nativeElement, 'clientHeight').and.returnValue(200); component.onResize(1000, 500, 1000); - expect(component.chart?.updateOptions).toHaveBeenCalledOnceWith({ - chart: { height: 1000 - estimatorBaseHeight - 200 + estimatorHeights.title }, - }); + expect(component.chartHeight).toBe(1000 - estimatorBaseHeight - 200 + estimatorHeights.title); }); it('should cap chart height as a percentage of screen height, for laptop screen', () => { - spyOn(component.chart as ChartComponent, 'updateOptions'); spyOnProperty(component.detailsPanel.nativeElement, 'clientHeight').and.returnValue(200); const screenHeight = 2000; component.onResize(2000, 1500, screenHeight); - expect(component.chart?.updateOptions).toHaveBeenCalledOnceWith({ - chart: { height: screenHeight * 0.75 }, - }); + expect(component.chartHeight).toBe(screenHeight * 0.75); }); it('should cap chart height as a percentage of screen height, for mobile screen', () => { - spyOn(component.chart as ChartComponent, 'updateOptions'); spyOnProperty(component.detailsPanel.nativeElement, 'clientHeight').and.returnValue(200); const screenHeight = 1200; component.onResize(1200, 500, screenHeight); - expect(component.chart?.updateOptions).toHaveBeenCalledOnceWith({ - chart: { height: screenHeight * 0.75 }, - }); + expect(component.chartHeight).toBe(screenHeight * 0.75); }); it('should have a chart height of 300 for small innerHeight values (if screen height is large enough)', () => { - spyOn(component.chart as ChartComponent, 'updateOptions'); spyOnProperty(component.detailsPanel.nativeElement, 'clientHeight').and.returnValue(200); component.onResize(100, 1000, 2000); - expect(component.chart?.updateOptions).toHaveBeenCalledOnceWith({ - chart: { height: 300 }, - }); + expect(component.chartHeight).toBe(300); }); it('should call onResize when onExpansion is called', () => { @@ -130,264 +111,4 @@ describe('CarbonEstimationComponent', () => { expect(component.onResize).toHaveBeenCalledTimes(1); }); - - it('should set emissions with total % and category breakdown', () => { - const expectedEmissions = [ - { - name: 'Upstream Emissions - 25%', - color: '#40798C', - data: [ - { - x: 'Software - Off the Shelf', - y: 7, - meta: { svg: 'web-logo', parent: 'Upstream Emissions' }, - }, - { - x: 'Employee Hardware', - y: 6, - meta: { svg: 'devices-logo', parent: 'Upstream Emissions' }, - }, - { - x: 'Networking and Infrastructure Hardware', - y: 6, - meta: { svg: 'router-logo', parent: 'Upstream Emissions' }, - }, - { - x: 'Servers and Storage Hardware', - y: 6, - meta: { svg: 'storage-logo', parent: 'Upstream Emissions' }, - }, - ], - }, - { - name: 'Direct Emissions - 25%', - color: '#CB3775', - data: [ - { - x: 'Employee Devices', - y: 9, - meta: { svg: 'devices-logo', parent: 'Direct Emissions' }, - }, - { - x: 'Networking and Infrastructure', - y: 8, - meta: { svg: 'router-logo', parent: 'Direct Emissions' }, - }, - { - x: 'Servers and Storage', - y: 8, - meta: { svg: 'storage-logo', parent: 'Direct Emissions' }, - }, - ], - }, - { - name: 'Indirect Emissions - 25%', - color: '#91234C', - data: [ - { - x: 'Cloud Services', - y: 9, - meta: { svg: 'cloud-logo', parent: 'Indirect Emissions' }, - }, - { - x: 'SaaS', - y: 8, - meta: { svg: 'web-logo', parent: 'Indirect Emissions' }, - }, - { - x: 'Managed Services', - y: 8, - meta: { svg: 'storage-logo', parent: 'Indirect Emissions' }, - }, - ], - }, - { - name: 'Downstream Emissions - 25%', - color: '#4B7E56', - data: [ - { - x: 'End-User Devices', - y: 13, - meta: { svg: 'devices-logo', parent: 'Downstream Emissions' }, - }, - { - x: 'Network Data Transfer', - y: 12, - meta: { svg: 'cell-tower-logo', parent: 'Downstream Emissions' }, - }, - ], - }, - ]; - - expect(component.chartData()).toEqual(expectedEmissions); - }); - - it('should have detailed aria label', () => { - expect(component.emissionAriaLabel().length).toBeGreaterThan(25); - }); - - it('should set label to <1% if emission is less than 1', () => { - const carbonEstimation: CarbonEstimation = { - version: '1.0', - upstreamEmissions: { - software: 0.2, - employee: 0.1, - network: 0.1, - server: 0.1, - }, - directEmissions: { - employee: 34.5, - network: 8, - server: 8, - }, - indirectEmissions: { - cloud: 9, - saas: 8, - managed: 8, - }, - downstreamEmissions: { - endUser: 13, - networkTransfer: 12, - }, - }; - fixture.componentRef.setInput('carbonEstimation', carbonEstimation); - - fixture.detectChanges(); - - expect(component.chartData()[0].name).toBe('Upstream Emissions - <1%'); - }); - - it('should remove categories when they are 0', () => { - const carbonEstimation: CarbonEstimation = { - version: '1.0', - upstreamEmissions: { - software: 25, - employee: 0, - network: 0, - server: 0, - }, - directEmissions: { - employee: 25, - network: 0, - server: 0, - }, - indirectEmissions: { - cloud: 25, - saas: 0, - managed: 0, - }, - downstreamEmissions: { - endUser: 25, - networkTransfer: 0, - }, - }; - fixture.componentRef.setInput('carbonEstimation', carbonEstimation); - - fixture.detectChanges(); - - const expectedEmissions = [ - { - name: 'Upstream Emissions - 25%', - color: '#40798C', - data: [ - { - x: 'Software - Off the Shelf', - y: 25, - meta: { svg: 'web-logo', parent: 'Upstream Emissions' }, - }, - ], - }, - { - name: 'Direct Emissions - 25%', - color: '#CB3775', - data: [ - { - x: 'Employee Devices', - y: 25, - meta: { svg: 'devices-logo', parent: 'Direct Emissions' }, - }, - ], - }, - { - name: 'Indirect Emissions - 25%', - color: '#91234C', - data: [ - { - x: 'Cloud Services', - y: 25, - meta: { svg: 'cloud-logo', parent: 'Indirect Emissions' }, - }, - ], - }, - { - name: 'Downstream Emissions - 25%', - color: '#4B7E56', - data: [ - { - x: 'End-User Devices', - y: 25, - meta: { svg: 'devices-logo', parent: 'Downstream Emissions' }, - }, - ], - }, - ]; - - expect(component.chartData()).toEqual(expectedEmissions); - }); - - it('should remove parent categories when all values are 0', () => { - const carbonEstimation: CarbonEstimation = { - version: '1.0', - upstreamEmissions: { - software: 50, - employee: 0, - network: 0, - server: 0, - }, - directEmissions: { - employee: 50, - network: 0, - server: 0, - }, - indirectEmissions: { - cloud: 0, - saas: 0, - managed: 0, - }, - downstreamEmissions: { - endUser: 0, - networkTransfer: 0, - }, - }; - fixture.componentRef.setInput('carbonEstimation', carbonEstimation); - - fixture.detectChanges(); - - const expectedEmissions = [ - { - name: 'Upstream Emissions - 50%', - color: '#40798C', - data: [ - { - x: 'Software - Off the Shelf', - y: 50, - meta: { svg: 'web-logo', parent: 'Upstream Emissions' }, - }, - ], - }, - { - name: 'Direct Emissions - 50%', - color: '#CB3775', - data: [ - { - x: 'Employee Devices', - y: 50, - meta: { svg: 'devices-logo', parent: 'Direct Emissions' }, - }, - ], - }, - ]; - - expect(component.chartData()).toEqual(expectedEmissions); - }); }); diff --git a/src/app/carbon-estimation/carbon-estimation.component.ts b/src/app/carbon-estimation/carbon-estimation.component.ts index f25ea21e..10bf764a 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.ts +++ b/src/app/carbon-estimation/carbon-estimation.component.ts @@ -1,27 +1,24 @@ -import { ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit, ViewChild, computed, input } from '@angular/core'; -import { CarbonEstimation } from '../types/carbon-estimator'; -import { NumberObject, sumValues } from '../utils/number-object'; -import { ApexAxisChartSeries, ChartComponent, NgApexchartsModule } from 'ng-apexcharts'; - -import { startCase } from 'lodash-es'; -import { - EmissionsColours, - EmissionsLabels, - SVG, - getBaseChartOptions, - estimatorHeights, - tooltipFormatter, - placeholderData, - ApexChartSeriesItem, - ApexChartDataItem, -} from './carbon-estimation.constants'; +import { ChangeDetectorRef, Component, ElementRef, input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ExpansionPanelComponent } from '../expansion-panel/expansion-panel.component'; -import { Subscription, debounceTime, fromEvent } from 'rxjs'; +import { TabsComponent } from '../tab/tabs/tabs.component'; +import { TabItemComponent } from '../tab/tab-item/tab-item.component'; +import { CarbonEstimationTreemapComponent } from '../carbon-estimation-treemap/carbon-estimation-treemap.component'; +import { CarbonEstimation } from '../types/carbon-estimator'; +import { sumValues } from '../utils/number-object'; +import { estimatorHeights } from './carbon-estimation.constants'; +import { debounceTime, fromEvent, Subscription } from 'rxjs'; +import { CarbonEstimationTableComponent } from '../carbon-estimation-table/carbon-estimation-table.component'; @Component({ selector: 'carbon-estimation', standalone: true, - imports: [NgApexchartsModule, ExpansionPanelComponent], + imports: [ + ExpansionPanelComponent, + TabsComponent, + TabItemComponent, + CarbonEstimationTreemapComponent, + CarbonEstimationTableComponent, + ], templateUrl: './carbon-estimation.component.html', styleUrls: ['./carbon-estimation.component.css'], }) @@ -29,21 +26,18 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { public carbonEstimation = input(); public extraHeight = input(); - public chartData = computed(() => this.getChartData(this.carbonEstimation())); - public emissionAriaLabel = computed(() => this.getEmissionAriaLabel(this.chartData(), !this.carbonEstimation())); + @ViewChild('detailsPanel', { static: true, read: ElementRef }) detailsPanel!: ElementRef; - public chartOptions = computed(() => this.getChartOptions(!this.carbonEstimation())); - private tooltipFormatter = tooltipFormatter; - private estimatorBaseHeight = sumValues(estimatorHeights); + public chartHeight!: number; + private estimatorBaseHeight = sumValues(estimatorHeights); private resizeSubscription!: Subscription; - @ViewChild('chart') chart: ChartComponent | undefined; - @ViewChild('detailsPanel', { static: true, read: ElementRef }) detailsPanel!: ElementRef; - constructor(private changeDetectorRef: ChangeDetectorRef) {} public ngOnInit(): void { + this.chartHeight = this.getChartHeight(window.innerHeight, window.innerWidth, window.screen.height); + this.resizeSubscription = fromEvent(window, 'resize') .pipe(debounceTime(500)) .subscribe(() => this.onResize(window.innerHeight, window.innerWidth, window.screen.height)); @@ -53,73 +47,13 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { this.resizeSubscription.unsubscribe(); } - public onExpanded(): void { - this.changeDetectorRef.detectChanges(); - this.onResize(window.innerHeight, window.innerWidth, window.screen.height); - } - onResize(innerHeight: number, innerWidth: number, screenHeight: number): void { - const chartHeight = this.getChartHeight(innerHeight, innerWidth, screenHeight); - this.chart?.updateOptions({ chart: { height: chartHeight } }); - } - - private getOverallEmissionPercentages(carbonEstimation: CarbonEstimation): ApexAxisChartSeries { - return [ - { - name: `${EmissionsLabels.Upstream} - ${this.getOverallPercentageLabel(carbonEstimation.upstreamEmissions)}`, - color: EmissionsColours.Upstream, - data: this.getEmissionPercentages(carbonEstimation.upstreamEmissions, EmissionsLabels.Upstream), - }, - { - name: `${EmissionsLabels.Direct} - ${this.getOverallPercentageLabel(carbonEstimation.directEmissions)}`, - color: EmissionsColours.Direct, - data: this.getEmissionPercentages(carbonEstimation.directEmissions, EmissionsLabels.Direct), - }, - { - name: `${EmissionsLabels.Indirect} - ${this.getOverallPercentageLabel(carbonEstimation.indirectEmissions)}`, - color: EmissionsColours.Indirect, - data: this.getEmissionPercentages(carbonEstimation.indirectEmissions, EmissionsLabels.Indirect), - }, - { - name: `${EmissionsLabels.Downstream} - ${this.getOverallPercentageLabel(carbonEstimation.downstreamEmissions)}`, - color: EmissionsColours.Downstream, - data: this.getEmissionPercentages(carbonEstimation.downstreamEmissions, EmissionsLabels.Downstream), - }, - ].filter(entry => entry.data.length !== 0); - } - - private getAriaLabel(emission: ApexAxisChartSeries): string { - return `Estimation of emissions. ${emission.map(entry => this.getAriaLabelForCategory(entry as ApexChartSeriesItem)).join(' ')}`; - } - - private getEmissionAriaLabel(chartData: ApexAxisChartSeries, isPlaceholder: boolean) { - return isPlaceholder ? 'Placeholder for estimation of emissions' : this.getAriaLabel(chartData); - } - - private getAriaLabelForCategory(series: ApexChartSeriesItem): string { - const category = series.name.replace('-', 'are'); - return `${category}${this.getEmissionMadeUp(series.data)}`; - } - - private getEmissionMadeUp(emission: ApexChartDataItem[]): string { - if (emission.length === 0) { - return '.'; - } - return `, made up of ${emission.map(item => `${item.x} ${this.tooltipFormatter(item.y)}`).join(', ')}.`; + this.chartHeight = this.getChartHeight(innerHeight, innerWidth, screenHeight); } - private getOverallPercentageLabel = (emissions: NumberObject): string => { - const percentage = sumValues(emissions); - return percentage < 1 ? '<1%' : Math.round(percentage) + '%'; - }; - - private getEmissionPercentages(emissions: NumberObject, parent: string): ApexChartDataItem[] { - return ( - Object.entries(emissions) - // eslint-disable-next-line @typescript-eslint/no-unused-vars - .filter(([_key, value]) => value !== 0) - .map(([_key, value]) => this.getDataItem(_key, value, parent)) - ); + public onExpanded(): void { + this.changeDetectorRef.detectChanges(); + this.onResize(window.innerHeight, window.innerWidth, window.screen.height); } private getChartHeight(innerHeight: number, innerWidth: number, screenHeight: number): number { @@ -149,83 +83,4 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { return heightBoundedAboveAndBelow; } - - private getChartOptions(isPlaceholder: boolean) { - const chartOptions = getBaseChartOptions(isPlaceholder); - chartOptions.chart.height = this.getChartHeight(window.innerHeight, window.innerWidth, window.screen.height); - return chartOptions; - } - - private getChartData(estimation?: CarbonEstimation): ApexAxisChartSeries { - return estimation ? this.getOverallEmissionPercentages(estimation) : placeholderData; - } - - private getDataItem(key: string, value: number, parent: string): ApexChartDataItem { - switch (key) { - case 'software': - return this.getDataItemObject('Software - Off the Shelf', value, SVG.WEB, parent); - case 'saas': - return this.getDataItemObject('SaaS', value, SVG.WEB, parent); - case 'employee': - return this.getDataItemObject(this.getEmployeeLabel(parent), value, SVG.DEVICES, parent); - case 'endUser': - return this.getDataItemObject('End-User Devices', value, SVG.DEVICES, parent); - case 'network': - return this.getDataItemObject(this.getNetworkLabel(parent), value, SVG.ROUTER, parent); - case 'server': - return this.getDataItemObject(this.getServerLabel(parent), value, SVG.STORAGE, parent); - case 'managed': - return this.getDataItemObject('Managed Services', value, SVG.STORAGE, parent); - case 'cloud': - return this.getDataItemObject('Cloud Services', value, SVG.CLOUD, parent); - case 'networkTransfer': - return this.getDataItemObject('Network Data Transfer', value, SVG.CELL_TOWER, parent); - default: - return this.getDataItemObject(startCase(key), value, '', parent); - } - } - - private getDataItemObject(x: string, y: number, svg: string, parent: string): ApexChartDataItem { - return { - x, - y, - meta: { - svg, - parent, - }, - }; - } - - private getEmployeeLabel(key: string): string { - switch (key) { - case 'Upstream Emissions': - return 'Employee Hardware'; - case 'Direct Emissions': - return 'Employee Devices'; - default: - return startCase(key); - } - } - - private getNetworkLabel(key: string): string { - switch (key) { - case 'Upstream Emissions': - return 'Networking and Infrastructure Hardware'; - case 'Direct Emissions': - return 'Networking and Infrastructure'; - default: - return startCase(key); - } - } - - private getServerLabel(key: string): string { - switch (key) { - case 'Upstream Emissions': - return 'Servers and Storage Hardware'; - case 'Direct Emissions': - return 'Servers and Storage'; - default: - return startCase(key); - } - } } diff --git a/src/app/carbon-estimation/carbon-estimation.constants.ts b/src/app/carbon-estimation/carbon-estimation.constants.ts index 9be3a1a5..ce4ac6bc 100644 --- a/src/app/carbon-estimation/carbon-estimation.constants.ts +++ b/src/app/carbon-estimation/carbon-estimation.constants.ts @@ -51,23 +51,26 @@ const getCustomTooltip = (isPlaceholder: boolean) => { }; const getCustomDataLabel = (isPlaceholder: boolean) => { - const customDataLabel = (value: string | number | number[], { - seriesIndex, - dataPointIndex, - w, - }: { - seriesIndex: number; - dataPointIndex: number; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - w: any; - }) => { + const customDataLabel = ( + value: string | number | number[], + { + seriesIndex, + dataPointIndex, + w, + }: { + seriesIndex: number; + dataPointIndex: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + w: any; + } + ) => { const initialSeries = w.globals.initialSeries[seriesIndex]; const data = initialSeries.data[dataPointIndex]; - - return `${value} - ${isPlaceholder ? '?' : tooltipFormatter(data.y)}` - } + + return `${value} - ${isPlaceholder ? '?' : tooltipFormatter(data.y)}`; + }; return customDataLabel; -} +}; export const getBaseChartOptions = (isPlaceholder: boolean) => { const chartOptions: ChartOptions = { From 5afdc9b62b22d608bad10f8b66027655148325c2 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Thu, 18 Jul 2024 14:46:29 +0100 Subject: [PATCH 09/37] Style tabs --- .../carbon-estimation.component.html | 2 +- src/app/tab/tab-item/tab-item.component.css | 0 src/app/tab/tab-item/tab-item.component.ts | 1 - src/app/tab/tabs/tabs.component.css | 0 src/app/tab/tabs/tabs.component.html | 13 +++++++++---- src/sl-styles.css | 7 ++++++- src/styles.css | 5 +++++ 7 files changed, 21 insertions(+), 7 deletions(-) delete mode 100644 src/app/tab/tab-item/tab-item.component.css delete mode 100644 src/app/tab/tabs/tabs.component.css diff --git a/src/app/carbon-estimation/carbon-estimation.component.html b/src/app/carbon-estimation/carbon-estimation.component.html index b19aaabb..dd7ac551 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.html +++ b/src/app/carbon-estimation/carbon-estimation.component.html @@ -21,7 +21,7 @@

Estimations

- + +
@for (tab of tabs; track tab.title) { -
  • +
    {{ tab.title() }} -
  • +
    } - + diff --git a/src/sl-styles.css b/src/sl-styles.css index ac757069..f8a99951 100644 --- a/src/sl-styles.css +++ b/src/sl-styles.css @@ -32,4 +32,9 @@ input { .tce-button-calculate:hover, .tce-button-reset:hover, .tce-button-assumptions:hover, .tce-button-close:hover { background-color: var(--primary-turquoise); @apply tce-text-white -} \ No newline at end of file +} + +.tce-active-tab, .tce-active-tab:hover { + border-color: var(--primary-turquoise); + @apply tce-cursor-default +} diff --git a/src/styles.css b/src/styles.css index bcd5f382..69c14da8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -13,3 +13,8 @@ .tce-button-reset, .tce-button-assumptions { @apply tce-bg-slate-200 tce-text-slate-800 hover:tce-bg-slate-300 tce-rounded } + + +.tce-active-tab, .tce-active-tab:hover { + @apply tce-cursor-default tce-border-sky-800 +} From 39e8e2464f2bf7c90f6a91e7ac41316bd6084e2b Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Thu, 18 Jul 2024 14:47:03 +0100 Subject: [PATCH 10/37] make sure only ever 1 active tab --- src/app/tab/tabs/tabs.component.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/app/tab/tabs/tabs.component.ts b/src/app/tab/tabs/tabs.component.ts index a7115583..1c217dc6 100644 --- a/src/app/tab/tabs/tabs.component.ts +++ b/src/app/tab/tabs/tabs.component.ts @@ -1,4 +1,4 @@ -import { Component, ContentChildren, QueryList } from '@angular/core'; +import { AfterContentInit, Component, ContentChildren, QueryList } from '@angular/core'; import { TabItemComponent } from '../tab-item/tab-item.component'; @Component({ @@ -6,12 +6,21 @@ import { TabItemComponent } from '../tab-item/tab-item.component'; standalone: true, imports: [TabItemComponent], templateUrl: './tabs.component.html', - styleUrl: './tabs.component.css', }) -export class TabsComponent { +export class TabsComponent implements AfterContentInit { @ContentChildren(TabItemComponent) tabs!: QueryList; - selectTab(selectedTab: TabItemComponent) { + public ngAfterContentInit() { + const activeTabs = this.tabs.filter(tab => tab.active()); + + if (activeTabs.length === 0) { + this.selectTab(this.tabs.first); + } else if (activeTabs.length > 1) { + this.selectTab(activeTabs[0]); + } + } + + public selectTab(selectedTab: TabItemComponent) { this.tabs.filter(tab => tab.active()).forEach(tab => tab.active.set(false)); selectedTab.active.set(true); } From 24d947ca26e004682c78f898d20b8a208a5c9052 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Thu, 18 Jul 2024 14:47:22 +0100 Subject: [PATCH 11/37] add tab unit tests --- package-lock.json | 16 +++++++++ package.json | 1 + src/app/tab/tabs/tabs.component.spec.ts | 44 +++++++++++++++++-------- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index facbebc1..1f58bc51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jasmine-core": "~5.1.0", + "ng-mocks": "^14.13.0", "postcss": "^8.4.35", "prettier": "^3.2.5", "prettier-eslint": "^16.3.0", @@ -11673,6 +11674,21 @@ "rxjs": "^6.5.5 || ^7.4.0" } }, + "node_modules/ng-mocks": { + "version": "14.13.0", + "resolved": "https://registry.npmjs.org/ng-mocks/-/ng-mocks-14.13.0.tgz", + "integrity": "sha512-cQ6nUj/P+v7X52gYU6bAj/03iDKl2pzbPy2V0tx/d5lxME063Vxc190p6UPlJkbRIxcB+OqSALPgQvy83efzjw==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/help-me-mom" + }, + "peerDependencies": { + "@angular/common": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18", + "@angular/core": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18", + "@angular/forms": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18", + "@angular/platform-browser": "5.0.0-alpha - 5 || 6.0.0-alpha - 6 || 7.0.0-alpha - 7 || 8.0.0-alpha - 8 || 9.0.0-alpha - 9 || 10.0.0-alpha - 10 || 11.0.0-alpha - 11 || 12.0.0-alpha - 12 || 13.0.0-alpha - 13 || 14.0.0-alpha - 14 || 15.0.0-alpha - 15 || 16.0.0-alpha - 16 || 17.0.0-alpha - 17 || 18.0.0-alpha - 18" + } + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", diff --git a/package.json b/package.json index ad8dce67..6ace0ace 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "jasmine-core": "~5.1.0", + "ng-mocks": "^14.13.0", "postcss": "^8.4.35", "prettier": "^3.2.5", "prettier-eslint": "^16.3.0", diff --git a/src/app/tab/tabs/tabs.component.spec.ts b/src/app/tab/tabs/tabs.component.spec.ts index c2822189..5b97e8fe 100644 --- a/src/app/tab/tabs/tabs.component.spec.ts +++ b/src/app/tab/tabs/tabs.component.spec.ts @@ -1,23 +1,41 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - +import { TestBed } from '@angular/core/testing'; +import { MockRender, ngMocks } from 'ng-mocks'; import { TabsComponent } from './tabs.component'; +import { TabItemComponent } from '../tab-item/tab-item.component'; describe('TabsComponent', () => { - let component: TabsComponent; - let fixture: ComponentFixture; - beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TabsComponent] - }) - .compileComponents(); - - fixture = TestBed.createComponent(TabsComponent); - component = fixture.componentInstance; + imports: [TabsComponent, TabItemComponent], + }).compileComponents(); + }); + + it('should select 1st tab is no active tab', () => { + const fixture = MockRender(` + + Test + Test + `); + + const component = ngMocks.findInstance(TabsComponent); fixture.detectChanges(); + + expect(component.tabs.first.active()).toBeTrue(); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should select 1st active tab when multiple active tabs', () => { + const fixture = MockRender(` + + + + + `); + + const component = ngMocks.findInstance(TabsComponent); + fixture.detectChanges(); + + const activeTabs = component.tabs.filter(tab => tab.active()); + expect(activeTabs.length).toBe(1); + expect(activeTabs[0].title()).toBe('tab 2'); }); }); From f7a6e4d0092f3a1c3a4160f1a9076cb18c2989a8 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Thu, 18 Jul 2024 14:48:10 +0100 Subject: [PATCH 12/37] Update table styling --- .../carbon-estimation-table.component.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html index ec114c9a..f7f57590 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.html +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -9,19 +9,19 @@ @for (emissionsItem of emissions; track $index) { @if (!emissionsItem.parent) { - + {{ emissionsItem.category }} {{ emissionsItem.emissions }} } @else if (emissionsItem.display) { - +
    From 1068cfeb5065e478e8553e8daab475a4a9b4346f Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Thu, 18 Jul 2024 17:09:19 +0100 Subject: [PATCH 13/37] Fix treemap not rendering properly after resize --- .../carbon-estimation.component.html | 5 +++-- .../carbon-estimation.component.ts | 13 ++++++++++++- src/app/tab/tab-item/tab-item.component.ts | 6 ++++-- src/app/tab/tabs/tabs.component.ts | 1 + 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/app/carbon-estimation/carbon-estimation.component.html b/src/app/carbon-estimation/carbon-estimation.component.html index dd7ac551..21d74ed6 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.html +++ b/src/app/carbon-estimation/carbon-estimation.component.html @@ -22,12 +22,13 @@

    Estimations

    - + - + diff --git a/src/app/carbon-estimation/carbon-estimation.component.ts b/src/app/carbon-estimation/carbon-estimation.component.ts index 10bf764a..2980a3e2 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.ts +++ b/src/app/carbon-estimation/carbon-estimation.component.ts @@ -27,11 +27,13 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { public extraHeight = input(); @ViewChild('detailsPanel', { static: true, read: ElementRef }) detailsPanel!: ElementRef; + @ViewChild('treemap', { static: true }) treemap!: CarbonEstimationTreemapComponent; public chartHeight!: number; private estimatorBaseHeight = sumValues(estimatorHeights); private resizeSubscription!: Subscription; + private hasResized = true; constructor(private changeDetectorRef: ChangeDetectorRef) {} @@ -47,7 +49,8 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { this.resizeSubscription.unsubscribe(); } - onResize(innerHeight: number, innerWidth: number, screenHeight: number): void { + public onResize(innerHeight: number, innerWidth: number, screenHeight: number): void { + this.hasResized = true; this.chartHeight = this.getChartHeight(innerHeight, innerWidth, screenHeight); } @@ -56,6 +59,14 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { this.onResize(window.innerHeight, window.innerWidth, window.screen.height); } + public treemapSelected(): void { + if (this.hasResized) { + this.hasResized = false; + this.changeDetectorRef.detectChanges(); + this.treemap.chart?.updateOptions({}); + } + } + private getChartHeight(innerHeight: number, innerWidth: number, screenHeight: number): number { const expansionPanelHeight = this.detailsPanel.nativeElement.clientHeight; diff --git a/src/app/tab/tab-item/tab-item.component.ts b/src/app/tab/tab-item/tab-item.component.ts index 1f7114fd..81caa968 100644 --- a/src/app/tab/tab-item/tab-item.component.ts +++ b/src/app/tab/tab-item/tab-item.component.ts @@ -1,12 +1,14 @@ -import { Component, input, model } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, input, model, Output } from '@angular/core'; @Component({ selector: 'tab-item', standalone: true, - imports: [], + imports: [CommonModule], templateUrl: './tab-item.component.html', }) export class TabItemComponent { public active = model(false); public title = input.required(); + @Output() public tabSelected = new EventEmitter(); } diff --git a/src/app/tab/tabs/tabs.component.ts b/src/app/tab/tabs/tabs.component.ts index 1c217dc6..3c2a2075 100644 --- a/src/app/tab/tabs/tabs.component.ts +++ b/src/app/tab/tabs/tabs.component.ts @@ -23,5 +23,6 @@ export class TabsComponent implements AfterContentInit { public selectTab(selectedTab: TabItemComponent) { this.tabs.filter(tab => tab.active()).forEach(tab => tab.active.set(false)); selectedTab.active.set(true); + selectedTab.tabSelected.emit(); } } From 78870f82c8f3090857b08678ffe5459dc38a7d92 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Mon, 22 Jul 2024 11:27:14 +0100 Subject: [PATCH 14/37] Add table placeholder --- .../carbon-estimation-table.component.html | 26 ++-- .../carbon-estimation-table.component.ts | 116 +++++++++--------- .../carbon-estimation.constants.ts | 23 ++++ 3 files changed, 96 insertions(+), 69 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html index f7f57590..08a27bcb 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.html +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -6,33 +6,33 @@ - @for (emissionsItem of emissions; track $index) { - - @if (!emissionsItem.parent) { + @for (tableItem of tableData(); track $index) { + + @if (!tableItem.parent) { - {{ emissionsItem.category }} + {{ tableItem.category }} - {{ emissionsItem.emissions }} - } @else if (emissionsItem.display) { + {{ tableItem.emissions }} + } @else if (tableItem.display) {
    -
    + [style.background-color]="tableItem.colour.svg ?? tableItem.colour.background"> +
    - {{ emissionsItem.category }} + {{ tableItem.category }} - {{ emissionsItem.emissions }} + {{ tableItem.emissions }} } diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts index 2bb5f999..410c74ce 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -1,11 +1,15 @@ -import { Component, effect, input } from '@angular/core'; +import { Component, computed, effect, input } from '@angular/core'; import { CarbonEstimation } from '../types/carbon-estimator'; -import { EmissionsColours, EmissionsLabels } from '../carbon-estimation/carbon-estimation.constants'; +import { + EmissionsColours, + EmissionsLabels, + placeholderTableData, +} from '../carbon-estimation/carbon-estimation.constants'; import { CarbonEstimationUtilService } from '../services/carbon-estimation-util.service'; import { NumberObject } from '../utils/number-object'; import { NgClass, NgStyle } from '@angular/common'; -type TableItem = { +export type TableItem = { category: string; emissions: string; parent?: string; @@ -26,9 +30,11 @@ type ItemColour = { templateUrl: './carbon-estimation-table.component.html', }) export class CarbonEstimationTableComponent { - public carbonEstimation = input.required(); + public carbonEstimation = input(); public emissions: TableItem[] = []; + public tableData = computed(() => this.getTableData(this.carbonEstimation())); + public expanded: { [key: string]: boolean } = { [EmissionsLabels.Upstream]: true, [EmissionsLabels.Direct]: true, @@ -36,11 +42,7 @@ export class CarbonEstimationTableComponent { [EmissionsLabels.Downstream]: true, }; - constructor(private carbonEstimationUtilService: CarbonEstimationUtilService) { - effect(() => { - this.emissions = this.getEmissions(this.carbonEstimation()); - }); - } + constructor(private carbonEstimationUtilService: CarbonEstimationUtilService) {} public toggle(category: string): void { this.emissions.forEach(emission => { @@ -51,53 +53,55 @@ export class CarbonEstimationTableComponent { this.expanded[category] = !this.expanded[category]; } - public getEmissions(carbonEstimation: CarbonEstimation): TableItem[] { - return [ - { - category: EmissionsLabels.Upstream, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.upstreamEmissions), - colour: { background: EmissionsColours.Upstream }, - }, - ...this.getEmissionsBreakdown( - carbonEstimation.upstreamEmissions, - EmissionsLabels.Upstream, - EmissionsColours.Upstream, - EmissionsColours.UpstreamLight - ), - { - category: EmissionsLabels.Direct, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.directEmissions), - colour: { background: EmissionsColours.Direct }, - }, - ...this.getEmissionsBreakdown( - carbonEstimation.directEmissions, - EmissionsLabels.Direct, - EmissionsColours.Direct, - EmissionsColours.OperationLight - ), - { - category: EmissionsLabels.Indirect, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.indirectEmissions), - colour: { background: EmissionsColours.Indirect }, - }, - ...this.getEmissionsBreakdown( - carbonEstimation.indirectEmissions, - EmissionsLabels.Indirect, - EmissionsColours.Indirect, - EmissionsColours.OperationLight - ), - { - category: EmissionsLabels.Downstream, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.downstreamEmissions), - colour: { background: EmissionsColours.Downstream }, - }, - ...this.getEmissionsBreakdown( - carbonEstimation.downstreamEmissions, - EmissionsLabels.Downstream, - EmissionsColours.Downstream, - EmissionsColours.DownstreamLight - ), - ]; + public getTableData(carbonEstimation?: CarbonEstimation): TableItem[] { + return !carbonEstimation ? placeholderTableData : ( + [ + { + category: EmissionsLabels.Upstream, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.upstreamEmissions), + colour: { background: EmissionsColours.Upstream }, + }, + ...this.getEmissionsBreakdown( + carbonEstimation.upstreamEmissions, + EmissionsLabels.Upstream, + EmissionsColours.Upstream, + EmissionsColours.UpstreamLight + ), + { + category: EmissionsLabels.Direct, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.directEmissions), + colour: { background: EmissionsColours.Direct }, + }, + ...this.getEmissionsBreakdown( + carbonEstimation.directEmissions, + EmissionsLabels.Direct, + EmissionsColours.Direct, + EmissionsColours.OperationLight + ), + { + category: EmissionsLabels.Indirect, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.indirectEmissions), + colour: { background: EmissionsColours.Indirect }, + }, + ...this.getEmissionsBreakdown( + carbonEstimation.indirectEmissions, + EmissionsLabels.Indirect, + EmissionsColours.Indirect, + EmissionsColours.OperationLight + ), + { + category: EmissionsLabels.Downstream, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.downstreamEmissions), + colour: { background: EmissionsColours.Downstream }, + }, + ...this.getEmissionsBreakdown( + carbonEstimation.downstreamEmissions, + EmissionsLabels.Downstream, + EmissionsColours.Downstream, + EmissionsColours.DownstreamLight + ), + ] + ); } private getEmissionsBreakdown( diff --git a/src/app/carbon-estimation/carbon-estimation.constants.ts b/src/app/carbon-estimation/carbon-estimation.constants.ts index ce4ac6bc..62f7f9e5 100644 --- a/src/app/carbon-estimation/carbon-estimation.constants.ts +++ b/src/app/carbon-estimation/carbon-estimation.constants.ts @@ -1,3 +1,4 @@ +import { TableItem } from '../carbon-estimation-table/carbon-estimation-table.component'; import { ChartOptions } from '../types/carbon-estimator'; export enum EmissionsColours { @@ -190,3 +191,25 @@ export const placeholderData: ApexChartSeriesItem[] = [ ], }, ]; +export const placeholderTableData: TableItem[] = [ + { + category: EmissionsLabels.Upstream, + emissions: '?', + colour: { background: PlaceholderEmissionsColours.Upstream }, + }, + { + category: EmissionsLabels.Direct, + emissions: '?', + colour: { background: PlaceholderEmissionsColours.Direct }, + }, + { + category: EmissionsLabels.Indirect, + emissions: '?', + colour: { background: PlaceholderEmissionsColours.Indirect }, + }, + { + category: EmissionsLabels.Downstream, + emissions: '?', + colour: { background: PlaceholderEmissionsColours.Downstream }, + }, +]; From ab80298bb5c450e3ab8cfd896e5e2bfea5c0134a Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 23 Jul 2024 09:04:38 +0100 Subject: [PATCH 15/37] Update tab names --- src/app/carbon-estimation/carbon-estimation.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/carbon-estimation/carbon-estimation.component.html b/src/app/carbon-estimation/carbon-estimation.component.html index 21d74ed6..51703239 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.html +++ b/src/app/carbon-estimation/carbon-estimation.component.html @@ -22,13 +22,13 @@

    Estimations

    - + - + From 02080973882800066bad982313c3cfb8f01e8988 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 23 Jul 2024 15:58:41 +0100 Subject: [PATCH 16/37] Update tab names and make diagram main active tab --- src/app/carbon-estimation/carbon-estimation.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/carbon-estimation/carbon-estimation.component.html b/src/app/carbon-estimation/carbon-estimation.component.html index 51703239..673b9b15 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.html +++ b/src/app/carbon-estimation/carbon-estimation.component.html @@ -22,13 +22,13 @@

    Estimations

    - + - + From 4de2967740d8bc04f1e6c3cf73db377c29e16898 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 23 Jul 2024 15:59:27 +0100 Subject: [PATCH 17/37] Fix unit tests --- .../carbon-estimation-table.component.spec.ts | 8 ++++---- .../carbon-estimation-treemap.component.spec.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts index e88bc210..fa15f6d9 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts @@ -53,8 +53,8 @@ describe('CarbonEstimationTableComponent', () => { it('should toggle display value of child emissions when toggle called', () => { component.toggle('Upstream'); - expect(component.emissions[0].display).toBeFalse(); - component.emissions.forEach(emission => { + expect(component.tableData()[0].display).toBeFalse(); + component.tableData().forEach(emission => { if (emission.parent === 'Upstream') { expect(emission.display).toBeFalse(); } @@ -62,7 +62,7 @@ describe('CarbonEstimationTableComponent', () => { }); it('should set child emissions to display by default', () => { - component.emissions.forEach(emission => { + component.tableData().forEach(emission => { if (emission.parent) { expect(emission.display).toBeFalse(); } @@ -70,7 +70,7 @@ describe('CarbonEstimationTableComponent', () => { }); it('should get emissions when getEmissions called', () => { - const emissions = component.getEmissions(component.carbonEstimation()); + const emissions = component.getTableData(component.carbonEstimation()); expect(emissions.length).toBe(13); }); }); diff --git a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts index c9feae59..7448dab4 100644 --- a/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts +++ b/src/app/carbon-estimation-treemap/carbon-estimation-treemap.component.spec.ts @@ -133,11 +133,11 @@ describe('CarbonEstimationTreemapComponent', () => { }, ]; - expect(component.emissions).toEqual(expectedEmissions); + expect(component.chartData()).toEqual(expectedEmissions); }); it('should have detailed aria label', () => { - expect(component.emissionAriaLabel.length).toBeGreaterThan(25); + expect(component.emissionAriaLabel().length).toBeGreaterThan(25); }); it('should set label to <1% if emission is less than 1', () => { @@ -168,7 +168,7 @@ describe('CarbonEstimationTreemapComponent', () => { fixture.detectChanges(); - expect(component.emissions[0].name).toBe('Upstream Emissions - <1%'); + expect(component.chartData()[0].name).toBe('Upstream Emissions - <1%'); }); it('should remove categories when they are 0', () => { @@ -246,7 +246,7 @@ describe('CarbonEstimationTreemapComponent', () => { }, ]; - expect(component.emissions).toEqual(expectedEmissions); + expect(component.chartData()).toEqual(expectedEmissions); }); it('should remove parent categories when all values are 0', () => { @@ -302,6 +302,6 @@ describe('CarbonEstimationTreemapComponent', () => { }, ]; - expect(component.emissions).toEqual(expectedEmissions); + expect(component.chartData()).toEqual(expectedEmissions); }); }); From 364e7791b254d6a35d5952587c28fa49472ac315 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 23 Jul 2024 16:03:22 +0100 Subject: [PATCH 18/37] Fix row toggle, and make expanded state maintained --- .../carbon-estimation-table.component.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts index 410c74ce..877b6f7a 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -31,7 +31,6 @@ type ItemColour = { }) export class CarbonEstimationTableComponent { public carbonEstimation = input(); - public emissions: TableItem[] = []; public tableData = computed(() => this.getTableData(this.carbonEstimation())); @@ -45,7 +44,7 @@ export class CarbonEstimationTableComponent { constructor(private carbonEstimationUtilService: CarbonEstimationUtilService) {} public toggle(category: string): void { - this.emissions.forEach(emission => { + this.tableData().forEach(emission => { if (emission.parent === category) { emission.display = !emission.display; } @@ -65,7 +64,8 @@ export class CarbonEstimationTableComponent { carbonEstimation.upstreamEmissions, EmissionsLabels.Upstream, EmissionsColours.Upstream, - EmissionsColours.UpstreamLight + EmissionsColours.UpstreamLight, + this.expanded[EmissionsLabels.Upstream] ), { category: EmissionsLabels.Direct, @@ -76,7 +76,8 @@ export class CarbonEstimationTableComponent { carbonEstimation.directEmissions, EmissionsLabels.Direct, EmissionsColours.Direct, - EmissionsColours.OperationLight + EmissionsColours.OperationLight, + this.expanded[EmissionsLabels.Direct] ), { category: EmissionsLabels.Indirect, @@ -87,7 +88,8 @@ export class CarbonEstimationTableComponent { carbonEstimation.indirectEmissions, EmissionsLabels.Indirect, EmissionsColours.Indirect, - EmissionsColours.OperationLight + EmissionsColours.OperationLight, + this.expanded[EmissionsLabels.Indirect] ), { category: EmissionsLabels.Downstream, @@ -98,7 +100,8 @@ export class CarbonEstimationTableComponent { carbonEstimation.downstreamEmissions, EmissionsLabels.Downstream, EmissionsColours.Downstream, - EmissionsColours.DownstreamLight + EmissionsColours.DownstreamLight, + this.expanded[EmissionsLabels.Downstream] ), ] ); @@ -108,19 +111,20 @@ export class CarbonEstimationTableComponent { emissions: NumberObject, parent: string, svgColour: string, - backgroundColour: string + backgroundColour: string, + display = true ): TableItem[] { return ( Object.entries(emissions) // eslint-disable-next-line @typescript-eslint/no-unused-vars .filter(([_key, value]) => value !== 0) .map(([_key, value]) => - this.getTableItem(_key, value, parent, { background: backgroundColour, svg: svgColour }) + this.getTableItem(_key, value, parent, { background: backgroundColour, svg: svgColour }, display) ) ); } - private getTableItem(key: string, value: number, parent: string, colour: ItemColour): TableItem { + private getTableItem(key: string, value: number, parent: string, colour: ItemColour, display: boolean): TableItem { const { label, svg } = this.carbonEstimationUtilService.getLabelAndSvg(key, parent); return { category: label, @@ -128,7 +132,7 @@ export class CarbonEstimationTableComponent { parent, svg, colour, - display: true, + display, }; } } From 146341d20ff2203c06112d27aab2346216c6e270 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 23 Jul 2024 16:04:31 +0100 Subject: [PATCH 19/37] Make sure treemap re-render if values change --- .../carbon-estimation.component.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/app/carbon-estimation/carbon-estimation.component.ts b/src/app/carbon-estimation/carbon-estimation.component.ts index 2980a3e2..0eeafaf7 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.ts +++ b/src/app/carbon-estimation/carbon-estimation.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, ElementRef, input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, computed, ElementRef, input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ExpansionPanelComponent } from '../expansion-panel/expansion-panel.component'; import { TabsComponent } from '../tab/tabs/tabs.component'; import { TabItemComponent } from '../tab/tab-item/tab-item.component'; @@ -6,8 +6,9 @@ import { CarbonEstimationTreemapComponent } from '../carbon-estimation-treemap/c import { CarbonEstimation } from '../types/carbon-estimator'; import { sumValues } from '../utils/number-object'; import { estimatorHeights } from './carbon-estimation.constants'; -import { debounceTime, fromEvent, Subscription } from 'rxjs'; +import { debounceTime, fromEvent, Observable, Subscription } from 'rxjs'; import { CarbonEstimationTableComponent } from '../carbon-estimation-table/carbon-estimation-table.component'; +import { toObservable } from '@angular/core/rxjs-interop'; @Component({ selector: 'carbon-estimation', @@ -34,8 +35,14 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { private estimatorBaseHeight = sumValues(estimatorHeights); private resizeSubscription!: Subscription; private hasResized = true; + private hasEstimationUpdated = false; + private carbonEstimationSubscription?: Subscription; - constructor(private changeDetectorRef: ChangeDetectorRef) {} + constructor(private changeDetectorRef: ChangeDetectorRef) { + this.carbonEstimationSubscription = toObservable(this.carbonEstimation).subscribe(() => { + this.hasEstimationUpdated = true; + }); + } public ngOnInit(): void { this.chartHeight = this.getChartHeight(window.innerHeight, window.innerWidth, window.screen.height); @@ -47,6 +54,7 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { public ngOnDestroy(): void { this.resizeSubscription.unsubscribe(); + this.carbonEstimationSubscription?.unsubscribe(); } public onResize(innerHeight: number, innerWidth: number, screenHeight: number): void { @@ -60,8 +68,9 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { } public treemapSelected(): void { - if (this.hasResized) { + if (this.hasResized || this.hasEstimationUpdated) { this.hasResized = false; + this.hasEstimationUpdated = false; this.changeDetectorRef.detectChanges(); this.treemap.chart?.updateOptions({}); } From 2d233b52ebb68a978706cb1e7d5431435690f17b Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Wed, 24 Jul 2024 16:24:40 +0100 Subject: [PATCH 20/37] Make the table accessible --- .../carbon-estimation-table.component.html | 73 +++-- .../carbon-estimation-table.component.ts | 271 +++++++++++++++--- .../carbon-estimation.constants.ts | 16 ++ 3 files changed, 296 insertions(+), 64 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html index 08a27bcb..8a9a8574 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.html +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -1,41 +1,72 @@ - + +
    - + @for (tableItem of tableData(); track $index) { - - @if (!tableItem.parent) { - - - } @else if (tableItem.display) { - + + } @else { + + - - } - + + } }
    Category Emissions
    - + +
    - {{ expanded[tableItem.category] ? 'expand_less' : 'expand_more' }} - + class="material-icons-outlined hover:tce-opacity-50 tce-m-1 tce-cursor-pointer"> + {{ tableItem.expanded ? 'expand_less' : 'expand_more' }} +
    {{ tableItem.category }}
    {{ tableItem.emissions }} + + {{ tableItem.emissions }} +
    - - {{ tableItem.category }} - + {{ tableItem.category }}
    + {{ tableItem.emissions }}
    diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts index 877b6f7a..b4216ea9 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, effect, input } from '@angular/core'; +import { ChangeDetectorRef, Component, computed, input } from '@angular/core'; import { CarbonEstimation } from '../types/carbon-estimator'; import { EmissionsColours, @@ -12,10 +12,14 @@ import { NgClass, NgStyle } from '@angular/common'; export type TableItem = { category: string; emissions: string; + colour: ItemColour; + display: boolean; + positionInSet: number; + setSize: number; parent?: string; svg?: string; - colour: ItemColour; - display?: boolean; + expanded?: boolean; + level: number; }; type ItemColour = { @@ -23,6 +27,11 @@ type ItemColour = { background: string; }; +type ArrowDirection = ArrowDirectionHorizontal | ArrowDirectionVertical; + +type ArrowDirectionHorizontal = 'left' | 'right'; +type ArrowDirectionVertical = 'up' | 'down'; + @Component({ selector: 'carbon-estimation-table', standalone: true, @@ -34,74 +43,211 @@ export class CarbonEstimationTableComponent { public tableData = computed(() => this.getTableData(this.carbonEstimation())); - public expanded: { [key: string]: boolean } = { - [EmissionsLabels.Upstream]: true, - [EmissionsLabels.Direct]: true, - [EmissionsLabels.Indirect]: true, - [EmissionsLabels.Downstream]: true, - }; - - constructor(private carbonEstimationUtilService: CarbonEstimationUtilService) {} + constructor( + private carbonEstimationUtilService: CarbonEstimationUtilService, + private changeDetector: ChangeDetectorRef + ) {} public toggle(category: string): void { this.tableData().forEach(emission => { if (emission.parent === category) { emission.display = !emission.display; + } else if (emission.category === category) { + emission.expanded = !emission.expanded; } }); - this.expanded[category] = !this.expanded[category]; + } + + public parentRowArrowKeyBoardEvent( + keyBoardEvent: Event, + direction: ArrowDirectionHorizontal, + parent: string, + expanded?: boolean + ): void { + if ( + keyBoardEvent.target === keyBoardEvent.currentTarget && + ((direction === 'left' && expanded) || (direction === 'right' && !expanded)) + ) { + keyBoardEvent.preventDefault(); + this.toggle(parent); + } else { + this.arrowKeyBoardEvent(keyBoardEvent, direction); + } + } + + public arrowKeyBoardEvent(keyBoardEvent: Event, direction: ArrowDirection): void { + keyBoardEvent.preventDefault(); + const tagName = (keyBoardEvent.target as HTMLElement).tagName; + + if (tagName === 'TR') { + this.moveRowFocus(keyBoardEvent, direction); + } else if (tagName === 'TD') { + this.moveCellFocus(keyBoardEvent, direction); + } + } + + public homeEndKeyBoardEvent(event: Event, home: boolean): void { + const keyBoardEvent = event as KeyboardEvent; + keyBoardEvent.preventDefault(); + const tagName = (keyBoardEvent.target as HTMLElement).tagName; + + if (tagName === 'TR') { + // Row is focused + const target = keyBoardEvent.target as HTMLTableRowElement; + const table = target.parentElement as HTMLTableElement; + const newRow = home ? table.rows[0] : this.getLastVisibleRow(table); + this.setNewTabIndexAndFocus(newRow, target); + } else if (tagName === 'TD') { + // Cell is focused + const target = keyBoardEvent.target as HTMLTableCellElement; + const row = target.parentElement as HTMLTableRowElement; + + if (keyBoardEvent.ctrlKey) { + const table = row.parentElement as HTMLTableElement; + const newRow = home ? table.rows[0] : this.getLastVisibleRow(table); + const newCell = newRow.cells[target.cellIndex]; + this.setNewTabIndexAndFocus(newRow, row, false); + this.setNewTabIndexAndFocus(newCell, target); + } else { + const newCell = home ? row.cells[0] : row.cells[row.cells.length - 1]; + this.setNewTabIndexAndFocus(newCell, target); + } + } + } + + private moveRowFocus(keyBoardEvent: Event, direction: ArrowDirection): void { + if (direction === 'right') { + this.moveFocusToCell(keyBoardEvent); + } else if (direction === 'down' || direction === 'up') { + this.moveRowFocusVertically(keyBoardEvent, direction); + } + } + + private moveFocusToCell(keyBoardEvent: Event): void { + const target = keyBoardEvent.target as HTMLElement; + const cell = target.firstElementChild as HTMLElement; + cell.tabIndex = 0; + this.changeDetector.detectChanges(); + cell.focus(); + } + + private moveRowFocusVertically(keyBoardEvent: Event, direction: ArrowDirectionVertical): void { + const target = keyBoardEvent.target as HTMLElement; + const newRow = this.findNextVisibleRow(target as HTMLTableRowElement, direction); + if (newRow) { + this.setNewTabIndexAndFocus(newRow, target); + } + } + + private moveCellFocus(keyBoardEvent: Event, direction: ArrowDirection): void { + const target = keyBoardEvent.target as HTMLTableCellElement; + const currentRow = target.parentElement as HTMLTableRowElement; + let newCell: HTMLTableCellElement | undefined = undefined; + let newRow: HTMLTableRowElement | undefined = undefined; + + if (direction === 'left') { + newCell = target.previousElementSibling as HTMLTableCellElement; + } else if (direction === 'right') { + newCell = target.nextElementSibling as HTMLTableCellElement; + } else if (direction === 'up' || direction === 'down') { + newRow = this.findNextVisibleRow(currentRow, direction); + newCell = newRow?.cells[target.cellIndex] as HTMLTableCellElement; + } + + if (newCell) { + if (newRow) { + this.setNewTabIndexAndFocus(newRow, currentRow, false); + } + this.setNewTabIndexAndFocus(newCell, target); + } else if (direction === 'left') { + this.moveFocusToRow(keyBoardEvent); + } + } + + private moveFocusToRow(keyBoardEvent: Event): void { + const target = keyBoardEvent.target as HTMLElement; + const row = target.parentElement as HTMLElement; + target.tabIndex = -1; + this.changeDetector.detectChanges(); + row.focus(); + } + + private findNextVisibleRow( + row: HTMLTableRowElement, + direction: ArrowDirectionVertical + ): HTMLTableRowElement | undefined { + const newRow = (direction === 'up' ? row.previousElementSibling : row.nextElementSibling) as HTMLTableRowElement; + return newRow?.classList.contains('tce-hidden') ? this.findNextVisibleRow(newRow, direction) : newRow; + } + + private getLastVisibleRow(table: HTMLTableElement): HTMLTableRowElement { + const rows = Array.from(table.rows); + const lastVisibleRow = rows.reverse().find(row => !row.classList.contains('tce-hidden')); + return lastVisibleRow as HTMLTableRowElement; + } + + private setNewTabIndexAndFocus(newElement: HTMLElement, oldElement: HTMLElement, setFocus = true): void { + oldElement.tabIndex = -1; + newElement.tabIndex = 0; + if (setFocus) { + newElement.focus(); + } } public getTableData(carbonEstimation?: CarbonEstimation): TableItem[] { return !carbonEstimation ? placeholderTableData : ( [ - { - category: EmissionsLabels.Upstream, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.upstreamEmissions), - colour: { background: EmissionsColours.Upstream }, - }, + this.getParentTableItem( + EmissionsLabels.Upstream, + carbonEstimation.upstreamEmissions, + EmissionsColours.Upstream, + 1, + 4 + ), ...this.getEmissionsBreakdown( carbonEstimation.upstreamEmissions, EmissionsLabels.Upstream, EmissionsColours.Upstream, - EmissionsColours.UpstreamLight, - this.expanded[EmissionsLabels.Upstream] + EmissionsColours.UpstreamLight + ), + this.getParentTableItem( + EmissionsLabels.Direct, + carbonEstimation.directEmissions, + EmissionsColours.Direct, + 2, + 4 ), - { - category: EmissionsLabels.Direct, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.directEmissions), - colour: { background: EmissionsColours.Direct }, - }, ...this.getEmissionsBreakdown( carbonEstimation.directEmissions, EmissionsLabels.Direct, EmissionsColours.Direct, - EmissionsColours.OperationLight, - this.expanded[EmissionsLabels.Direct] + EmissionsColours.OperationLight + ), + this.getParentTableItem( + EmissionsLabels.Indirect, + carbonEstimation.indirectEmissions, + EmissionsColours.Indirect, + 3, + 4 ), - { - category: EmissionsLabels.Indirect, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.indirectEmissions), - colour: { background: EmissionsColours.Indirect }, - }, ...this.getEmissionsBreakdown( carbonEstimation.indirectEmissions, EmissionsLabels.Indirect, EmissionsColours.Indirect, - EmissionsColours.OperationLight, - this.expanded[EmissionsLabels.Indirect] + EmissionsColours.OperationLight + ), + this.getParentTableItem( + EmissionsLabels.Downstream, + carbonEstimation.downstreamEmissions, + EmissionsColours.Downstream, + 4, + 4 ), - { - category: EmissionsLabels.Downstream, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(carbonEstimation.downstreamEmissions), - colour: { background: EmissionsColours.Downstream }, - }, ...this.getEmissionsBreakdown( carbonEstimation.downstreamEmissions, EmissionsLabels.Downstream, EmissionsColours.Downstream, - EmissionsColours.DownstreamLight, - this.expanded[EmissionsLabels.Downstream] + EmissionsColours.DownstreamLight ), ] ); @@ -118,13 +264,49 @@ export class CarbonEstimationTableComponent { Object.entries(emissions) // eslint-disable-next-line @typescript-eslint/no-unused-vars .filter(([_key, value]) => value !== 0) - .map(([_key, value]) => - this.getTableItem(_key, value, parent, { background: backgroundColour, svg: svgColour }, display) + .map(([key, value], index, array) => + this.getChildTableItem( + key, + value, + parent, + { background: backgroundColour, svg: svgColour }, + display, + index + 1, + array.length + ) ) ); } - private getTableItem(key: string, value: number, parent: string, colour: ItemColour, display: boolean): TableItem { + private getParentTableItem( + label: string, + value: NumberObject, + colour: string, + positionInSet: number, + setSize: number, + expanded: boolean = true + ): TableItem { + return { + category: label, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(value), + colour: { background: colour }, + display: true, + expanded, + positionInSet, + setSize, + level: 1, + }; + } + + private getChildTableItem( + key: string, + value: number, + parent: string, + colour: ItemColour, + display: boolean, + positionInSet: number, + setSize: number + ): TableItem { const { label, svg } = this.carbonEstimationUtilService.getLabelAndSvg(key, parent); return { category: label, @@ -133,6 +315,9 @@ export class CarbonEstimationTableComponent { svg, colour, display, + positionInSet, + setSize, + level: 2, }; } } diff --git a/src/app/carbon-estimation/carbon-estimation.constants.ts b/src/app/carbon-estimation/carbon-estimation.constants.ts index 62f7f9e5..498ba5a3 100644 --- a/src/app/carbon-estimation/carbon-estimation.constants.ts +++ b/src/app/carbon-estimation/carbon-estimation.constants.ts @@ -196,20 +196,36 @@ export const placeholderTableData: TableItem[] = [ category: EmissionsLabels.Upstream, emissions: '?', colour: { background: PlaceholderEmissionsColours.Upstream }, + display: true, + positionInSet: 1, + setSize: 4, + level: 1, }, { category: EmissionsLabels.Direct, emissions: '?', colour: { background: PlaceholderEmissionsColours.Direct }, + display: true, + positionInSet: 1, + setSize: 4, + level: 1, }, { category: EmissionsLabels.Indirect, emissions: '?', colour: { background: PlaceholderEmissionsColours.Indirect }, + display: true, + positionInSet: 1, + setSize: 4, + level: 1, }, { category: EmissionsLabels.Downstream, emissions: '?', colour: { background: PlaceholderEmissionsColours.Downstream }, + display: true, + positionInSet: 1, + setSize: 4, + level: 1, }, ]; From fcf7566c56ef25f2abdddaceb8d7a01b1f9a7a5f Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Wed, 24 Jul 2024 16:24:54 +0100 Subject: [PATCH 21/37] Add spellings to workspace --- .vscode/settings.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 5bc8d200..4a1b97fa 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,12 @@ "typescript.tsdk": "node_modules\\typescript\\lib", "editor.tabSize": 2, "files.eol": "\n", - "cSpell.words": ["apexcharts", "treemap"] + "cSpell.words": [ + "apexcharts", + "arrowdown", + "arrowleft", + "arrowright", + "arrowup", + "treemap" + ] } From 415db35dd3eb6c1a3d624f1957308a6862baabc0 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Thu, 25 Jul 2024 09:34:43 +0100 Subject: [PATCH 22/37] Add unit test for keyboard interactions --- .../carbon-estimation-table.component.spec.ts | 309 +++++++++++++++++- 1 file changed, 295 insertions(+), 14 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts index fa15f6d9..853dbe99 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts @@ -1,24 +1,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { CarbonEstimationTableComponent } from './carbon-estimation-table.component'; +import { CarbonEstimationTableComponent, TableItem } from './carbon-estimation-table.component'; import { CarbonEstimationUtilService } from '../services/carbon-estimation-util.service'; import { CarbonEstimation } from '../types/carbon-estimator'; +import { EmissionsLabels } from '../carbon-estimation/carbon-estimation.constants'; describe('CarbonEstimationTableComponent', () => { let component: CarbonEstimationTableComponent; let fixture: ComponentFixture; - const utilSpy = jasmine.createSpyObj('CarbonEstimationUtilService', [ - 'getOverallPercentageLabel, getPercentageLabel, getLabelAndSvg', - ]); - - utilSpy.getOverallPercentageLabel.and.returnValue('7%'); - utilSpy.getPercentageLabel.and.returnValue('7%'); - utilSpy.getLabelAndSvg.and.returnValue({ label: 'Emissions', svg: 'svg' }); beforeEach(async () => { await TestBed.configureTestingModule({ imports: [CarbonEstimationTableComponent], - providers: [{ provide: CarbonEstimationUtilService, useValue: utilSpy }], + providers: [CarbonEstimationUtilService], }).compileComponents(); fixture = TestBed.createComponent(CarbonEstimationTableComponent); @@ -52,10 +46,13 @@ describe('CarbonEstimationTableComponent', () => { }); it('should toggle display value of child emissions when toggle called', () => { - component.toggle('Upstream'); - expect(component.tableData()[0].display).toBeFalse(); + component.toggle(EmissionsLabels.Upstream); + fixture.detectChanges(); component.tableData().forEach(emission => { - if (emission.parent === 'Upstream') { + if (emission.category === EmissionsLabels.Upstream) { + expect(emission.expanded).toBeFalse(); + } + if (emission.parent === EmissionsLabels.Upstream) { expect(emission.display).toBeFalse(); } }); @@ -64,13 +61,297 @@ describe('CarbonEstimationTableComponent', () => { it('should set child emissions to display by default', () => { component.tableData().forEach(emission => { if (emission.parent) { - expect(emission.display).toBeFalse(); + expect(emission.display).toBeTrue(); } }); }); it('should get emissions when getEmissions called', () => { const emissions = component.getTableData(component.carbonEstimation()); - expect(emissions.length).toBe(13); + expect(emissions.length).toBe(16); + }); + + it('should call toggle when left arrow clicked on expanded parent row', () => { + const toggleSpy = spyOn(component, 'toggle'); + const parentRow = fixture.debugElement.nativeElement.querySelector('tbody tr'); + + parentRow.focus(); + fixture.detectChanges(); + + component.parentRowArrowKeyBoardEvent( + { target: parentRow, currentTarget: parentRow, preventDefault: () => {} } as unknown as Event, + 'left', + EmissionsLabels.Upstream, + true + ); + + expect(toggleSpy).toHaveBeenCalled(); + }); + + it('should call toggle when right arrow clicked on none expanded parent row', () => { + const toggleSpy = spyOn(component, 'toggle'); + const parentRow = fixture.debugElement.nativeElement.querySelector('tbody tr'); + + parentRow.focus(); + fixture.detectChanges(); + component.parentRowArrowKeyBoardEvent( + { target: parentRow, currentTarget: parentRow, preventDefault: () => {} } as unknown as Event, + 'right', + EmissionsLabels.Upstream, + false + ); + + fixture.detectChanges(); + expect(toggleSpy).toHaveBeenCalled(); + }); + + it('should move focus to down row when down arrow clicked on row', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.querySelector('tr'); + row.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: row, preventDefault: () => {} } as unknown as Event, 'down'); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[1]); + }); + + it('should move focus to up row when up arrow clicked on row', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[2]; + row.tabIndex = 0; + fixture.detectChanges(); + row.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: row, preventDefault: () => {} } as unknown as Event, 'up'); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[1]); + }); + + it('should move focus to last row when end clicked on row', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + row.focus(); + fixture.detectChanges(); + + component.homeEndKeyBoardEvent({ target: row, preventDefault: () => {} } as unknown as Event, false); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[table.rows.length - 1]); + }); + + it('should move focus to first row when home clicked on row', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[2]; + row.tabIndex = 0; + fixture.detectChanges(); + row.focus(); + fixture.detectChanges(); + + component.homeEndKeyBoardEvent({ target: row, preventDefault: () => {} } as unknown as Event, true); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[0]); + }); + + it('should move focus to cell when right arrow clicked on row', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.querySelector('tr'); + row.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: row, preventDefault: () => {} } as unknown as Event, 'right'); + fixture.detectChanges(); + expect(document.activeElement).toBe(row.cells[0]); + }); + + it('should move focus to row when left arrow clicked on left hand cell', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + const cell = row.cells[0]; + cell.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, 'left'); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[0]); + }); + + it('should move focus to left cell when left arrow clicked on right hand cell', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + const cell = row.cells[1]; + cell.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, 'left'); + fixture.detectChanges(); + expect(document.activeElement).toBe(row.cells[0]); + }); + + it('should move focus to right cell when right arrow clicked on left hand cell', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + const cell = row.cells[0]; + cell.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, 'right'); + fixture.detectChanges(); + expect(document.activeElement).toBe(row.cells[1]); + }); + + it('should move focus to down a cell when down arrow clicked on cell', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + const cell = row.cells[0]; + cell.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, 'down'); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[row.sectionRowIndex + 1].cells[cell.cellIndex]); + }); + + it('should move focus to up a cell when up arrow clicked on cell', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[2]; + const cell = row.cells[1]; + cell.tabIndex = 0; + row.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, 'up'); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[row.sectionRowIndex - 1].cells[1]); + }); + + it('should move focus to last cell when end clicked on cell', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + const cell = row.cells[0]; + cell.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.homeEndKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, false); + fixture.detectChanges(); + expect(document.activeElement).toBe(row.cells[row.cells.length - 1]); + }); + + it('should move focus to first cell when home clicked on cell', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + const cell = row.cells[1]; + cell.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.homeEndKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, true); + fixture.detectChanges(); + expect(document.activeElement).toBe(row.cells[0]); + }); + + it('should move focus to corresponding cell in first row when control and home are clicked', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[4]; + const cell = row.cells[1]; + cell.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.homeEndKeyBoardEvent({ target: cell, preventDefault: () => {}, ctrlKey: true } as unknown as Event, true); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[0].cells[cell.cellIndex]); + }); + + it('should move focus to corresponding cell in last row when control and end are clicked', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[2]; + const cell = row.cells[0]; + cell.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.homeEndKeyBoardEvent( + { target: cell, preventDefault: () => {}, ctrlKey: true } as unknown as Event, + false + ); + fixture.detectChanges(); + expect(document.activeElement).toBe(table.rows[table.rows.length - 1].cells[cell.cellIndex]); + }); + + it('should move row to next visible row when up/down arrow clicked', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + component.toggle(EmissionsLabels.Upstream); + fixture.detectChanges(); + row.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: row, preventDefault: () => {} } as unknown as Event, 'down'); + fixture.detectChanges(); + expect(document.activeElement).not.toBe(table.rows[1]); + expect(document.activeElement).toBe( + table.rows[component.tableData().findIndex((emission: TableItem) => emission.category === EmissionsLabels.Direct)] + ); + }); + + it('should not move focus when focus is on right edge and press right arrow', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[1]; + const cell = row.cells[1]; + cell.tabIndex = 0; + row.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, 'right'); + fixture.detectChanges(); + expect(document.activeElement).toBe(cell); + }); + + it('should not move focus when focus is on top edge and press up arrow', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[0]; + const cell = row.cells[1]; + cell.tabIndex = 0; + row.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, 'up'); + fixture.detectChanges(); + expect(document.activeElement).toBe(cell); + }); + + it('should not move focus when focus is on bottom edge and press down arrow', () => { + const table = fixture.debugElement.nativeElement.querySelector('tbody'); + const row = table.rows[table.rows.length - 1]; + const cell = row.cells[1]; + cell.tabIndex = 0; + row.tabIndex = 0; + fixture.detectChanges(); + cell.focus(); + fixture.detectChanges(); + + component.arrowKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, 'down'); + fixture.detectChanges(); + expect(document.activeElement).toBe(cell); }); }); From cb005d535a85b83955fe8cfe9ed56f29e3023494 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Thu, 25 Jul 2024 15:20:27 +0100 Subject: [PATCH 23/37] Align table headings to be same as columns --- .../carbon-estimation-table.component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html index 8a9a8574..881be1fc 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.html +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -2,8 +2,8 @@ - - + + Date: Mon, 29 Jul 2024 09:40:12 +0100 Subject: [PATCH 24/37] Hide expand element from screen readers are cell handles expanding for keyboard users --- .../carbon-estimation-table.component.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html index 881be1fc..de23779b 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.html +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -33,6 +33,7 @@
    CategoryEmissionsCategoryEmissions
    - + - - @for (tableItem of tableData(); track $index) { - @if (!tableItem.parent) { - - + - - - } @else { - - - - + class="tce-flex tce-text-white tce-items-center" + (keydown.enter)="toggle(tableItem.category)"> + + + {{ tableItem.category }} + + + + } @else { + + + + + } } - } - + + } @else { + + + + + + }
    Category Emissions
    - -
    + @for (tableItem of tableData(); track $index) { + @if (tableItem.level === 1) { +
    - {{ tableItem.emissions }} -
    -
    -
    -
    - {{ tableItem.category }} -
    - {{ tableItem.emissions }} -
    + {{ tableItem.emissions }} +
    +
    +
    +
    + {{ tableItem.category }} +
    + {{ tableItem.emissions }} +
    No estimation available
    diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts index fe897bcd..d35bd0d7 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -1,10 +1,6 @@ import { ChangeDetectorRef, Component, computed, input } from '@angular/core'; import { CarbonEstimation } from '../types/carbon-estimator'; -import { - EmissionsColours, - EmissionsLabels, - placeholderTableData, -} from '../carbon-estimation/carbon-estimation.constants'; +import { EmissionsColours, EmissionsLabels } from '../carbon-estimation/carbon-estimation.constants'; import { CarbonEstimationUtilService } from '../services/carbon-estimation-util.service'; import { NumberObject } from '../utils/number-object'; import { NgClass, NgStyle } from '@angular/common'; @@ -195,9 +191,10 @@ export class CarbonEstimationTableComponent { } public getTableData(carbonEstimation?: CarbonEstimation): TableItem[] { - return !carbonEstimation ? placeholderTableData : ( - [ - this.getParentTableItem( + return !carbonEstimation ? + [] + : [ + ...this.getParentTableItems( EmissionsLabels.Upstream, carbonEstimation.upstreamEmissions, EmissionsColours.Upstream, diff --git a/src/app/carbon-estimation/carbon-estimation.constants.ts b/src/app/carbon-estimation/carbon-estimation.constants.ts index 498ba5a3..ce4ac6bc 100644 --- a/src/app/carbon-estimation/carbon-estimation.constants.ts +++ b/src/app/carbon-estimation/carbon-estimation.constants.ts @@ -1,4 +1,3 @@ -import { TableItem } from '../carbon-estimation-table/carbon-estimation-table.component'; import { ChartOptions } from '../types/carbon-estimator'; export enum EmissionsColours { @@ -191,41 +190,3 @@ export const placeholderData: ApexChartSeriesItem[] = [ ], }, ]; -export const placeholderTableData: TableItem[] = [ - { - category: EmissionsLabels.Upstream, - emissions: '?', - colour: { background: PlaceholderEmissionsColours.Upstream }, - display: true, - positionInSet: 1, - setSize: 4, - level: 1, - }, - { - category: EmissionsLabels.Direct, - emissions: '?', - colour: { background: PlaceholderEmissionsColours.Direct }, - display: true, - positionInSet: 1, - setSize: 4, - level: 1, - }, - { - category: EmissionsLabels.Indirect, - emissions: '?', - colour: { background: PlaceholderEmissionsColours.Indirect }, - display: true, - positionInSet: 1, - setSize: 4, - level: 1, - }, - { - category: EmissionsLabels.Downstream, - emissions: '?', - colour: { background: PlaceholderEmissionsColours.Downstream }, - display: true, - positionInSet: 1, - setSize: 4, - level: 1, - }, -]; From 4107078829238123cd51f3051f33df98de9e70bd Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Mon, 29 Jul 2024 12:03:45 +0100 Subject: [PATCH 28/37] Add maintained expanded state for parent and linked children --- .../carbon-estimation-table.component.ts | 82 ++++++++----------- 1 file changed, 36 insertions(+), 46 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts index d35bd0d7..f2dfacf0 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -39,6 +39,8 @@ export class CarbonEstimationTableComponent { public tableData = computed(() => this.getTableData(this.carbonEstimation())); + private expandedState: { [key: string]: boolean } = {}; + constructor( private carbonEstimationUtilService: CarbonEstimationUtilService, private changeDetector: ChangeDetectorRef @@ -48,6 +50,7 @@ export class CarbonEstimationTableComponent { this.tableData().forEach(emission => { if (emission.level === 2 && emission.parent === category) { emission.display = !emission.display; + this.expandedState[category] = emission.display; } else if (emission.level === 1 && emission.category === category) { emission.expanded = !emission.expanded; } @@ -198,56 +201,39 @@ export class CarbonEstimationTableComponent { EmissionsLabels.Upstream, carbonEstimation.upstreamEmissions, EmissionsColours.Upstream, + EmissionsColours.UpstreamLight, 1, - 4 - ), - ...this.getEmissionsBreakdown( - carbonEstimation.upstreamEmissions, - EmissionsLabels.Upstream, - EmissionsColours.Upstream, - EmissionsColours.UpstreamLight + 4, + this.expandedState[EmissionsLabels.Upstream] ), - this.getParentTableItem( + ...this.getParentTableItems( EmissionsLabels.Direct, carbonEstimation.directEmissions, EmissionsColours.Direct, + EmissionsColours.OperationLight, 2, - 4 - ), - ...this.getEmissionsBreakdown( - carbonEstimation.directEmissions, - EmissionsLabels.Direct, - EmissionsColours.Direct, - EmissionsColours.OperationLight + 4, + this.expandedState[EmissionsLabels.Direct] ), - this.getParentTableItem( + ...this.getParentTableItems( EmissionsLabels.Indirect, carbonEstimation.indirectEmissions, EmissionsColours.Indirect, + EmissionsColours.OperationLight, 3, - 4 - ), - ...this.getEmissionsBreakdown( - carbonEstimation.indirectEmissions, - EmissionsLabels.Indirect, - EmissionsColours.Indirect, - EmissionsColours.OperationLight + 4, + this.expandedState[EmissionsLabels.Indirect] ), - this.getParentTableItem( + ...this.getParentTableItems( EmissionsLabels.Downstream, carbonEstimation.downstreamEmissions, EmissionsColours.Downstream, + EmissionsColours.DownstreamLight, 4, - 4 - ), - ...this.getEmissionsBreakdown( - carbonEstimation.downstreamEmissions, - EmissionsLabels.Downstream, - EmissionsColours.Downstream, - EmissionsColours.DownstreamLight + 4, + this.expandedState[EmissionsLabels.Downstream] ), - ] - ); + ]; } private getEmissionsBreakdown( @@ -275,24 +261,28 @@ export class CarbonEstimationTableComponent { ); } - private getParentTableItem( + private getParentTableItems( label: string, value: NumberObject, - colour: string, + parentColour: string, + childColour: string, positionInSet: number, setSize: number, expanded: boolean = true - ): TableItem { - return { - category: label, - emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(value), - colour: { background: colour }, - display: true, - expanded, - positionInSet, - setSize, - level: 1, - }; + ): TableItem[] { + return [ + { + category: label, + emissions: this.carbonEstimationUtilService.getOverallPercentageLabel(value), + colour: { background: parentColour }, + display: true, + expanded, + positionInSet, + setSize, + level: 1, + }, + ...this.getEmissionsBreakdown(value, label, parentColour, childColour, expanded), + ]; } private getChildTableItem( From 778aa9a35efd6025274d2eccc13ff6a38a4045be Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Mon, 29 Jul 2024 12:16:44 +0100 Subject: [PATCH 29/37] Added dark mode text colour for table --- .../carbon-estimation-table.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html index 5fc5f8f2..3dffecac 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.html +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -56,7 +56,7 @@ (keydown.arrowright)="arrowKeyBoardEvent($event, 'right')" (keydown.arrowleft)="arrowKeyBoardEvent($event, 'left')" [class.tce-hidden]="!tableItem.display"> - +
    From 458945b8ed60e96177e6d3fa549cbad0a16d7f4a Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Mon, 29 Jul 2024 12:21:37 +0100 Subject: [PATCH 30/37] Fix table unit test since adding table levels --- .../carbon-estimation-table.component.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts index 853dbe99..827b34b9 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts @@ -49,10 +49,10 @@ describe('CarbonEstimationTableComponent', () => { component.toggle(EmissionsLabels.Upstream); fixture.detectChanges(); component.tableData().forEach(emission => { - if (emission.category === EmissionsLabels.Upstream) { + if (emission.level === 1 && emission.category === EmissionsLabels.Upstream) { expect(emission.expanded).toBeFalse(); } - if (emission.parent === EmissionsLabels.Upstream) { + if (emission.level === 2 && emission.parent === EmissionsLabels.Upstream) { expect(emission.display).toBeFalse(); } }); @@ -60,7 +60,7 @@ describe('CarbonEstimationTableComponent', () => { it('should set child emissions to display by default', () => { component.tableData().forEach(emission => { - if (emission.parent) { + if (emission.level === 2 && emission.parent) { expect(emission.display).toBeTrue(); } }); From c3251e67bc091797d968ade16202cb0beb561af2 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Mon, 29 Jul 2024 16:55:49 +0100 Subject: [PATCH 31/37] Change getPercentageLabel to call tooltipFormatter --- src/app/services/carbon-estimation-util.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/services/carbon-estimation-util.service.ts b/src/app/services/carbon-estimation-util.service.ts index cfb2b06b..17687660 100644 --- a/src/app/services/carbon-estimation-util.service.ts +++ b/src/app/services/carbon-estimation-util.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { NumberObject, sumValues } from '../utils/number-object'; import { startCase } from 'lodash-es'; -import { SVG } from '../carbon-estimation/carbon-estimation.constants'; +import { SVG, tooltipFormatter } from '../carbon-estimation/carbon-estimation.constants'; @Injectable({ providedIn: 'root', @@ -14,7 +14,7 @@ export class CarbonEstimationUtilService { return this.getPercentageLabel(percentage); }; - public getPercentageLabel = (percentage: number): string => (percentage < 1 ? '<1%' : Math.round(percentage) + '%'); + public getPercentageLabel = tooltipFormatter; public getLabelAndSvg(key: string, parent: string = ''): { label: string; svg: string } { switch (key) { From 99b1aa684b5a420ef0bffe727579ee226e0d51fe Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Mon, 29 Jul 2024 16:57:12 +0100 Subject: [PATCH 32/37] Change hasUpdated to be changed from an observable to within an effect function --- src/app/carbon-estimation/carbon-estimation.component.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app/carbon-estimation/carbon-estimation.component.ts b/src/app/carbon-estimation/carbon-estimation.component.ts index 2c770a7c..168f5600 100644 --- a/src/app/carbon-estimation/carbon-estimation.component.ts +++ b/src/app/carbon-estimation/carbon-estimation.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectorRef, Component, ElementRef, input, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, effect, ElementRef, input, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ExpansionPanelComponent } from '../expansion-panel/expansion-panel.component'; import { TabsComponent } from '../tab/tabs/tabs.component'; import { TabItemComponent } from '../tab/tab-item/tab-item.component'; @@ -8,7 +8,6 @@ import { sumValues } from '../utils/number-object'; import { estimatorHeights } from './carbon-estimation.constants'; import { debounceTime, fromEvent, Subscription } from 'rxjs'; import { CarbonEstimationTableComponent } from '../carbon-estimation-table/carbon-estimation-table.component'; -import { toObservable } from '@angular/core/rxjs-interop'; @Component({ selector: 'carbon-estimation', @@ -36,10 +35,10 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { private resizeSubscription!: Subscription; private hasResized = true; private hasEstimationUpdated = false; - private carbonEstimationSubscription?: Subscription; constructor(private changeDetectorRef: ChangeDetectorRef) { - this.carbonEstimationSubscription = toObservable(this.carbonEstimation).subscribe(() => { + effect(() => { + this.carbonEstimation(); this.hasEstimationUpdated = true; }); } @@ -54,7 +53,6 @@ export class CarbonEstimationComponent implements OnInit, OnDestroy { public ngOnDestroy(): void { this.resizeSubscription.unsubscribe(); - this.carbonEstimationSubscription?.unsubscribe(); } public onResize(innerHeight: number, innerWidth: number, screenHeight: number): void { From 8e796ba304065f2e4cb89f9176ec76a111728690 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Mon, 29 Jul 2024 16:59:00 +0100 Subject: [PATCH 33/37] Add effect to tab to handle tab being selected event emitted, instead of Tabs component emitting value --- src/app/tab/tab-item/tab-item.component.ts | 10 +++++++++- src/app/tab/tabs/tabs.component.ts | 1 - 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app/tab/tab-item/tab-item.component.ts b/src/app/tab/tab-item/tab-item.component.ts index 81caa968..d40fb03a 100644 --- a/src/app/tab/tab-item/tab-item.component.ts +++ b/src/app/tab/tab-item/tab-item.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, EventEmitter, input, model, Output } from '@angular/core'; +import { Component, effect, EventEmitter, input, model, Output } from '@angular/core'; @Component({ selector: 'tab-item', @@ -11,4 +11,12 @@ export class TabItemComponent { public active = model(false); public title = input.required(); @Output() public tabSelected = new EventEmitter(); + + constructor() { + effect(() => { + if (this.active()) { + this.tabSelected.emit(); + } + }); + } } diff --git a/src/app/tab/tabs/tabs.component.ts b/src/app/tab/tabs/tabs.component.ts index 3c2a2075..1c217dc6 100644 --- a/src/app/tab/tabs/tabs.component.ts +++ b/src/app/tab/tabs/tabs.component.ts @@ -23,6 +23,5 @@ export class TabsComponent implements AfterContentInit { public selectTab(selectedTab: TabItemComponent) { this.tabs.filter(tab => tab.active()).forEach(tab => tab.active.set(false)); selectedTab.active.set(true); - selectedTab.tabSelected.emit(); } } From 84fad3f060529a6917e713c9ab3bc632afbca4e8 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 30 Jul 2024 14:40:45 +0100 Subject: [PATCH 34/37] Move setting expand state so only once per toggle --- .../carbon-estimation-table.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts index f2dfacf0..06abfa59 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -50,9 +50,9 @@ export class CarbonEstimationTableComponent { this.tableData().forEach(emission => { if (emission.level === 2 && emission.parent === category) { emission.display = !emission.display; - this.expandedState[category] = emission.display; } else if (emission.level === 1 && emission.category === category) { emission.expanded = !emission.expanded; + this.expandedState[category] = emission.expanded; } }); } From db665cda708b40e6dcef463419d5514648d8c5f9 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 30 Jul 2024 14:55:25 +0100 Subject: [PATCH 35/37] Update home ENd key function to pull if home or end fom event --- .../carbon-estimation-table.component.html | 8 +++--- .../carbon-estimation-table.component.spec.ts | 25 ++++++++++++------- .../carbon-estimation-table.component.ts | 9 ++++--- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html index 3dffecac..b0b0c6a9 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.html +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -8,10 +8,10 @@ @if (carbonEstimation()) { @for (tableItem of tableData(); track $index) { diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts index 827b34b9..6de436be 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts @@ -135,7 +135,7 @@ describe('CarbonEstimationTableComponent', () => { row.focus(); fixture.detectChanges(); - component.homeEndKeyBoardEvent({ target: row, preventDefault: () => {} } as unknown as Event, false); + component.homeEndKeyBoardEvent({ target: row, preventDefault: () => {}, key: 'End' } as unknown as Event); fixture.detectChanges(); expect(document.activeElement).toBe(table.rows[table.rows.length - 1]); }); @@ -148,7 +148,7 @@ describe('CarbonEstimationTableComponent', () => { row.focus(); fixture.detectChanges(); - component.homeEndKeyBoardEvent({ target: row, preventDefault: () => {} } as unknown as Event, true); + component.homeEndKeyBoardEvent({ target: row, preventDefault: () => {}, key: 'Home' } as unknown as Event); fixture.detectChanges(); expect(document.activeElement).toBe(table.rows[0]); }); @@ -244,7 +244,7 @@ describe('CarbonEstimationTableComponent', () => { cell.focus(); fixture.detectChanges(); - component.homeEndKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, false); + component.homeEndKeyBoardEvent({ target: cell, preventDefault: () => {}, key: 'End' } as unknown as Event); fixture.detectChanges(); expect(document.activeElement).toBe(row.cells[row.cells.length - 1]); }); @@ -258,7 +258,7 @@ describe('CarbonEstimationTableComponent', () => { cell.focus(); fixture.detectChanges(); - component.homeEndKeyBoardEvent({ target: cell, preventDefault: () => {} } as unknown as Event, true); + component.homeEndKeyBoardEvent({ target: cell, preventDefault: () => {}, key: 'Home' } as unknown as Event); fixture.detectChanges(); expect(document.activeElement).toBe(row.cells[0]); }); @@ -272,7 +272,12 @@ describe('CarbonEstimationTableComponent', () => { cell.focus(); fixture.detectChanges(); - component.homeEndKeyBoardEvent({ target: cell, preventDefault: () => {}, ctrlKey: true } as unknown as Event, true); + component.homeEndKeyBoardEvent({ + target: cell, + preventDefault: () => {}, + ctrlKey: true, + key: 'Home', + } as unknown as Event); fixture.detectChanges(); expect(document.activeElement).toBe(table.rows[0].cells[cell.cellIndex]); }); @@ -286,10 +291,12 @@ describe('CarbonEstimationTableComponent', () => { cell.focus(); fixture.detectChanges(); - component.homeEndKeyBoardEvent( - { target: cell, preventDefault: () => {}, ctrlKey: true } as unknown as Event, - false - ); + component.homeEndKeyBoardEvent({ + target: cell, + preventDefault: () => {}, + ctrlKey: true, + key: 'End', + } as unknown as Event); fixture.detectChanges(); expect(document.activeElement).toBe(table.rows[table.rows.length - 1].cells[cell.cellIndex]); }); diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts index 06abfa59..594f9be1 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -85,8 +85,9 @@ export class CarbonEstimationTableComponent { } } - public homeEndKeyBoardEvent(event: Event, home: boolean): void { + public homeEndKeyBoardEvent(event: Event): void { const keyBoardEvent = event as KeyboardEvent; + const isHomeKey = keyBoardEvent.key === 'Home'; keyBoardEvent.preventDefault(); const tagName = (keyBoardEvent.target as HTMLElement).tagName; @@ -94,7 +95,7 @@ export class CarbonEstimationTableComponent { // Row is focused const target = keyBoardEvent.target as HTMLTableRowElement; const table = target.parentElement as HTMLTableElement; - const newRow = home ? table.rows[0] : this.getLastVisibleRow(table); + const newRow = isHomeKey ? table.rows[0] : this.getLastVisibleRow(table); this.setNewTabIndexAndFocus(newRow, target); } else if (tagName === 'TD') { // Cell is focused @@ -103,12 +104,12 @@ export class CarbonEstimationTableComponent { if (keyBoardEvent.ctrlKey) { const table = row.parentElement as HTMLTableElement; - const newRow = home ? table.rows[0] : this.getLastVisibleRow(table); + const newRow = isHomeKey ? table.rows[0] : this.getLastVisibleRow(table); const newCell = newRow.cells[target.cellIndex]; this.setNewTabIndexAndFocus(newRow, row, false); this.setNewTabIndexAndFocus(newCell, target); } else { - const newCell = home ? row.cells[0] : row.cells[row.cells.length - 1]; + const newCell = isHomeKey ? row.cells[0] : row.cells[row.cells.length - 1]; this.setNewTabIndexAndFocus(newCell, target); } } From 923d64736da6ba8c0e7a89ea2659cf7eb669b5c0 Mon Sep 17 00:00:00 2001 From: Jareth Main Date: Tue, 30 Jul 2024 14:56:53 +0100 Subject: [PATCH 36/37] Change empty table check to use table data --- .../carbon-estimation-table.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.html b/src/app/carbon-estimation-table/carbon-estimation-table.component.html index b0b0c6a9..94b21ff4 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.html +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.html @@ -6,7 +6,7 @@ Emissions - @if (carbonEstimation()) { + @if (tableData().length > 0) { Date: Tue, 30 Jul 2024 15:00:58 +0100 Subject: [PATCH 37/37] Change getTableData to private --- .../carbon-estimation-table.component.spec.ts | 4 ++-- .../carbon-estimation-table.component.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts index 6de436be..4da6ea57 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.spec.ts @@ -67,8 +67,8 @@ describe('CarbonEstimationTableComponent', () => { }); it('should get emissions when getEmissions called', () => { - const emissions = component.getTableData(component.carbonEstimation()); - expect(emissions.length).toBe(16); + const tableData = component.tableData(); + expect(tableData.length).toBe(16); }); it('should call toggle when left arrow clicked on expanded parent row', () => { diff --git a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts index 594f9be1..712345a2 100644 --- a/src/app/carbon-estimation-table/carbon-estimation-table.component.ts +++ b/src/app/carbon-estimation-table/carbon-estimation-table.component.ts @@ -194,7 +194,7 @@ export class CarbonEstimationTableComponent { } } - public getTableData(carbonEstimation?: CarbonEstimation): TableItem[] { + private getTableData(carbonEstimation?: CarbonEstimation): TableItem[] { return !carbonEstimation ? [] : [