diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 5b7c36189f..01e597399d 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -49,6 +49,7 @@ jobs: components/assets components/autonumeric components/avatar + components/charts components/code-examples components/colorpicker components/config diff --git a/apps/playground/src/app/app.routes.ts b/apps/playground/src/app/app.routes.ts index 06b9b486c7..6dc73027d1 100644 --- a/apps/playground/src/app/app.routes.ts +++ b/apps/playground/src/app/app.routes.ts @@ -4,6 +4,7 @@ export const routes: Routes = [ { path: '', loadChildren: () => import('./home/home.module').then((m) => m.HomeModule), + title: 'Home', }, { path: 'components', diff --git a/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo-routes.ts b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo-routes.ts new file mode 100644 index 0000000000..0127539cd6 --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo-routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; + +import { ChartBarDemoComponent } from './chart-bar-demo.component'; + +const BAR_CHART_ROUTES: Routes = [ + { + path: '', + component: ChartBarDemoComponent, + title: 'Charts - Bar chart demo', + data: { + name: 'Bar chart', + icon: 'bar-chart-horizontal', + library: 'charts', + }, + }, +]; + +export default BAR_CHART_ROUTES; diff --git a/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.html b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.html new file mode 100644 index 0000000000..d1bc2a8632 --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.html @@ -0,0 +1,1116 @@ + + + + +
+ + + + +
+ + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of linear.multiSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of linear.stacked; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + + + @for (point of log.singleSeries[0].data; track $index) { + + } + + + + + + + + + + + + + + + + + @for (series of log.multiSeries; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of log.stacked; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + + + @for ( + series of density.single1x3; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.single1x6; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.single1x9; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.single1x12; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + @for ( + series of density.multi3x8; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.multi6x8; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.multi9x8; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.multi12x8; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + @for ( + series of density.stacked3x3; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.stacked3x6; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.stacked3x9; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.stacked3x12; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + +
+
+ +Help content diff --git a/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.ts b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.ts new file mode 100644 index 0000000000..15543173c3 --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.ts @@ -0,0 +1,174 @@ +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { + SkyChartBarComponent, + type SkyChartBarDatum, + SkyChartBarSeriesComponent, + SkyChartBarSeriesDataPointComponent, + SkyChartCategoryAxisComponent, + SkyChartComponent, + type SkyChartDataPointClickArgs, + SkyChartMeasureAxisComponent, +} from '@skyux/charts'; +import { SkyRadioModule } from '@skyux/forms'; +import { SkyBoxModule } from '@skyux/layout'; +import { SkyFluidGridModule } from '@skyux/layout'; +import { SkyPageModule } from '@skyux/pages'; +import { SkyTabsModule } from '@skyux/tabs'; + +import { ChartDemoUtils } from '../shared/chart-demo-utils'; + +@Component({ + selector: 'app-chart-bar-demo', + templateUrl: './chart-bar-demo.component.html', + styles: [], + imports: [ + FormsModule, + SkyPageModule, + SkyChartBarComponent, + SkyBoxModule, + SkyRadioModule, + SkyTabsModule, + SkyFluidGridModule, + SkyChartBarComponent, + SkyChartBarSeriesComponent, + SkyChartBarSeriesDataPointComponent, + SkyChartCategoryAxisComponent, + SkyChartMeasureAxisComponent, + SkyChartComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChartBarDemoComponent { + protected readonly orientation = signal<'vertical' | 'horizontal'>( + 'vertical', + ); + + protected readonly linear = { + singleSeries: [ + { + labelText: 'Spending', + data: [ + { category: 'January', label: '$50,000', value: 50_000 }, + { category: 'February', label: '$100,000', value: 100_000 }, + { category: 'March', label: '$150,000', value: 150_000 }, + { category: 'April', label: '$200,000', value: 200_000 }, + { category: 'May', label: '$250,000', value: 250_000 }, + { category: 'June', label: '$300,000', value: 300_000 }, + { category: 'July', label: '$350,000', value: 350_000 }, + { category: 'August', label: '$400,000', value: 400_000 }, + { category: 'September', label: '$450,000', value: 450_000 }, + { category: 'October', label: '$500,000', value: 500_000 }, + { category: 'November', label: '$550,000', value: 550_000 }, + { category: 'December', label: '$575,000', value: 575_000 }, + ], + }, + ], + multiSeries: [ + { + labelText: 'Budget', + data: [ + { category: 'Revenue', label: '$120,000', value: 120_000 }, + { category: 'Expense', label: '$85,000', value: 85_000 }, + ], + }, + { + labelText: 'Actuals', + data: [ + { category: 'Revenue', label: '$115,000', value: 115_000 }, + { category: 'Expense', label: '$78,000', value: 78_000 }, + ], + }, + ], + stacked: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + categories: ChartDemoUtils.months({ count: 7 }), + min: 0, + max: 100, + }), + }; + + protected readonly log = { + singleSeries: [ + { + labelText: 'Dataset 1', + data: [1, 1.1, 1.9, 2.1, 4.9, 5.1, 9, 11, 90, 110, 900, 1100, 9000].map( + (value, index) => ({ + category: 'Cat-' + (index + 1), + label: `$${value}`, + value, + }), + ), + }, + ], + multiSeries: ChartDemoUtils.createRandomSeries({ + labels: ['2024', '2025'], + categories: ChartDemoUtils.months({ count: 6 }), + min: 1, + max: 100_000, + }), + stacked: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + categories: ChartDemoUtils.months({ count: 7 }), + min: 1, + max: 100, + }), + }; + + protected readonly density = { + single1x3: ChartDemoUtils.createRandomSeries({ + seriesCount: 1, + dataCount: 3, + }), + single1x6: ChartDemoUtils.createRandomSeries({ + seriesCount: 1, + dataCount: 6, + }), + single1x9: ChartDemoUtils.createRandomSeries({ + seriesCount: 1, + dataCount: 9, + }), + single1x12: ChartDemoUtils.createRandomSeries({ + seriesCount: 1, + dataCount: 12, + }), + multi3x8: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 8, + }), + multi6x8: ChartDemoUtils.createRandomSeries({ + seriesCount: 6, + dataCount: 8, + }), + multi9x8: ChartDemoUtils.createRandomSeries({ + seriesCount: 9, + dataCount: 8, + }), + multi12x8: ChartDemoUtils.createRandomSeries({ + seriesCount: 12, + dataCount: 8, + }), + stacked3x3: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 3, + }), + stacked3x6: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 6, + }), + stacked3x9: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 9, + }), + stacked3x12: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 12, + }), + }; + + public onDataPointClick( + event: SkyChartDataPointClickArgs, + ): void { + console.log(JSON.stringify(event, null, 2)); + } +} diff --git a/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo-routes.ts b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo-routes.ts new file mode 100644 index 0000000000..a9a3215788 --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo-routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; + +import { ChartDonutDemoComponent } from './chart-donut-demo.component'; + +const DONUT_CHART_ROUTES: Routes = [ + { + path: '', + component: ChartDonutDemoComponent, + title: 'Charts - Donut chart demo', + data: { + name: 'Donut chart', + icon: 'donut-chart', + library: 'charts', + }, + }, +]; + +export default DONUT_CHART_ROUTES; diff --git a/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.html b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.html new file mode 100644 index 0000000000..80d47b5d3f --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.html @@ -0,0 +1,328 @@ + + + + + + + + + + + + + + + + @for (item of chart1; track $index) { + + } + + + + + + + + + + + + + + + + + + + + @for (item of density.three; track $index) { + + } + + + + + + + + + + + + + + @for (item of density.six; track $index) { + + } + + + + + + + + + + + + + + @for (item of density.nine; track $index) { + + } + + + + + + + + + + + + + + @for (item of density.twelve; track $index) { + + } + + + + + + + + + + + + + + + + + + + + @for (item of chart1; track $index) { + + } + + + + + + + + + + + + + + @for (item of chart1; track $index) { + + } + + + + + + + + + + + + + + @for (item of chart1; track $index) { + + } + + + + + + + + + + + + + + @for (item of chart1; track $index) { + + } + + + + + + + + + + + + diff --git a/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.ts b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.ts new file mode 100644 index 0000000000..2646039985 --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.ts @@ -0,0 +1,75 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + SkyChartComponent, + type SkyChartDataPointClickArgs, + SkyChartDonutComponent, + SkyChartDonutDatum, + SkyChartDonutSeriesComponent, + SkyChartDonutSeriesDataPointComponent, +} from '@skyux/charts'; +import { SkyBoxModule, SkyFluidGridModule } from '@skyux/layout'; +import { SkyPageModule } from '@skyux/pages'; +import { SkyTabsModule } from '@skyux/tabs'; + +import { + ChartDemoUtils, + type DemoSeriesData, +} from '../shared/chart-demo-utils'; + +@Component({ + selector: 'app-chart-donut-demo', + templateUrl: 'chart-donut-demo.component.html', + styles: [], + imports: [ + SkyPageModule, + SkyTabsModule, + SkyChartDonutComponent, + SkyBoxModule, + SkyFluidGridModule, + SkyChartComponent, + SkyChartDonutComponent, + SkyChartDonutSeriesComponent, + SkyChartDonutSeriesDataPointComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChartDonutDemoComponent { + protected chart1: DemoSeriesData[] = [ + { + category: 'Securities', + value: 5_000_000, + label: '$5,000,000', + }, + { + category: 'Income/Compensation', + value: 2_500_000, + label: '$2,500,000', + }, + { + category: 'Private Co. Valuation', + value: 1_500_000, + label: '$1,500,000', + }, + { + category: 'Real Estate', + value: 1_000_000, + label: '$1,000,000', + }, + ]; + + // #region Data density + protected readonly density = { + three: ChartDemoUtils.createRandomData({ count: 3 }), + six: ChartDemoUtils.createRandomData({ count: 6 }), + nine: ChartDemoUtils.createRandomData({ count: 9 }), + twelve: ChartDemoUtils.createRandomData({ count: 12 }), + }; + + // #endregion + + public onDataPointClick( + event: SkyChartDataPointClickArgs, + ): void { + console.log(JSON.stringify(event, null, 2)); + } +} diff --git a/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo-routes.ts b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo-routes.ts new file mode 100644 index 0000000000..e105de0773 --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo-routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; + +import { ChartLineDemoComponent } from './chart-line-demo.component'; + +const LINE_CHART_ROUTES: Routes = [ + { + path: '', + component: ChartLineDemoComponent, + title: 'Charts - Line chart demo', + data: { + name: 'Line chart', + icon: 'line-chart', + library: 'charts', + }, + }, +]; + +export default LINE_CHART_ROUTES; diff --git a/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.html b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.html new file mode 100644 index 0000000000..c828ce9fb4 --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.html @@ -0,0 +1,1082 @@ + + + + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of linear.multiSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of linear.stacked; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + + @for ( + series of log.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of log.multiSeries; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of log.stacked; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + + + @for ( + series of density.single1x3; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.single1x6; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.single1x9; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.single1x12; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + @for ( + series of density.multi3x3; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.multi3x6; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.multi3x9; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.multi3x12; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + @for ( + series of density.stacked3x3; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.stacked3x6; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.stacked3x9; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of density.stacked3x12; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + @for ( + series of linear.singleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + diff --git a/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.ts b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.ts new file mode 100644 index 0000000000..3b28e19abf --- /dev/null +++ b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.ts @@ -0,0 +1,155 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + SkyChartCategoryAxisComponent, + SkyChartComponent, + SkyChartDataPointClickArgs, + SkyChartLineComponent, + SkyChartLineDatum, + SkyChartLineSeriesComponent, + SkyChartLineSeriesDataPointComponent, + SkyChartMeasureAxisComponent, +} from '@skyux/charts'; +import { SkyBoxModule } from '@skyux/layout'; +import { SkyFluidGridModule } from '@skyux/layout'; +import { SkyPageModule } from '@skyux/pages'; +import { SkyTabsModule } from '@skyux/tabs'; + +import { ChartDemoUtils } from '../shared/chart-demo-utils'; + +@Component({ + selector: 'app-chart-line-demo', + templateUrl: 'chart-line-demo.component.html', + imports: [ + SkyPageModule, + SkyTabsModule, + SkyChartLineComponent, + SkyBoxModule, + SkyFluidGridModule, + SkyChartComponent, + SkyChartLineComponent, + SkyChartLineSeriesComponent, + SkyChartLineSeriesDataPointComponent, + SkyChartCategoryAxisComponent, + SkyChartMeasureAxisComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChartLineDemoComponent { + protected readonly linear = { + singleSeries: [ + { + labelText: '2022', + data: [ + { category: 'January', label: '$25K', value: 25 }, + { category: 'February', label: '$28K', value: 28 }, + { category: 'March', label: '$22K', value: 22 }, + { category: 'April', label: '$35K', value: 35 }, + { category: 'May', label: '$30K', value: 30 }, + { category: 'June', label: '$45K', value: 45 }, + { category: 'July', label: '$38K', value: 38 }, + { category: 'August', label: '$42K', value: 42 }, + { category: 'September', label: '$50K', value: 50 }, + { category: 'October', label: '$40K', value: 40 }, + { category: 'November', label: '$55K', value: 55 }, + { category: 'December', label: '$48K', value: 48 }, + ], + }, + ], + multiSeries: ChartDemoUtils.createRandomSeries({ + labels: ['2022', '2023', '2024', '2025'], + categories: ChartDemoUtils.months({ count: 12 }), + min: 0, + max: 100, + }), + stacked: ChartDemoUtils.createRandomSeries({ + labels: ['2022', '2023', '2024', '2025'], + categories: ChartDemoUtils.months({ count: 12 }), + min: 0, + max: 100, + }), + }; + + protected readonly log = { + singleSeries: [ + { + labelText: 'Dataset 1', + data: [1, 1.1, 1.9, 2.1, 4.9, 5.1, 9, 11, 90, 110, 900, 1100, 9000].map( + (value, index) => ({ + category: 'Cat-' + (index + 1), + label: `$${value}`, + value, + }), + ), + }, + ], + multiSeries: ChartDemoUtils.createRandomSeries({ + labels: ['2022', '2023', '2024', '2025'], + categories: ChartDemoUtils.months({ count: 12 }), + min: 1, + max: 1_000, + }), + stacked: ChartDemoUtils.createRandomSeries({ + labels: ['2022', '2023', '2024', '2025'], + categories: ChartDemoUtils.months({ count: 12 }), + min: 1, + max: 1_000, + }), + }; + + protected readonly density = { + single1x3: ChartDemoUtils.createRandomSeries({ + seriesCount: 1, + dataCount: 3, + }), + single1x6: ChartDemoUtils.createRandomSeries({ + seriesCount: 1, + dataCount: 6, + }), + single1x9: ChartDemoUtils.createRandomSeries({ + seriesCount: 1, + dataCount: 9, + }), + single1x12: ChartDemoUtils.createRandomSeries({ + seriesCount: 1, + dataCount: 12, + }), + multi3x3: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 3, + }), + multi3x6: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 6, + }), + multi3x9: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 9, + }), + multi3x12: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 12, + }), + stacked3x3: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 3, + }), + stacked3x6: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 6, + }), + stacked3x9: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 9, + }), + stacked3x12: ChartDemoUtils.createRandomSeries({ + seriesCount: 3, + dataCount: 12, + }), + }; + + public onDataPointClick( + event: SkyChartDataPointClickArgs, + ): void { + console.log(JSON.stringify(event, null, 2)); + } +} diff --git a/apps/playground/src/app/components/charts/charts-routes.ts b/apps/playground/src/app/components/charts/charts-routes.ts new file mode 100644 index 0000000000..313ae3b4ea --- /dev/null +++ b/apps/playground/src/app/components/charts/charts-routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; + +const CHARTS_ROUTES: Routes = [ + { + path: 'chart-bar-demos', + loadChildren: () => import('./chart-bar-demo/chart-bar-demo-routes'), + }, + { + path: 'chart-line-demos', + loadChildren: () => import('./chart-line-demo/chart-line-demo-routes'), + }, + { + path: 'chart-donut-demos', + loadChildren: () => import('./chart-donut-demo/chart-donut-demo-routes'), + }, +]; + +export default CHARTS_ROUTES; diff --git a/apps/playground/src/app/components/charts/shared/chart-demo-utils.ts b/apps/playground/src/app/components/charts/shared/chart-demo-utils.ts new file mode 100644 index 0000000000..c525c71d6f --- /dev/null +++ b/apps/playground/src/app/components/charts/shared/chart-demo-utils.ts @@ -0,0 +1,169 @@ +/** + * Forked from https://www.chartjs.org/docs/latest/samples/utils.html + */ +export class ChartDemoUtils { + // Adapted from http://indiegamr.com/generate-repeatable-random-numbers-in-js/ + static #seed = Date.now(); + + static #Months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ] as const; + + public static random(min?: number, max?: number): number { + min = min ?? 0; + max = max ?? 0; + this.#seed = (this.#seed * 9301 + 49297) % 233280; + return min + (this.#seed / 233280) * (max - min); + } + + // eslint-disable-next-line complexity -- test utility + public static numbers(config: { + min?: number; + max?: number; + from?: number[]; + count?: number; + decimals?: number; + continuity?: number; + }): (number | null)[] { + const cfg = config || {}; + const min = cfg.min ?? 0; + const max = cfg.max ?? 100; + const from = cfg.from ?? []; + const count = cfg.count ?? 8; + const decimals = cfg.decimals ?? 8; + const continuity = cfg.continuity ?? 1; + const dfactor = Math.pow(10, decimals) || 0; + + const data: (number | null)[] = []; + let value: number; + + for (let i = 0; i < count; ++i) { + value = (from[i] || 0) + this.random(min, max); + if (this.random() <= continuity) { + data.push(Math.round(dfactor * value) / dfactor); + } else { + data.push(null); + } + } + + return data; + } + + public static months(config: { count?: number; section?: number }): string[] { + const cfg = config ?? {}; + const count = cfg.count ?? 12; + const section = cfg.section; + const values: string[] = []; + let value: string; + + for (let i = 0; i < count; ++i) { + value = this.#Months[Math.ceil(i) % 12]; + values.push(value.substring(0, section)); + } + + return values; + } + + /** + * Creates an array of random series data. + * @param config Configuration options. + * @returns An array of randomly generated series data. + */ + public static createRandomSeries(config: { + seriesCount?: number; + labels?: string[]; + dataCount?: number; + categories?: string[]; + min?: number; + max?: number; + }): DemoSeries[] { + const { labels, dataCount, categories, min, max } = config; + const seriesCount = labels?.length ?? config.seriesCount ?? 1; + + return Array.from({ length: seriesCount }, (_, seriesIndex) => + this.createRandomSeriesData({ + seriesIndex, + labelText: labels?.[seriesIndex], + count: dataCount, + categories, + min, + max, + }), + ); + } + + /** + * Creates a single series of random data. + * @param config Configuration options. + * @returns An object representing a series of randomly generated data points. + */ + public static createRandomSeriesData(config: { + seriesIndex: number; + labelText?: string; + count?: number; + categories?: string[]; + min?: number; + max?: number; + }): DemoSeries { + const { seriesIndex, labelText, count, categories, min, max } = config; + const data = this.createRandomData({ + count: count ?? categories?.length ?? 8, + categories, + min, + max, + }); + + return { + labelText: labelText ?? `Series ${seriesIndex + 1}`, + data: data, + }; + } + + /** + * Creates an array of random donut chart slices. + * @param config Configuration options. + * @returns An array of randomly generated slice data. + */ + public static createRandomData(config: { + count?: number; + categories?: string[]; + min?: number; + max?: number; + }): DemoSeriesData[] { + const { count, categories, min, max } = config; + const resolvedCount = count ?? categories?.length ?? 8; + + return ChartDemoUtils.numbers({ + min: min ?? 1, + max: max ?? 100, + count: resolvedCount, + decimals: 0, + }).map((value, index) => ({ + category: categories?.[index] ?? `Category ${index + 1}`, + value: value, + label: `$${value.toLocaleString()}`, + })); + } +} + +export interface DemoSeries { + labelText: string; + data: DemoSeriesData[]; +} + +export interface DemoSeriesData { + category: string; + label: string; + value: number; +} diff --git a/apps/playground/src/app/components/components.module.ts b/apps/playground/src/app/components/components.module.ts index 0e542c804f..047662700c 100644 --- a/apps/playground/src/app/components/components.module.ts +++ b/apps/playground/src/app/components/components.module.ts @@ -44,6 +44,10 @@ export const componentRoutes: Routes = [ (m) => m.AutonumericModule, ), }, + { + path: 'charts', + loadChildren: () => import('./charts/charts-routes'), + }, { path: 'colorpicker', loadChildren: () => diff --git a/libs/components/charts/CHANGELOG.md b/libs/components/charts/CHANGELOG.md new file mode 100644 index 0000000000..1d013ff92f --- /dev/null +++ b/libs/components/charts/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/libs/components/charts/README.md b/libs/components/charts/README.md new file mode 100644 index 0000000000..7d2700a807 --- /dev/null +++ b/libs/components/charts/README.md @@ -0,0 +1,7 @@ +# charts + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test charts` to execute the unit tests. diff --git a/libs/components/charts/documentation.json b/libs/components/charts/documentation.json new file mode 100644 index 0000000000..8526ddc239 --- /dev/null +++ b/libs/components/charts/documentation.json @@ -0,0 +1,30 @@ +{ + "$schema": "../manifest-generator/documentation-schema.json", + "groups": { + "charts": { + "development": { + "docsIds": [ + "SkyChartBarComponent", + "SkyChartBarSeriesComponent", + "SkyChartBarSeriesDataPointComponent", + "SkyChartCategoryAxisComponent", + "SkyChartComponent", + "SkyChartDonutComponent", + "SkyChartDonutSeriesComponent", + "SkyChartDonutSeriesDataPointComponent", + "SkyChartLineComponent", + "SkyChartLineSeriesComponent", + "SkyChartLineSeriesDataPointComponent", + "SkyChartMeasureAxisComponent" + ], + "primaryDocsId": "SkyChartComponent" + }, + "testing": { + "docsIds": ["SkyChartBarHarness", "SkyChartBarHarnessFilters"] + }, + "codeExamples": { + "docsIds": [] + } + } + } +} diff --git a/libs/components/charts/eslint.config.js b/libs/components/charts/eslint.config.js new file mode 100644 index 0000000000..daef7db2fa --- /dev/null +++ b/libs/components/charts/eslint.config.js @@ -0,0 +1,3 @@ +const config = require('../../../eslint-libs.config'); + +module.exports = [...config]; diff --git a/libs/components/charts/karma.conf.js b/libs/components/charts/karma.conf.js new file mode 100644 index 0000000000..924d060885 --- /dev/null +++ b/libs/components/charts/karma.conf.js @@ -0,0 +1,16 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +const { join } = require('path'); +const getBaseKarmaConfig = require('../../../karma.conf'); + +module.exports = function (config) { + const baseConfig = getBaseKarmaConfig(); + config.set({ + ...baseConfig, + coverageReporter: { + ...baseConfig.coverageReporter, + dir: join(__dirname, '../../../coverage/libs/components/charts'), + }, + }); +}; diff --git a/libs/components/charts/ng-package.json b/libs/components/charts/ng-package.json new file mode 100644 index 0000000000..41325cd83a --- /dev/null +++ b/libs/components/charts/ng-package.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../../dist/libs/components/charts", + "lib": { + "entryFile": "src/index.ts", + "styleIncludePaths": ["../../.."] + }, + "inlineStyleLanguage": "scss", + "allowedNonPeerDependencies": ["chart.js"] +} diff --git a/libs/components/charts/package.json b/libs/components/charts/package.json new file mode 100644 index 0000000000..1229da02e2 --- /dev/null +++ b/libs/components/charts/package.json @@ -0,0 +1,38 @@ +{ + "name": "@skyux/charts", + "version": "0.0.0-PLACEHOLDER", + "author": "Blackbaud, Inc.", + "keywords": [ + "blackbaud", + "skyux" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/blackbaud/skyux.git" + }, + "bugs": { + "url": "https://github.com/blackbaud/skyux/issues" + }, + "homepage": "https://github.com/blackbaud/skyux#readme", + "peerDependencies": { + "@angular/cdk": "^20.2.14", + "@angular/common": "^20.3.15", + "@angular/core": "^20.3.15", + "@angular/platform-browser": "^20.3.15", + "@skyux-sdk/testing": "0.0.0-PLACEHOLDER", + "@skyux/ag-grid": "0.0.0-PLACEHOLDER", + "@skyux/core": "0.0.0-PLACEHOLDER", + "@skyux/help-inline": "0.0.0-PLACEHOLDER", + "@skyux/icon": "0.0.0-PLACEHOLDER", + "@skyux/i18n": "0.0.0-PLACEHOLDER", + "@skyux/modals": "0.0.0-PLACEHOLDER", + "@skyux/popovers": "0.0.0-PLACEHOLDER", + "@skyux/theme": "0.0.0-PLACEHOLDER" + }, + "dependencies": { + "chart.js": "^4.5.1", + "tslib": "^2.8.1" + }, + "sideEffects": false +} diff --git a/libs/components/charts/project.json b/libs/components/charts/project.json new file mode 100644 index 0000000000..6957040b25 --- /dev/null +++ b/libs/components/charts/project.json @@ -0,0 +1,81 @@ +{ + "name": "charts", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/components/charts/src", + "prefix": "sky", + "tags": ["component", "npm"], + "targets": { + "build": { + "executor": "@nx/angular:package", + "outputs": ["{workspaceRoot}/dist/libs/components/charts"], + "options": { + "project": "libs/components/charts/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/components/charts/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "libs/components/charts/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production", + "dependsOn": [ + "^build", + { + "projects": ["core"], + "target": "build" + } + ], + "inputs": [ + "buildInputs", + "^buildInputs", + "{workspaceRoot}/libs/components/charts/testing/src/**/*", + "!{workspaceRoot}/libs/components/charts/testing/src/**/*.spec.ts", + "!{workspaceRoot}/libs/components/charts/testing/src/**/fixtures/**/*" + ] + }, + "test": { + "executor": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "libs/components/charts/tsconfig.spec.json", + "karmaConfig": "libs/components/charts/karma.conf.js", + "styles": [ + "libs/components/theme/src/lib/styles/sky.scss", + "libs/components/theme/src/lib/styles/themes/modern/styles.scss", + "libs/components/ag-grid/src/lib/styles/ag-grid-styles.scss" + ], + "codeCoverage": true, + "codeCoverageExclude": ["**/fixtures/**"], + "polyfills": [ + "zone.js", + "zone.js/testing", + "libs/components/packages/src/polyfills.js" + ], + "inlineStyleLanguage": "scss", + "stylePreprocessorOptions": { + "includePaths": ["{workspaceRoot}"] + } + }, + "configurations": { + "ci": { + "browsers": "ChromeHeadlessNoSandbox", + "codeCoverage": true, + "progress": false, + "sourceMap": true, + "watch": false + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "lintFilePatterns": [ + "{projectRoot}/src/**/*.ts", + "{projectRoot}/src/**/*.html" + ] + } + } + } +} diff --git a/libs/components/charts/src/assets/locales/resources_en_US.json b/libs/components/charts/src/assets/locales/resources_en_US.json new file mode 100644 index 0000000000..6359c08216 --- /dev/null +++ b/libs/components/charts/src/assets/locales/resources_en_US.json @@ -0,0 +1,101 @@ +{ + "chart.canvas.role_description": { + "_description": "The role description announced by screen readers when the chart canvas receives focus.", + "message": "chart" + }, + "chart.canvas.label.bar": { + "_description": "The aria-label for the bar chart canvas element.", + "message": "Bar chart" + }, + "chart.canvas.label.line": { + "_description": "The aria-label for the line chart canvas element.", + "message": "Line chart" + }, + "chart.canvas.label.donut": { + "_description": "The aria-label for the donut chart canvas element.", + "message": "Donut chart" + }, + "chart.focus_element.multi_series.description": { + "_description": "The template for the screen-reader description of the currently focused chart element.", + "message": "{0}, series {1} of {2}. {3}: {4}. Point {5} of {6}.", + "_0": "Series label", + "_1": "Series index (1-based)", + "_2": "Total series count in the chart", + "_3": "Category label", + "_4": "Data point value", + "_5": "Data point index (1-based)", + "_6": "Total data points in the series" + }, + "chart.focus_element.single_series.description": { + "_description": "The template for the screen-reader description of the currently focused chart element.", + "message": "{0}: {1}. Point {2} of {3}.", + "_0": "Category label", + "_1": "Data point value", + "_2": "Data point index (1-based)", + "_3": "Total data points in the series" + }, + "chart.legend.aria_label": { + "_description": "The label for the chart legend", + "message": "Chart legend" + }, + "chart.legend.legend_item.aria_description": { + "_description": "The screen-reader description attached to each legend button, explaining how to interact with it.", + "message": "Press Space or Enter to toggle inclusion in chart." + }, + "chart.legend.legend_item.aria_label": { + "_description": "The aria-label for a legend button.", + "message": "{0}, Legend item {1} of {2}", + "_0": "Series name", + "_1": "Item position (1-based)", + "_2": "Total item count" + }, + "chart.menu.label": { + "_description": "The label for the chart menu", + "message": "Context menu for {0}", + "_0": "Chart heading" + }, + "chart.menu.view_data_table": { + "_description": "The label for the 'View data table' chart menu item", + "message": "View data table" + }, + "chart.menu.view_data_table.aria_label": { + "_description": "The aria label for the 'View data table' chart menu item", + "message": "View data table for {0}", + "_0": "Chart heading" + }, + "chart_data_grid.category_column_name": { + "_description": "The label for the pinned category column", + "message": "Category" + }, + "chart_data_grid.close_button": { + "_description": "The label for the close button", + "message": "Close" + }, + "chart.summary.bar_chart": { + "_description": "The auto-generated screen-reader summary intro for a standard vertical bar chart.", + "message": "Bar chart with {0} data series.", + "_0": "Number of data series" + }, + "chart.summary.line_chart": { + "_description": "The auto-generated screen-reader summary intro for a line chart.", + "message": "Line chart with {0} data series.", + "_0": "Number of data series" + }, + "chart.summary.donut_chart": { + "_description": "The auto-generated screen-reader summary intro for a donut chart.", + "message": "Donut chart with {0} data points.", + "_0": "Number of data points (slices)" + }, + "chart.summary.category_axis": { + "_description": "Sentence describing the category axis in the auto-generated chart summary.", + "message": "The chart has a category axis displaying {0}.", + "_0": "Category axis label text" + }, + "chart.summary.measure_axis": { + "_description": "Sentence describing the value axis in the auto-generated chart summary.", + "message": "The chart has a measure axis displaying {0}.", + "_0": "Measure axis label text", + "_1": "Minimum data value", + "_2": "Maximum data value" + } +} diff --git a/libs/components/charts/src/index.ts b/libs/components/charts/src/index.ts new file mode 100644 index 0000000000..ec595d78e8 --- /dev/null +++ b/libs/components/charts/src/index.ts @@ -0,0 +1,27 @@ +export { SkyChartComponent } from './lib/modules/chart/chart.component'; +export { SkyChartDataPointClickArgs } from './lib/modules/shared/types/chart-data-point-click-args'; + +// Axis +export { SkyChartCategoryAxisComponent } from './lib/modules/axis/chart-category-axis.component'; +export { SkyChartMeasureAxisComponent } from './lib/modules/axis/chart-measure-axis.component'; + +// Bar Chart +export { SkyChartBarComponent } from './lib/modules/chart-bar/chart-bar.component'; +export { SkyChartBarSeriesComponent } from './lib/modules/chart-bar/chart-bar-series.component'; +export { SkyChartBarSeriesDataPointComponent } from './lib/modules/chart-bar/chart-bar-series-data-point.component'; +export { + SkyChartBarOrientation, + SkyChartBarDatum, +} from './lib/modules/chart-bar/chart-bar-types'; + +// Line Chart +export { SkyChartLineComponent } from './lib/modules/chart-line/chart-line.component'; +export { SkyChartLineSeriesComponent } from './lib/modules/chart-line/chart-line-series.component'; +export { SkyChartLineSeriesDataPointComponent } from './lib/modules/chart-line/chart-line-series-data-point.component'; +export { SkyChartLineDatum } from './lib/modules/chart-line/chart-line-types'; + +// Donut Chart +export { SkyChartDonutComponent } from './lib/modules/chart-donut/chart-donut.component'; +export { SkyChartDonutSeriesComponent } from './lib/modules/chart-donut/chart-donut-series.component'; +export { SkyChartDonutSeriesDataPointComponent } from './lib/modules/chart-donut/chart-donut-series-data-point.component'; +export { SkyChartDonutDatum } from './lib/modules/chart-donut/chart-donut-types'; diff --git a/libs/components/charts/src/lib/modules/axis/chart-axis-registry.service.ts b/libs/components/charts/src/lib/modules/axis/chart-axis-registry.service.ts new file mode 100644 index 0000000000..992a416d2d --- /dev/null +++ b/libs/components/charts/src/lib/modules/axis/chart-axis-registry.service.ts @@ -0,0 +1,46 @@ +import { InjectionToken, Signal } from '@angular/core'; + +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; + +/** + * This injection token provides access to a chart-specific chart axis registry. + */ +export const SKY_CHART_AXIS_REGISTRY = new InjectionToken( + 'SKY_CHART_AXIS_REGISTRY', +); + +/** + * The interface for a chart axis registry service, which is responsible for managing the configuration of category and measure axes in a chart + */ +export interface SkyChartAxisRegistry { + /** Signals that emit the current category axes configuration. */ + readonly categoryAxis: Signal; + + /** Signals that emit the current measure axes configuration. */ + readonly measureAxis: Signal; + + /** + * Updates or inserts the category axis configuration. + * @param axis + */ + upsertCategoryAxis(axis: SkyChartCategoryAxisConfig): void; + + /** + * Removes the category axis configuration. + */ + removeCategoryAxis(): void; + + /** + * Updates or inserts the measure axis configuration. + * @param axis + */ + upsertMeasureAxis(axis: SkyChartMeasureAxisConfig): void; + + /** + * Removes the measure axis configuration. + */ + removeMeasureAxis(): void; +} diff --git a/libs/components/charts/src/lib/modules/axis/chart-category-axis.component.spec.ts b/libs/components/charts/src/lib/modules/axis/chart-category-axis.component.spec.ts new file mode 100644 index 0000000000..84c6ce39e0 --- /dev/null +++ b/libs/components/charts/src/lib/modules/axis/chart-category-axis.component.spec.ts @@ -0,0 +1,102 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { expect } from '@skyux-sdk/testing'; + +import { SkyChartCategoryAxisConfig } from '../shared/types/axis-types'; + +import { + SKY_CHART_AXIS_REGISTRY, + SkyChartAxisRegistry, +} from './chart-axis-registry.service'; +import { SkyChartCategoryAxisComponent } from './chart-category-axis.component'; + +describe('SkyChartCategoryAxisComponent', () => { + let fixture: ComponentFixture; + let mockRegistry: jasmine.SpyObj; + + beforeEach(() => { + mockRegistry = jasmine.createSpyObj( + 'SkyChartAxisRegistry', + [ + 'upsertCategoryAxis', + 'removeCategoryAxis', + 'upsertMeasureAxis', + 'removeMeasureAxis', + ], + { + categoryAxis: signal(undefined), + measureAxis: signal(undefined), + }, + ); + + TestBed.configureTestingModule({ + imports: [SkyChartCategoryAxisComponent], + providers: [ + { + provide: SKY_CHART_AXIS_REGISTRY, + useValue: mockRegistry, + }, + ], + }); + + fixture = TestBed.createComponent(SkyChartCategoryAxisComponent); + }); + + describe('registration lifecycle', () => { + it('should register the category axis with the registry on creation', () => { + fixture.componentRef.setInput('labelText', 'Quarter'); + fixture.detectChanges(); + + expect(mockRegistry.upsertCategoryAxis).toHaveBeenCalledWith({ + labelText: 'Quarter', + } satisfies SkyChartCategoryAxisConfig); + }); + + it('should update the registry when labelText changes', () => { + fixture.componentRef.setInput('labelText', 'Quarter'); + fixture.detectChanges(); + + fixture.componentRef.setInput('labelText', 'Month'); + fixture.detectChanges(); + + expect(mockRegistry.upsertCategoryAxis).toHaveBeenCalledWith({ + labelText: 'Month', + } satisfies SkyChartCategoryAxisConfig); + }); + + it('should remove the category axis from the registry on destroy', () => { + fixture.componentRef.setInput('labelText', 'Quarter'); + fixture.detectChanges(); + + fixture.destroy(); + + expect(mockRegistry.removeCategoryAxis).toHaveBeenCalled(); + }); + }); + + describe('required inputs', () => { + it('should require labelText', () => { + expect(() => fixture.detectChanges()).toThrowError( + /NG0950: Input is required/, + ); + }); + }); + + it('should reflect the label', () => { + fixture.componentRef.setInput('labelText', 'Year'); + fixture.detectChanges(); + + expect(mockRegistry.upsertCategoryAxis).toHaveBeenCalledWith({ + labelText: 'Year', + } satisfies SkyChartCategoryAxisConfig); + }); + + it('should support tuple labelText', () => { + fixture.componentRef.setInput('labelText', ['Label A', 'Label B']); + fixture.detectChanges(); + + expect(mockRegistry.upsertCategoryAxis).toHaveBeenCalledWith({ + labelText: ['Label A', 'Label B'], + } satisfies SkyChartCategoryAxisConfig); + }); +}); diff --git a/libs/components/charts/src/lib/modules/axis/chart-category-axis.component.ts b/libs/components/charts/src/lib/modules/axis/chart-category-axis.component.ts new file mode 100644 index 0000000000..324cb2b883 --- /dev/null +++ b/libs/components/charts/src/lib/modules/axis/chart-category-axis.component.ts @@ -0,0 +1,53 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { + SkyChartAxisLabelText, + SkyChartCategoryAxisConfig, +} from '../shared/types/axis-types'; + +import { SKY_CHART_AXIS_REGISTRY } from './chart-axis-registry.service'; + +/** + * Configures the chart's category axis + */ +@Component({ + selector: 'sky-chart-category-axis', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartCategoryAxisComponent implements OnDestroy { + readonly #registry = inject(SKY_CHART_AXIS_REGISTRY); + + /** + * The label displayed alongside the category axis. + */ + public readonly labelText = input.required(); + + /** + * The axis object + */ + readonly #axis = computed(() => { + return { + labelText: this.labelText(), + }; + }); + + constructor() { + effect(() => { + const axis = this.#axis(); + this.#registry.upsertCategoryAxis(axis); + }); + } + + public ngOnDestroy(): void { + this.#registry.removeCategoryAxis(); + } +} diff --git a/libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.spec.ts b/libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.spec.ts new file mode 100644 index 0000000000..cfe58122dd --- /dev/null +++ b/libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.spec.ts @@ -0,0 +1,186 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { expect } from '@skyux-sdk/testing'; + +import { SkyChartMeasureAxisConfig } from '../shared/types/axis-types'; + +import { + SKY_CHART_AXIS_REGISTRY, + SkyChartAxisRegistry, +} from './chart-axis-registry.service'; +import { SkyChartMeasureAxisComponent } from './chart-measure-axis.component'; + +describe('SkyChartMeasureAxisComponent', () => { + let fixture: ComponentFixture; + let mockRegistry: jasmine.SpyObj; + + beforeEach(() => { + mockRegistry = jasmine.createSpyObj( + 'SkyChartAxisRegistry', + [ + 'upsertCategoryAxis', + 'removeCategoryAxis', + 'upsertMeasureAxis', + 'removeMeasureAxis', + ], + { + categoryAxis: signal(undefined), + measureAxis: signal(undefined), + }, + ); + + TestBed.configureTestingModule({ + imports: [SkyChartMeasureAxisComponent], + providers: [ + { + provide: SKY_CHART_AXIS_REGISTRY, + useValue: mockRegistry, + }, + ], + }); + + fixture = TestBed.createComponent(SkyChartMeasureAxisComponent); + }); + + describe('registration lifecycle', () => { + it('should register the measure axis with the registry on creation', () => { + fixture.componentRef.setInput('labelText', 'Revenue'); + fixture.detectChanges(); + + expect(mockRegistry.upsertMeasureAxis).toHaveBeenCalledWith( + jasmine.objectContaining({ + labelText: 'Revenue', + scaleType: 'linear', + }), + ); + }); + + it('should update the registry when any input changes', () => { + fixture.componentRef.setInput('labelText', 'Revenue'); + fixture.detectChanges(); + + fixture.componentRef.setInput('labelText', 'Cost'); + fixture.detectChanges(); + + expect(mockRegistry.upsertMeasureAxis).toHaveBeenCalledWith( + jasmine.objectContaining({ + labelText: 'Cost', + }), + ); + }); + + it('should remove the measure axis from the registry on destroy', () => { + fixture.componentRef.setInput('labelText', 'Revenue'); + fixture.detectChanges(); + + fixture.destroy(); + + expect(mockRegistry.removeMeasureAxis).toHaveBeenCalled(); + }); + }); + + describe('required inputs', () => { + it('should require labelText', () => { + expect(() => fixture.detectChanges()).toThrowError( + /NG0950: Input is required/, + ); + }); + }); + + describe('input defaults', () => { + beforeEach(() => { + fixture.componentRef.setInput('labelText', 'Amount'); + fixture.detectChanges(); + }); + + it('should default scaleType to "linear"', () => { + expect(fixture.componentInstance.scaleType()).toBe('linear'); + }); + + it('should default min to undefined', () => { + expect(fixture.componentInstance.min()).toBeUndefined(); + }); + + it('should default max to undefined', () => { + expect(fixture.componentInstance.max()).toBeUndefined(); + }); + + it('should default allowMinOverflow to false', () => { + expect(fixture.componentInstance.allowMinOverflow()).toBeFalse(); + }); + + it('should default allowMaxOverflow to false', () => { + expect(fixture.componentInstance.allowMaxOverflow()).toBeFalse(); + }); + }); + + it('should reflect the "logarithmic" scaleType input', () => { + fixture.componentRef.setInput('labelText', 'Amount'); + fixture.componentRef.setInput('scaleType', 'logarithmic'); + fixture.detectChanges(); + + expect(mockRegistry.upsertMeasureAxis).toHaveBeenCalledWith( + jasmine.objectContaining({ + scaleType: 'logarithmic', + }), + ); + }); + + it('should reflect min and max inputs', () => { + fixture.componentRef.setInput('labelText', 'Amount'); + fixture.componentRef.setInput('min', 0); + fixture.componentRef.setInput('max', 100); + fixture.detectChanges(); + + expect(mockRegistry.upsertMeasureAxis).toHaveBeenCalledWith( + jasmine.objectContaining({ + min: 0, + max: 100, + }), + ); + }); + + it('should reflect allowMinOverflow and allowMaxOverflow inputs', () => { + fixture.componentRef.setInput('labelText', 'Amount'); + fixture.componentRef.setInput('allowMinOverflow', true); + fixture.componentRef.setInput('allowMaxOverflow', true); + fixture.detectChanges(); + + expect(mockRegistry.upsertMeasureAxis).toHaveBeenCalledWith( + jasmine.objectContaining({ + allowMinOverflow: true, + allowMaxOverflow: true, + }), + ); + }); + + it('should support tuple labelText', () => { + fixture.componentRef.setInput('labelText', ['Primary', 'Secondary']); + fixture.detectChanges(); + + expect(mockRegistry.upsertMeasureAxis).toHaveBeenCalledWith( + jasmine.objectContaining({ + labelText: ['Primary', 'Secondary'], + }), + ); + }); + + it('should produce a complete axis config', () => { + fixture.componentRef.setInput('labelText', 'Revenue'); + fixture.componentRef.setInput('scaleType', 'logarithmic'); + fixture.componentRef.setInput('min', 10); + fixture.componentRef.setInput('max', 1000); + fixture.componentRef.setInput('allowMinOverflow', true); + fixture.componentRef.setInput('allowMaxOverflow', false); + fixture.detectChanges(); + + expect(mockRegistry.upsertMeasureAxis).toHaveBeenCalledWith({ + labelText: 'Revenue', + scaleType: 'logarithmic', + min: 10, + max: 1000, + allowMinOverflow: true, + allowMaxOverflow: false, + } satisfies SkyChartMeasureAxisConfig); + }); +}); diff --git a/libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.ts b/libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.ts new file mode 100644 index 0000000000..59f3e86918 --- /dev/null +++ b/libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.ts @@ -0,0 +1,96 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + booleanAttribute, + computed, + effect, + inject, + input, + numberAttribute, +} from '@angular/core'; + +import { + SkyChartAxisLabelText, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; + +import { SKY_CHART_AXIS_REGISTRY } from './chart-axis-registry.service'; + +/** + * Configures the Chart's measure axis. + */ +@Component({ + selector: 'sky-chart-measure-axis', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartMeasureAxisComponent implements OnDestroy { + readonly #registry = inject(SKY_CHART_AXIS_REGISTRY); + + /** + * The label displayed alongside the measure axis. + */ + public readonly labelText = input.required(); + + /** + * The scale type for the measure axis. + * @default 'linear' + */ + public readonly scaleType = input<'linear' | 'logarithmic'>('linear'); + + /** + * The lower bound for the measure axis. The chart will not go below this value. + */ + public readonly min = input(undefined, { + transform: numberAttribute, + }); + + /** + * The upper bound for the measure axis. The chart will not exceed this value. + */ + public readonly max = input(undefined, { + transform: numberAttribute, + }); + + /** + * When true, `min` acts as a soft lower bound: the axis starts at `min` but may extend below it if the data requires it. + * When false or omitted, `min` is a hard lower bound and the axis will never go below it. + */ + public readonly allowMinOverflow = input(false, { + transform: booleanAttribute, + }); + + /** + * When true, `max` acts as a soft upper bound: the axis starts at `max` but may extend above it if the data requires it. + * When false or omitted, `max` is a hard upper bound and the axis will never exceed it. + */ + public readonly allowMaxOverflow = input(false, { + transform: booleanAttribute, + }); + + /** + * The axis object + * @internal + */ + readonly #axis = computed(() => { + return { + labelText: this.labelText(), + scaleType: this.scaleType(), + min: this.min(), + max: this.max(), + allowMinOverflow: this.allowMinOverflow(), + allowMaxOverflow: this.allowMaxOverflow(), + }; + }); + + constructor() { + effect(() => { + this.#registry.upsertMeasureAxis(this.#axis()); + }); + } + + public ngOnDestroy(): void { + this.#registry.removeMeasureAxis(); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-bar/chart-bar-config.service.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-config.service.ts new file mode 100644 index 0000000000..3f4c68040d --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-config.service.ts @@ -0,0 +1,380 @@ +import { Injectable, inject } from '@angular/core'; + +import { + BarControllerDatasetOptions, + ChartConfiguration, + ChartDataset, + ChartOptions, + ChartTypeRegistry, + ScaleOptionsByType, +} from 'chart.js'; + +import { parseCategories } from '../shared/chart-helpers'; +import { + buildCategoryScale, + buildLinearMeasureScale, + buildLogarithmicMeasureScale, +} from '../shared/scale-mapping'; +import { + SkyChartStyleService, + SkyChartStyles, +} from '../shared/services/chart-style.service'; +import { SkyChartGlobalConfigService } from '../shared/services/global-chart-config.service'; +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; +import { SkyCategory } from '../shared/types/category'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; +import { SkyChartSeries } from '../shared/types/chart-series'; +import { DeepPartial } from '../shared/types/deep-partial-type'; + +import { + SkyChartBarDatum, + SkyChartBarOrientation, + SkyChartBarPoint, +} from './chart-bar-types'; + +/** + * Configuration service for the Bar Chart component. + */ +@Injectable({ providedIn: 'root' }) +export class SkyChartBarConfigService { + readonly #chartStyleService = inject(SkyChartStyleService); + readonly #globalConfig = inject(SkyChartGlobalConfigService); + + /** + * Builds a Chart.js Bar Chart configuration based on provided options. + * @remarks This uses the `SkyChartStyleService.styles` signal to support runtime theming recalculations + * @param options The bar chart options + */ + public buildConfig(options: SkyChartBarOptions): ChartConfiguration<'bar'> { + const styles = this.#chartStyleService.styles(); + + const orientation = options.orientation || 'vertical'; + const isVertical = orientation === 'vertical'; + + // Build categories from series data + const categories = parseCategories(options.series); + + // Build datasets from series + const datasets: ChartDataset<'bar'>[] = options.series.map((series) => { + const dataByCategory = new Map(); + + for (const p of series.data) { + dataByCategory.set(p.category, p.value); + } + + // Backfill null for categories missing from this series + const data = categories.map((category) => { + return dataByCategory.get(category) ?? null; + }); + + const dataset: ChartDataset<'bar'> = { + label: series.labelText, + data: data, + }; + + return dataset; + }); + + // Build Plugin options + const pluginOptions: ChartOptions<'bar'>['plugins'] = { + sky_indicator: { dataPointsClickEnabled: options.dataPointsClickEnabled }, + sky_keyboard_nav: { + valueLabel: (datasetIndex, dataIndex) => { + const series = options.series[datasetIndex]; + const category = categories[dataIndex]; + const dataPoint = series.data.find((d) => d.category === category); + const label = dataPoint?.labelText ?? ''; + return label; + }, + }, + tooltip: { + intersect: false, + callbacks: { + label: function (context) { + const series = options.series[context.datasetIndex]; + const category = categories[context.dataIndex]; + const dataPoint = series.data.find((d) => d.category === category); + return `${series.labelText}: ${dataPoint?.labelText}`; + }, + }, + }, + }; + + // Build chart options + const chartOptions: ChartOptions<'bar'> = { + indexAxis: isVertical ? 'x' : 'y', + interaction: { + mode: options.series.length > 1 ? 'nearest' : 'index', + intersect: true, + axis: options.orientation === 'vertical' ? 'x' : 'y', + }, + datasets: { + bar: this.#getBarDatasetOptions(options, categories.length, styles), + }, + elements: { + bar: { + borderWidth: styles.charts.bar.borderWidth, + borderColor: styles.charts.bar.borderColor, + borderRadius: styles.charts.bar.borderRadius, + }, + }, + scales: this.#createScales(styles, options), + plugins: pluginOptions, + onClick: (_, elements): void => { + if ( + !options.dataPointsClickEnabled || + !options.callbacks?.onDataPointClick || + elements.length === 0 + ) { + return; + } + + const element = elements[0]; + const series = options.series[element.datasetIndex]; + const category = categories[element.index]; + const dataPoint = series.data.find((d) => d.category === category); + + if (dataPoint) { + options.callbacks.onDataPointClick({ + series: series.labelText, + category: category, + value: dataPoint.value, + }); + } + }, + }; + + const config = this.#globalConfig.getMergedChartConfiguration<'bar'>({ + type: 'bar', + data: { labels: categories, datasets: datasets }, + options: chartOptions, + plugins: [], + }); + + return config; + } + + /** + * Calculates the appropriate height for a horizontal bar chart. + * @param options The bar chart options + * @returns A CSS height value (e.g. '400px') for the chart container + */ + public getChartHeight(options: SkyChartBarOptions): string { + const styles = this.#chartStyleService.styles(); + + if (options.orientation === 'vertical') { + return styles.height.default; + } + + const seriesCount = options.series.length; + const categoryCount = parseCategories(options.series).length; + const containerHeightMin = this.#chartStyleService.styles().height.min; + const barsPerCategory = options.stacked ? 1 : seriesCount; + const totalBars = categoryCount * barsPerCategory; + + const spacing = this.#computeHorizontalBarElementSpacing(totalBars, styles); + const { barThickness, categoryGap } = spacing; + const rowHeight = barThickness * barsPerCategory + categoryGap; + const totalRowsHeight = categoryCount * rowHeight; + + const computedHeight = this.#computeChartOverhead(styles) + totalRowsHeight; + + // Allow horizontal bar charts to grow indefinitely but clamp them from being too small + const clampedHeight = Math.max(containerHeightMin, computedHeight); + + return `${clampedHeight}px`; + } + + // #region Bar Element sizing + #getBarDatasetOptions( + options: SkyChartBarOptions, + categoryCount: number, + styles: SkyChartStyles, + ): DeepPartial { + if (options.orientation === 'vertical') { + const isFewBars = categoryCount <= 3; + const isManyBars = categoryCount >= 12; + + // Category Percentage is the proportion of the category slot allocated to bars. + const categoryPercentage = isFewBars ? 0.4 : isManyBars ? 0.95 : 0.7; + + // Bar Percentage is the proportion of the allocated category space that each bar occupies. + const barPercentage = 0.85; + + return { + categoryPercentage: categoryPercentage, + barPercentage: barPercentage, + maxBarThickness: styles.charts.bar.vertical.maxBarThickness, + }; + } + + const barsPerCategory = options.stacked ? 1 : options.series.length; + const totalBars = categoryCount * barsPerCategory; + const spacing = this.#computeHorizontalBarElementSpacing(totalBars, styles); + + return { barThickness: spacing.barThickness }; + } + + /** + * Returns the Bar Thickness (px) and Category Gap (px) for a horizontal bar chart. + * @param totalBars The total bars in the horizontal bar chart + * @param styles The chart styles + */ + #computeHorizontalBarElementSpacing( + totalBars: number, + styles: SkyChartStyles, + ): { + barThickness: number; + categoryGap: number; + } { + const barStyles = styles.charts.bar.horizontal; + const tuning = { + minBarThickness: barStyles.minBarThickness, + maxBarThickness: barStyles.maxBarThickness, + taperingStart: 12, + taperingStop: 36, + minCategoryGap: barStyles.minCategoryGap, + lowCategoryGapPercentage: 0.375, + highCategoryGapPercentage: 0.75, + }; + + let barThickness = 0; + let categoryGapPercentage = 0; + + // Calculate the Bar Thickness and Category Gap % + if (totalBars < tuning.taperingStart) { + barThickness = tuning.maxBarThickness; + categoryGapPercentage = tuning.lowCategoryGapPercentage; + } else { + const taperRange = tuning.taperingStop - tuning.taperingStart; + const rawTaperFraction = (totalBars - tuning.taperingStart) / taperRange; + const taperFraction = Math.min(1, rawTaperFraction); + + const thicknessRange = tuning.maxBarThickness - tuning.minBarThickness; + const taperedThickness = Math.round( + tuning.maxBarThickness - taperFraction * thicknessRange, + ); + barThickness = Math.max(tuning.minBarThickness, taperedThickness); + categoryGapPercentage = tuning.highCategoryGapPercentage; + } + + // Calculate the category gap + const taperedCategoryGap = barThickness * categoryGapPercentage; + const categoryGap = Math.max(tuning.minCategoryGap, taperedCategoryGap); + + return { barThickness, categoryGap }; + } + + /** + * Returns the pixel height consumed by a Chart's non-data elements (padding, axes, titles) based on the provided styles. + */ + #computeChartOverhead(styles: SkyChartStyles): number { + const padding = styles.chartPadding * 2; + + const tickHeight = + Number.parseFloat(styles.axis.ticks.lineHeight) + + styles.axis.ticks.measureLength + + styles.axis.ticks.padding; + + const axisTitleHeight = + styles.axis.title.paddingTop + + Number.parseFloat(styles.axis.title.lineHeight) + + styles.axis.title.paddingBottom; + + return padding + tickHeight + axisTitleHeight; + } + // #endregion + + // #region Scales + #createScales( + styles: SkyChartStyles, + options: SkyChartBarOptions, + ): ChartOptions<'bar'>['scales'] { + const orientation = options.orientation ?? 'vertical'; + const categoryScale = this.#createCategoryScale(styles, options); + const measureScale = this.#createMeasureScale(styles, options); + + if (orientation === 'vertical') { + return { x: categoryScale, y: measureScale }; + } else { + return { x: measureScale, y: categoryScale }; + } + } + + #createCategoryScale( + styles: SkyChartStyles, + options: SkyChartBarOptions, + ): PartialBarScale { + const scale = buildCategoryScale({ + styles, + stacked: options.stacked, + categoryAxis: options.categoryAxis, + }); + + return { + ...scale, + grid: { + ...scale.grid, + // Hide grid lines to improve readability + display: false, + lineWidth: 0, + drawTicks: false, + tickLength: 0, + }, + }; + } + + #createMeasureScale( + styles: SkyChartStyles, + options: SkyChartBarOptions, + ): PartialBarScale { + const params = { + styles: styles, + stacked: options.stacked ?? false, + measureAxis: options.measureAxis, + }; + + if (options.measureAxis?.scaleType === 'logarithmic') { + return buildLogarithmicMeasureScale(params); + } else { + return buildLinearMeasureScale(params); + } + } + // #endregion +} + +// #region Types +/** Configuration for the bar chart component. */ +export interface SkyChartBarOptions { + /** Orientation of the chart. */ + orientation?: SkyChartBarOrientation; + + /** The data series for the chart. */ + series: SkyChartSeries[]; + + /** Whether the chart should display stacked series. */ + stacked?: boolean; + + /** Configuration for the category axis. */ + categoryAxis?: SkyChartCategoryAxisConfig; + + /** Configuration for the measure axis. */ + measureAxis?: SkyChartMeasureAxisConfig; + + /** Are the data points clickable */ + dataPointsClickEnabled: boolean; + + callbacks?: { + onDataPointClick: ( + event: SkyChartDataPointClickArgs, + ) => void; + }; +} + +type PartialBarScale = DeepPartial< + ScaleOptionsByType +>; +// #endregion diff --git a/libs/components/charts/src/lib/modules/chart-bar/chart-bar-registry.service.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-registry.service.ts new file mode 100644 index 0000000000..990dc83569 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-registry.service.ts @@ -0,0 +1,85 @@ +import { Injectable, signal } from '@angular/core'; + +import { SkyChartAxisRegistry } from '../axis/chart-axis-registry.service'; +import { SkyChartRegistry } from '../shared/services/chart-registry.service'; +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartBarPoint } from './chart-bar-types'; + +@Injectable() +export class SkyChartBarRegistry + implements SkyChartRegistry, SkyChartAxisRegistry +{ + public readonly categoryAxis = signal( + undefined, + ); + public readonly measureAxis = signal( + undefined, + ); + public readonly series = signal[]>([]); + + public upsertCategoryAxis(axis: SkyChartCategoryAxisConfig): void { + this.categoryAxis.set(axis); + } + + public removeCategoryAxis(): void { + this.categoryAxis.set(undefined); + } + + public upsertMeasureAxis(axis: SkyChartMeasureAxisConfig): void { + this.measureAxis.set(axis); + } + + public removeMeasureAxis(): void { + this.measureAxis.set(undefined); + } + + public upsertSeries(series: SkyChartSeries): void { + this.series.update((list) => { + const idx = list.findIndex((s) => s.id === series.id); + + // Already exists, update it + if (idx >= 0) { + return list.map((s) => (s.id === series.id ? series : s)); + } + + // New series, add it + return [...list, series]; + }); + } + + public removeSeries(seriesId: number): void { + this.series.update((list) => list.filter((s) => s.id !== seriesId)); + } + + public upsertPoint(seriesId: number, point: SkyChartBarPoint): void { + this.series.update((list) => + list.map((s) => { + if (s.id !== seriesId) { + return s; + } + + const dataToKeep = s.data.filter((p) => p.id !== point.id); + const newData = [...dataToKeep, point]; + return { ...s, data: newData }; + }), + ); + } + + public removePoint(seriesId: number, pointId: number): void { + this.series.update((list) => + list.map((s) => { + if (s.id !== seriesId) { + return s; + } + + const newData = s.data.filter((p) => p.id !== pointId); + return { ...s, data: newData }; + }), + ); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series-data-point.component.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series-data-point.component.ts new file mode 100644 index 0000000000..c043ba5b47 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series-data-point.component.ts @@ -0,0 +1,73 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyCategory } from '../shared/types/category'; + +import { SkyChartBarRegistry } from './chart-bar-registry.service'; +import { + SKY_CHART_BAR_SERIES_ID, + SkyChartBarDatum, + SkyChartBarPoint, +} from './chart-bar-types'; + +let nextId = 0; + +/** + * Represents a single data point within a chart series. + */ +@Component({ + selector: 'sky-chart-bar-series-datapoint', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartBarSeriesDataPointComponent implements OnDestroy { + readonly #registry = inject(SkyChartBarRegistry); + readonly #seriesId = inject(SKY_CHART_BAR_SERIES_ID); + + /** + * The category bucket this data point belongs to (e.g. a month name or a label on the category axis). + */ + public readonly category = input.required(); + + /** + * The human-readable label shown in tooltips for this data point (e.g. "$10,000"). + */ + public readonly labelText = input.required(); + + /** + * The numeric value for this data point. + */ + public readonly value = input.required(); + + /** + * A unique ID for this data point component instance. + */ + readonly #id = nextId++; + + readonly #datapoint = computed(() => { + return { + id: this.#id, + category: this.category(), + labelText: this.labelText(), + value: this.value(), + }; + }); + + constructor() { + effect(() => { + const datapoint = this.#datapoint(); + this.#registry.upsertPoint(this.#seriesId, datapoint); + }); + } + + public ngOnDestroy(): void { + this.#registry.removePoint(this.#seriesId, this.#id); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series.component.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series.component.ts new file mode 100644 index 0000000000..a47e2ab3fe --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series.component.ts @@ -0,0 +1,60 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartBarRegistry } from './chart-bar-registry.service'; +import { SKY_CHART_BAR_SERIES_ID, SkyChartBarPoint } from './chart-bar-types'; + +let nextId = 0; + +/** + * Represents a named data series in a chart. + */ +@Component({ + selector: 'sky-chart-bar-series', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: SKY_CHART_BAR_SERIES_ID, + useFactory: (): number => nextId++, + }, + ], +}) +export class SkyChartBarSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyChartBarRegistry); + readonly #id = inject(SKY_CHART_BAR_SERIES_ID); + + /** + * The display label for this series. Shown in the chart legend and tooltips. + */ + public readonly labelText = input.required(); + + /** + * A unique ID for this series component instance. + */ + readonly #series = computed>(() => ({ + id: this.#id, + labelText: this.labelText(), + data: [], // Data will be dynamically set from children datapoints + })); + + constructor() { + effect(() => { + const series = this.#series(); + this.#registry.upsertSeries(series); + }); + } + + public ngOnDestroy(): void { + this.#registry.removeSeries(this.#id); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-bar/chart-bar-types.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-types.ts new file mode 100644 index 0000000000..c672c1bc61 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-types.ts @@ -0,0 +1,30 @@ +import { InjectionToken } from '@angular/core'; + +import { SkyChartDataPoint } from '../shared/types/chart-data-point'; + +/** + * The orientation of the bar chart + */ +export type SkyChartBarOrientation = 'vertical' | 'horizontal'; + +/** + * A bar chart data point, which can be a single numeric value. + */ +export type SkyChartBarDatum = number; + +/** + * Injection token for providing the series ID to datapoint components. + * @internal + */ +export const SKY_CHART_BAR_SERIES_ID = new InjectionToken( + 'SKY_CHART_BAR_SERIES_ID', +); + +/** + * A single data point within a bar chart series. + * @internal + */ +export interface SkyChartBarPoint extends SkyChartDataPoint { + /** The bar value */ + value: SkyChartBarDatum; +} diff --git a/libs/components/charts/src/lib/modules/chart-bar/chart-bar.component.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar.component.ts new file mode 100644 index 0000000000..d080e53a8c --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar.component.ts @@ -0,0 +1,264 @@ +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + computed, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SKY_CHART_AXIS_REGISTRY } from '../axis/chart-axis-registry.service'; +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; +import { SkyChartService } from '../chart/chart.service'; +import { SkyChartJsDirective } from '../chartjs/chartjs.directive'; +import { buildChartSummary, getLegendItems } from '../shared/chart-helpers'; +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { + SkyChartBarConfigService, + SkyChartBarOptions, +} from './chart-bar-config.service'; +import { SkyChartBarRegistry } from './chart-bar-registry.service'; +import { + SkyChartBarDatum, + SkyChartBarOrientation, + SkyChartBarPoint, +} from './chart-bar-types'; + +/** + * Displays a bar chart visualization. + */ +@Component({ + selector: 'sky-chart-bar', + template: ` + @if (chartConfiguration(); as config) { +
+ +
+ } + `, + // ChartJS requires the Canvas to have a parent container that is dedicated to the element + // See: https://www.chartjs.org/docs/latest/configuration/responsive.html + styles: '.chart-container { position: relative; }', + imports: [SkyChartJsDirective], + providers: [ + SkyChartBarRegistry, + { provide: SKY_CHART_AXIS_REGISTRY, useExisting: SkyChartBarRegistry }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartBarComponent { + // #region Dependency Injection + readonly #chartService = inject(SkyChartService); + readonly #chartRegistry = inject(SkyChartBarRegistry); + readonly #chartConfigService = inject(SkyChartBarConfigService); + readonly #resources = inject(SkyLibResourcesService); + // #endregion + + // #region Inputs + public readonly orientation = input('vertical'); + public readonly stacked = input(false, { transform: booleanAttribute }); + public readonly dataPointsClickEnabled = input(false, { + transform: booleanAttribute, + }); + + /** + * A CSS height value (e.g. `'400px'`, `'20rem'`, `'50vh'`) for the chart. + * When unspecified, the chart uses internal sizing logic. + */ + public readonly height = input(); + // #endregion + + // #region Outputs + public readonly dataPointClick = + output>(); + // #endregion + + // #region View Children + protected readonly chartDirective = viewChild(SkyChartJsDirective); + // #endregion + + readonly #chart = computed(() => this.chartDirective()?.chart()); + readonly #chartUpdated = signal(0); + readonly #refreshLegendItems = signal(0); + + /** The height of the chart */ + protected readonly chartHeight = computed(() => { + const explicitHeight = this.height(); + const options = this.#chartOptions(); + + return explicitHeight ?? this.#chartConfigService.getChartHeight(options); + }); + + readonly #chartOptions = computed(() => { + const dataPointsClickEnabled = this.dataPointsClickEnabled(); + const orientation = this.orientation(); + const stacked = this.stacked(); + + const categoryAxis = this.#chartRegistry.categoryAxis(); + const measureAxis = this.#chartRegistry.measureAxis(); + const series = this.#chartRegistry.series(); + + const options = this.#parseOptions({ + dataPointsClickEnabled: dataPointsClickEnabled, + orientation: orientation, + stacked: stacked, + categoryAxis: categoryAxis, + measureAxis: measureAxis, + series: series, + }); + + return options; + }); + + protected readonly canvasAriaLabel = toSignal( + this.#resources.getString('chart.canvas.label.bar'), + { initialValue: '' }, + ); + + protected readonly chartSummary = toSignal( + toObservable(this.#chartOptions).pipe( + switchMap((options) => (options ? this.#buildChartSummary(options) : '')), + ), + { initialValue: '' }, + ); + + protected readonly chartConfiguration = computed(() => { + const options = this.#chartOptions(); + + if (!options) { + return undefined; + } + + return this.#chartConfigService.buildConfig(options); + }); + + readonly #legendItems = computed(() => { + // Track chart, chart updates, series, and refresh triggers to update legend items + const chart = this.#chart(); + this.#chartUpdated(); // Recalculate on ChartJs updates since we rely on it to track visibility and color state + const series = this.#chartService.series(); + this.#refreshLegendItems(); + + return getLegendItems({ + chart: chart, + legendMode: 'series', + labels: series.map((s) => s.labelText), + }); + }); + + constructor() { + // Sync the generated chart summary to the chart service + effect(() => { + const summary = this.chartSummary(); + this.#chartService.generatedChartSummary.set(summary); + }); + + // Sync series to the chart service + effect(() => { + const config = this.#chartOptions(); + this.#chartService.setSeries(config?.series ?? []); + }); + + // Sync legend items to the chart service + effect(() => { + const items = this.#legendItems(); + this.#chartService.setLegendItems(items); + }); + + // Handle legend toggle requests + effect(() => { + const item = this.#chartService.legendItemToggleRequested(); + if (item) { + this.#onLegendItemToggled(item); + } + }); + } + + /** Handle chart updates */ + protected onChartUpdated(): void { + this.#chartUpdated.update((v) => v + 1); + } + + // #region Private + #parseOptions(context: { + dataPointsClickEnabled: boolean; + orientation: SkyChartBarOrientation; + stacked: boolean; + categoryAxis: Readonly | undefined; + measureAxis: Readonly | undefined; + series: SkyChartSeries[]; + }): SkyChartBarOptions { + const { + dataPointsClickEnabled, + orientation, + stacked, + categoryAxis, + measureAxis, + series, + } = context; + + return { + orientation: orientation, + stacked: stacked, + series: series, + categoryAxis: categoryAxis ? categoryAxis : undefined, + measureAxis: measureAxis ? measureAxis : undefined, + dataPointsClickEnabled: dataPointsClickEnabled, + callbacks: { + onDataPointClick: (dataPoint) => this.dataPointClick.emit(dataPoint), + }, + }; + } + + #onLegendItemToggled(item: SkyChartLegendItem): void { + const chart = this.#chart(); + + if (!chart) { + return; + } + + const isVisible = chart.isDatasetVisible(item.datasetIndex); + chart.setDatasetVisibility(item.datasetIndex, !isVisible); + chart.update(); + + // Refetch the legend items to reflect the updated visibility state + this.#refreshLegendItems.update((v) => v + 1); + } + + #buildChartSummary(options: SkyChartBarOptions): Observable { + const chartTypeDescription$ = this.#resources.getString( + 'chart.summary.bar_chart', + options.series.length, + ); + + return buildChartSummary({ + headingText: this.#chartService.headingText(), + subtitleText: this.#chartService.subtitleText(), + chartTypeDescription$: chartTypeDescription$, + categoryAxis: options.categoryAxis, + measureAxis: options.measureAxis, + resources: this.#resources, + }); + } + // #endregion +} diff --git a/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal-context.ts b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal-context.ts new file mode 100644 index 0000000000..bd1ffbe3be --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal-context.ts @@ -0,0 +1,22 @@ +import { parseCategories } from '../shared/chart-helpers'; +import { SkyChartDataPoint } from '../shared/types/chart-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +export class SkyChartGridModalContext { + public readonly modalTitle: string; + + /** The category labels for the chart data. */ + public readonly categories: (string | number)[]; + + /** The data series to display in the grid. */ + public readonly series: readonly SkyChartSeries[]; + + constructor(data: { + modalTitle: string; + series: readonly SkyChartSeries[]; + }) { + this.modalTitle = data.modalTitle; + this.categories = parseCategories(data.series); + this.series = data.series; + } +} diff --git a/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.html b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.html new file mode 100644 index 0000000000..1911ab6498 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.html @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.scss b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.spec.ts b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.spec.ts new file mode 100644 index 0000000000..8db8a7ce1f --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.spec.ts @@ -0,0 +1,227 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { expect, expectAsync } from '@skyux-sdk/testing'; +import { + SkyModalConfiguration, + SkyModalHostService, + SkyModalInstance, +} from '@skyux/modals'; + +import { GridReadyEvent } from 'ag-grid-community'; + +import { SkyChartDataPoint } from '../shared/types/chart-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartGridModalContext } from './chart-data-grid-modal-context'; +import { SkyChartDataGridModalComponent } from './chart-data-grid-modal.component'; + +describe('SkyChartDataGridModalComponent', () => { + function setupTest(options?: { + modalTitle?: string; + series?: SkyChartSeries[]; + }): { + fixture: ComponentFixture; + mockInstance: jasmine.SpyObj; + } { + const context = new SkyChartGridModalContext({ + modalTitle: options?.modalTitle ?? 'Chart data', + series: options?.series ?? series, + }); + + const mockInstance = jasmine.createSpyObj( + 'SkyModalInstance', + ['close', 'save', 'cancel'], + ); + + TestBed.configureTestingModule({ + imports: [SkyChartDataGridModalComponent], + providers: [ + { provide: SkyChartGridModalContext, useValue: context }, + { provide: SkyModalInstance, useValue: mockInstance }, + { provide: SkyModalHostService, useValue: {} }, + { provide: SkyModalConfiguration, useValue: {} }, + ], + }); + + const fixture = TestBed.createComponent(SkyChartDataGridModalComponent); + fixture.detectChanges(); + + return { fixture, mockInstance }; + } + + it('should pass accessibility', async () => { + const { fixture } = setupTest(); + + await expectAsync(fixture.nativeElement).toBeAccessible(); + }); + + describe('rendering', () => { + it('should display the provided modal title', () => { + const { fixture } = setupTest({ modalTitle: 'My Chart Data' }); + + expect(fixture.componentInstance.title).toBe('My Chart Data'); + }); + + it('should render the close button', () => { + const { fixture } = setupTest(); + + const closeButton: HTMLButtonElement | null = + fixture.nativeElement.querySelector('sky-modal-footer .sky-btn-link'); + + expect(closeButton).not.toBeNull(); + }); + + it('should render the ag-grid wrapper', () => { + const { fixture } = setupTest(); + + expect( + fixture.nativeElement.querySelector('sky-ag-grid-wrapper'), + ).not.toBeNull(); + }); + + it('should render the ag-grid component', () => { + const { fixture } = setupTest(); + + expect( + fixture.nativeElement.querySelector('ag-grid-angular'), + ).not.toBeNull(); + }); + }); + + describe('close behavior', () => { + it('should call instance.close() when close() is called', () => { + const { fixture, mockInstance } = setupTest(); + + fixture.componentInstance.close(); + + expect(mockInstance.close).toHaveBeenCalled(); + }); + + it('should call instance.close() when the close button is clicked', () => { + const { fixture, mockInstance } = setupTest(); + + const closeButton: HTMLButtonElement = + fixture.nativeElement.querySelector('sky-modal-footer .sky-btn-link'); + closeButton.click(); + fixture.detectChanges(); + + expect(mockInstance.close).toHaveBeenCalled(); + }); + }); + + describe('grid configuration', () => { + it('should build gridOptions with a column for each series plus a category column', () => { + const { fixture } = setupTest(); + + const gridOptions = fixture.componentInstance['gridOptions']; + const columnDefs = gridOptions.columnDefs as { field: string }[]; + + // One category column + one per series + expect(columnDefs.length).toBe(3); + expect(columnDefs[0].field).toBe('category'); + expect(columnDefs[1].field).toBe('series_0'); + expect(columnDefs[2].field).toBe('series_1'); + }); + + it('should build rowData with one row per category', () => { + const { fixture } = setupTest(); + + const gridOptions = fixture.componentInstance['gridOptions']; + const rowData = gridOptions.rowData as { category: string }[]; + + expect(rowData.length).toBe(3); + expect(rowData.map((r) => r.category)).toEqual(['Q1', 'Q2', 'Q3']); + }); + + it('should populate row data with series values for each category', () => { + const { fixture } = setupTest(); + + const gridOptions = fixture.componentInstance['gridOptions']; + const rowData = gridOptions.rowData as { + category: string; + series_0: string; + series_1: string; + }[]; + + const q1Row = rowData.find((r) => r.category === 'Q1'); + expect(q1Row?.['series_0']).toBe('$100'); + expect(q1Row?.['series_1']).toBe('$80'); + }); + + it('should use empty string when a category has no data point in a series', () => { + const partialSeries: SkyChartSeries[] = [ + { + id: 1, + labelText: 'Revenue', + data: [ + { id: 1, labelText: '$100', category: 'Q1' }, + { id: 2, labelText: '$200', category: 'Q2' }, + ], + }, + { + id: 2, + labelText: 'Expenses', + data: [ + { id: 3, labelText: '$80', category: 'Q1' }, + // Q2 intentionally missing from this series + ], + }, + ]; + const { fixture } = setupTest({ series: partialSeries }); + + const gridOptions = fixture.componentInstance['gridOptions']; + const rowData = gridOptions.rowData as { + category: string; + series_0: string; + series_1: string; + }[]; + + const q2Row = rowData.find((r) => r.category === 'Q2'); + expect(q2Row?.['series_0']).toBe('$200'); + expect(q2Row?.['series_1']).toBe(''); + }); + + it('should handle empty series gracefully', () => { + const { fixture } = setupTest({ series: [] }); + + const gridOptions = fixture.componentInstance['gridOptions']; + const columnDefs = gridOptions.columnDefs as { field: string }[]; + const rowData = gridOptions.rowData as unknown[]; + + expect(columnDefs.length).toBe(1); // only category column + expect(rowData.length).toBe(0); + }); + + it('should call sizeColumnsToFit when the grid is ready', () => { + const { fixture } = setupTest(); + + const mockApi = jasmine.createSpyObj('GridApi', ['sizeColumnsToFit']); + const { onGridReady } = fixture.componentInstance['gridOptions']; + onGridReady?.({ api: mockApi } as unknown as GridReadyEvent); + + expect(mockApi.sizeColumnsToFit).toHaveBeenCalled(); + }); + }); +}); + +// #region Test Data +const series: SkyChartSeries[] = [ + { + id: 1, + labelText: 'Revenue', + data: [ + { id: 1, labelText: '$100', category: 'Q1' }, + { id: 2, labelText: '$200', category: 'Q2' }, + { id: 3, labelText: '$150', category: 'Q3' }, + ], + }, + { + id: 2, + labelText: 'Expenses', + data: [ + { id: 4, labelText: '$80', category: 'Q1' }, + { id: 5, labelText: '$120', category: 'Q2' }, + { id: 6, labelText: '$90', category: 'Q3' }, + ], + }, +]; +// #endregion diff --git a/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.ts b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.ts new file mode 100644 index 0000000000..dff7a33ad1 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.ts @@ -0,0 +1,156 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { SkyAgGridModule, SkyAgGridService } from '@skyux/ag-grid'; +import { SkyLibResourcesService } from '@skyux/i18n'; +import { SkyModalInstance, SkyModalModule } from '@skyux/modals'; + +import { AgGridAngular } from 'ag-grid-angular'; +import { + CellStyleModule, + ClientSideRowModelModule, + ColDef, + ColumnApiModule, + ColumnAutoSizeModule, + EventApiModule, + GridReadyEvent, + ModuleRegistry, + RowApiModule, + RowSelectionModule, + RowStyleModule, + TextEditorModule, +} from 'ag-grid-community'; + +import { SkyChartsResourcesModule } from '../shared/sky-charts-resources.module'; +import type { SkyCategory } from '../shared/types/category'; +import { SkyChartDataPoint } from '../shared/types/chart-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartGridModalContext } from './chart-data-grid-modal-context'; + +ModuleRegistry.registerModules([ + ClientSideRowModelModule, + RowSelectionModule, + CellStyleModule, + EventApiModule, + ColumnAutoSizeModule, + RowStyleModule, + ColumnApiModule, + RowApiModule, + // Editing isn't needed but skyux/ag-charts uses `api.getEditingCells` which requires this module. + TextEditorModule, +]); + +@Component({ + selector: 'sky-chart-data-grid-modal', + templateUrl: 'chart-data-grid-modal.component.html', + styleUrl: 'chart-data-grid-modal.component.scss', + imports: [ + SkyChartsResourcesModule, + SkyModalModule, + AgGridAngular, + SkyAgGridModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartDataGridModalComponent { + // #region Dependency Injection + readonly #context = inject(SkyChartGridModalContext); + readonly #instance = inject(SkyModalInstance); + readonly #agGridSvc = inject(SkyAgGridService); + readonly #resources = inject(SkyLibResourcesService); + // #endregion + + public readonly title = this.#context.modalTitle; + + readonly #categories = this.#context.categories; + readonly #series = this.#context.series; + + protected readonly categoryHeaderName = toSignal( + this.#resources.getString('chart_data_grid.category_column_name'), + { initialValue: 'Category' }, + ); + + protected readonly gridOptions = this.#agGridSvc.getGridOptions({ + gridOptions: { + defaultColDef: { lockPinned: true }, + columnDefs: this.#buildColumnDefs(this.#series), + rowData: this.#buildRowData(this.#categories, this.#series), + onGridReady: this.#onGridReady.bind(this), + }, + }); + + public close(): void { + this.#instance.close(); + } + + #onGridReady(params: GridReadyEvent): void { + params.api.sizeColumnsToFit(); + } + + #buildColumnDefs( + series: readonly SkyChartSeries[], + ): ColDef[] { + const columns: ColDef[] = [ + { + field: 'category', + headerName: this.categoryHeaderName(), + pinned: 'left', + }, + ]; + + // Add a column for each series + series.forEach((seriesItem, seriesIndex) => { + columns.push({ + field: this.#getSeriesFieldId(seriesIndex), + headerName: seriesItem.labelText, + minWidth: 120, + }); + }); + + return columns; + } + + #buildRowData( + categories: readonly SkyCategory[], + series: readonly SkyChartSeries[], + ): ChartDataGridRow[] { + const rows: ChartDataGridRow[] = []; + + // Create a row for each category + categories.forEach((category) => { + const row: ChartDataGridRow = { category }; + + // Add data from each series for this category + series.forEach((seriesItem, seriesIndex) => { + const fieldId = this.#getSeriesFieldId(seriesIndex); + const dataPoint = seriesItem.data.find((p) => p.category === category); + row[fieldId] = dataPoint?.labelText ?? ''; + }); + + rows.push(row); + }); + + return rows; + } + + #getSeriesFieldId(seriesIndex: number): SeriesFieldId { + return `series_${seriesIndex}`; + } +} + +interface ChartDataGridRow { + /** The category for this row. */ + category: SkyCategory; + + /** + * Dynamically generated fields for each series. + */ + [key: SeriesFieldId]: string; +} + +/** + * Type for the dynamically generated series fields in the grid's row data. + * @example "series_0" for the first series + * @example "series_1" for the second + */ +type SeriesFieldId = `series_${number}`; diff --git a/libs/components/charts/src/lib/modules/chart-donut/chart-donut-config.service.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-config.service.ts new file mode 100644 index 0000000000..77aa90bb86 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-config.service.ts @@ -0,0 +1,162 @@ +import { Injectable, inject } from '@angular/core'; + +import { + ChartConfiguration, + ChartDataset, + ChartOptions, + TooltipItem, +} from 'chart.js'; + +import { SkyChartStyleService } from '../shared/services/chart-style.service'; +import { SkyChartGlobalConfigService } from '../shared/services/global-chart-config.service'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartDonutDatum, SkyChartDonutSlice } from './chart-donut-types'; + +/** + * Configuration service for the Donut Chart component. + */ +@Injectable({ providedIn: 'root' }) +export class SkyChartDonutConfigService { + readonly #chartStyleService = inject(SkyChartStyleService); + readonly #globalConfig = inject(SkyChartGlobalConfigService); + + /** + * Builds a Chart.js Donut Chart configuration based on provided options. + * @remarks This uses the `SkyChartStyleService.styles` signal to support runtime theming recalculations + * @param options bar chart options + */ + public buildConfig( + options: SkyChartDonutOptions, + ): ChartConfiguration<'doughnut'> { + const styles = this.#chartStyleService.styles(); + + // Build categories from series data + const categories = options.series.data.map((d) => d.category); + + // Build datasets from series + const dataset: ChartDataset<'doughnut'> = { + label: options.series.labelText, + data: options.series.data.map((dp) => dp.value), + }; + + // Build Plugin options + const pluginOptions: ChartOptions<'doughnut'>['plugins'] = { + sky_indicator: { dataPointsClickEnabled: options.dataPointsClickEnabled }, + sky_keyboard_nav: { + valueLabel: (_datasetIndex, dataIndex) => { + const series = options.series; + const category = categories[dataIndex]; + const slice = series.data.find((d) => d.category === category); + const label = slice?.labelText ?? ''; + return label; + }, + }, + tooltip: { + intersect: false, + callbacks: { + label(context) { + const series = options.series; + const category = categories[context.dataIndex]; + const slice = series.data.find((d) => d.category === category); + const percent = percentOfVisibleDataset(context); + return `${slice?.labelText} (${percent.toFixed(2)}%)`; + }, + }, + }, + }; + + // Build chart options + const chartOptions: ChartOptions<'doughnut'> = { + layout: { + // Add some extra layout padding for the offset indicators + padding: styles.chartPadding + 15, + }, + interaction: { + mode: 'nearest', + intersect: true, + axis: 'r', + }, + datasets: { + doughnut: { + borderWidth: styles.charts.donut.borderWidth, + borderColor: styles.charts.donut.borderColor, + }, + }, + plugins: pluginOptions, + onClick: (_, elements): void => { + if ( + !options.dataPointsClickEnabled || + !options.callbacks?.onDataPointClick || + elements.length === 0 + ) { + return; + } + + const element = elements[0]; + const series = options.series; + const dataPoint = series.data[element.index]; + + if (dataPoint) { + options.callbacks.onDataPointClick({ + series: series.labelText, + category: dataPoint.category, + value: dataPoint.value, + }); + } + }, + }; + + const config = this.#globalConfig.getMergedChartConfiguration<'doughnut'>({ + type: 'doughnut', + data: { labels: categories, datasets: [dataset] }, + options: chartOptions, + plugins: [], + }); + + return config; + } + + /** + * Gets the appropriate height for a line chart. + * @returns A CSS height value (e.g. '400px') for the chart container + */ + public getChartHeight(): string { + const styles = this.#chartStyleService.styles(); + return styles.height.default; + } +} + +function percentOfVisibleDataset(context: TooltipItem<'doughnut'>): number { + const value = Number(context.raw) || 0; + const chart = context.chart; + + // Total up the visible data points in the dataset + const visibleTotal = context.dataset.data.reduce((sum, v, i) => { + if (!chart.getDataVisibility(i)) { + return sum; + } + + return sum + (Number(v) || 0); + }, 0); + + return visibleTotal ? (value / visibleTotal) * 100 : 0; +} + +// #region Types +/** Configuration for the donut chart component. */ +export interface SkyChartDonutOptions { + /** The data series for the chart. */ + series: SkyChartSeries; + + /** Are the data points clickable */ + dataPointsClickEnabled: boolean; + + callbacks?: { + onDataPointClick: ( + event: SkyChartDataPointClickArgs, + ) => void; + }; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/chart-donut/chart-donut-registry.service.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-registry.service.ts new file mode 100644 index 0000000000..ec7b3b3a62 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-registry.service.ts @@ -0,0 +1,58 @@ +import { Injectable, signal } from '@angular/core'; + +import { SkyChartRegistry } from '../shared/services/chart-registry.service'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartDonutSlice } from './chart-donut-types'; + +@Injectable() +export class SkyChartDonutRegistry + implements SkyChartRegistry +{ + public readonly series = signal[]>([]); + + public upsertSeries(series: SkyChartSeries): void { + this.series.update((list) => { + const idx = list.findIndex((s) => s.id === series.id); + + // Already exists, update it + if (idx >= 0) { + return list.map((s) => (s.id === series.id ? series : s)); + } + + // New series, add it + return [...list, series]; + }); + } + + public removeSeries(seriesId: number): void { + this.series.update((list) => list.filter((s) => s.id !== seriesId)); + } + + public upsertPoint(seriesId: number, point: SkyChartDonutSlice): void { + this.series.update((list) => + list.map((s) => { + if (s.id !== seriesId) { + return s; + } + + const dataToKeep = s.data.filter((p) => p.id !== point.id); + const newData = [...dataToKeep, point]; + return { ...s, data: newData }; + }), + ); + } + + public removePoint(seriesId: number, pointId: number): void { + this.series.update((list) => + list.map((s) => { + if (s.id !== seriesId) { + return s; + } + + const newData = s.data.filter((p) => p.id !== pointId); + return { ...s, data: newData }; + }), + ); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series-data-point.component.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series-data-point.component.ts new file mode 100644 index 0000000000..71bff5fb11 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series-data-point.component.ts @@ -0,0 +1,73 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyCategory } from '../shared/types/category'; + +import { SkyChartDonutRegistry } from './chart-donut-registry.service'; +import { + SKY_CHART_DONUT_SERIES_ID, + SkyChartDonutDatum, + SkyChartDonutSlice, +} from './chart-donut-types'; + +let nextId = 0; + +/** + * Represents a single data point within a donut chart series. + */ +@Component({ + selector: 'sky-chart-donut-series-datapoint', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartDonutSeriesDataPointComponent implements OnDestroy { + readonly #registry = inject(SkyChartDonutRegistry); + readonly #seriesId = inject(SKY_CHART_DONUT_SERIES_ID); + + /** + * The category bucket this data point belongs to (e.g. a month name or a label on the category axis). + */ + public readonly category = input.required(); + + /** + * The human-readable label shown in tooltips for this data point (e.g. "$10,000"). + */ + public readonly labelText = input.required(); + + /** + * The numeric value for this data point. + */ + public readonly value = input.required(); + + /** + * A unique ID for this data point component instance. + */ + readonly #id = nextId++; + + readonly #datapoint = computed(() => { + return { + id: this.#id, + category: this.category(), + labelText: this.labelText(), + value: this.value(), + }; + }); + + constructor() { + effect(() => { + const datapoint = this.#datapoint(); + this.#registry.upsertPoint(this.#seriesId, datapoint); + }); + } + + public ngOnDestroy(): void { + this.#registry.removePoint(this.#seriesId, this.#id); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series.component.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series.component.ts new file mode 100644 index 0000000000..3f712b2a77 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series.component.ts @@ -0,0 +1,60 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartDonutRegistry } from './chart-donut-registry.service'; +import { + SKY_CHART_DONUT_SERIES_ID, + SkyChartDonutSlice, +} from './chart-donut-types'; + +let nextId = 0; + +/** + * Represents a named data series in a chart. + */ +@Component({ + selector: 'sky-chart-donut-series', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: SKY_CHART_DONUT_SERIES_ID, + useFactory: (): number => nextId++, + }, + ], +}) +export class SkyChartDonutSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyChartDonutRegistry); + readonly #id = inject(SKY_CHART_DONUT_SERIES_ID); + + /** + * The display label for this series. Shown in the chart legend and tooltips. + */ + public readonly labelText = input.required(); + + readonly #series = computed>(() => ({ + id: this.#id, + labelText: this.labelText(), + data: [], // Data will be dynamically set from children datapoints + })); + + constructor() { + effect(() => { + const series = this.#series(); + this.#registry.upsertSeries(series); + }); + } + + public ngOnDestroy(): void { + this.#registry.removeSeries(this.#id); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-donut/chart-donut-types.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-types.ts new file mode 100644 index 0000000000..86923ed2bb --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-types.ts @@ -0,0 +1,25 @@ +import { InjectionToken } from '@angular/core'; + +import { SkyChartDataPoint } from '../shared/types/chart-data-point'; + +/** + * A donut chart data point, which is a single numeric value representing the size of the slice. + */ +export type SkyChartDonutDatum = number; + +/** + * Injection token for providing the series ID to datapoint components. + * @internal + */ +export const SKY_CHART_DONUT_SERIES_ID = new InjectionToken( + 'SKY_CHART_DONUT_SERIES_ID', +); + +/** + * A single data point within a donut chart series. + * @internal + */ +export interface SkyChartDonutSlice extends SkyChartDataPoint { + /** Numeric value */ + value: SkyChartDonutDatum; +} diff --git a/libs/components/charts/src/lib/modules/chart-donut/chart-donut.component.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut.component.ts new file mode 100644 index 0000000000..7f31b3e5e1 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut.component.ts @@ -0,0 +1,234 @@ +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + computed, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import type { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; +import { SkyChartService } from '../chart/chart.service'; +import { SkyChartJsDirective } from '../chartjs/chartjs.directive'; +import { buildChartSummary, getLegendItems } from '../shared/chart-helpers'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { + SkyChartDonutConfigService, + SkyChartDonutOptions, +} from './chart-donut-config.service'; +import { SkyChartDonutRegistry } from './chart-donut-registry.service'; +import { SkyChartDonutDatum, SkyChartDonutSlice } from './chart-donut-types'; + +/** + * Displays a donut chart visualization. + */ +@Component({ + selector: 'sky-chart-donut', + template: ` + @if (chartConfiguration(); as config) { +
+ +
+ } + `, + // ChartJS requires the Canvas to have a parent container that is dedicated to the element + // See: https://www.chartjs.org/docs/latest/configuration/responsive.html + styles: '.chart-container { position: relative; }', + imports: [SkyChartJsDirective], + providers: [SkyChartDonutRegistry], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartDonutComponent { + // #region Dependency Injection + readonly #chartService = inject(SkyChartService); + readonly #chartRegistry = inject(SkyChartDonutRegistry); + readonly #chartConfigService = inject(SkyChartDonutConfigService); + readonly #resources = inject(SkyLibResourcesService); + // #endregion + + // #region Inputs + public readonly dataPointsClickEnabled = input(false, { + transform: booleanAttribute, + }); + + /** + * A CSS height value (e.g. `'400px'`, `'20rem'`, `'50vh'`) for the chart. + * When unspecified, the chart uses internal sizing logic. + */ + public readonly height = input(); + // #endregion + + // #region Outputs + public readonly dataPointClick = + output>(); + // #endregion + + // #region View Children + protected readonly chartDirective = viewChild(SkyChartJsDirective); + // #endregion + + readonly #chart = computed(() => this.chartDirective()?.chart()); + readonly #chartUpdated = signal(0); + readonly #refreshLegendItems = signal(0); + + readonly #chartOptions = computed(() => { + const dataPointsClickEnabled = this.dataPointsClickEnabled(); + const series = this.#chartRegistry.series(); + const options = this.#parseOptions({ + dataPointsClickEnabled: dataPointsClickEnabled, + series: series, + }); + + return options; + }); + + /** The height of the chart */ + protected readonly chartHeight = computed(() => { + const explicitHeight = this.height(); + return explicitHeight ?? this.#chartConfigService.getChartHeight(); + }); + + protected readonly canvasAriaLabel = toSignal( + this.#resources.getString('chart.canvas.label.donut'), + { initialValue: '' }, + ); + + protected readonly chartSummary = toSignal( + toObservable(this.#chartOptions).pipe( + switchMap((options) => (options ? this.#buildChartSummary(options) : '')), + ), + { initialValue: '' }, + ); + + protected readonly chartConfiguration = computed(() => { + const options = this.#chartOptions(); + + if (!options) { + return undefined; + } + + return this.#chartConfigService.buildConfig(options); + }); + + readonly #legendItems = computed(() => { + // Track chart, chart updates, series, and refresh triggers to update legend items + const chart = this.#chart(); + this.#chartUpdated(); // Recalculate on ChartJs updates since we rely on it to track visibility and color state + const series = this.#chartService.series(); + this.#refreshLegendItems(); + + const data = series[0]?.data ?? []; + const categoryLabels = data.map((dp) => dp.category.toString()); + + return getLegendItems({ + chart: chart, + legendMode: 'category', + labels: categoryLabels, + }); + }); + + constructor() { + // Sync the generated chart summary to the chart service + effect(() => { + const summary = this.chartSummary(); + this.#chartService.generatedChartSummary.set(summary); + }); + + // Sync series to the chart service + effect(() => { + const config = this.#chartOptions(); + const series = config?.series ? [config.series] : []; + this.#chartService.setSeries(series); + }); + + // Sync legend items to the chart service + effect(() => { + const items = this.#legendItems(); + this.#chartService.setLegendItems(items); + }); + + // Handle legend toggle requests + effect(() => { + const item = this.#chartService.legendItemToggleRequested(); + if (item) { + this.#onLegendItemToggled(item); + } + }); + } + + /** Handle chart updates */ + protected onChartUpdated(): void { + this.#chartUpdated.update((v) => v + 1); + } + + // #region Private + #parseOptions(context: { + dataPointsClickEnabled: boolean; + series: SkyChartSeries[]; + }): SkyChartDonutOptions | undefined { + const { dataPointsClickEnabled, series } = context; + + // Donut charts only supports a single series + if (series.length > 1) { + throw new Error('Donut charts only support a single series.'); + } + + // Return undefined if no series exists yet + if (series.length === 0) { + return undefined; + } + + return { + series: series[0], + dataPointsClickEnabled: dataPointsClickEnabled, + callbacks: { + onDataPointClick: (dataPoint) => this.dataPointClick.emit(dataPoint), + }, + }; + } + + #onLegendItemToggled(item: SkyChartLegendItem): void { + const chart = this.#chart(); + + if (!chart) { + return; + } + + chart.toggleDataVisibility(item.index); + chart.update(); + + // Refetch the legend items to reflect the updated visibility state + this.#refreshLegendItems.update((v) => v + 1); + } + + #buildChartSummary(options: SkyChartDonutOptions): Observable { + const chartTypeDescription$ = this.#resources.getString( + 'chart.summary.donut_chart', + options.series.data.length, + ); + + return buildChartSummary({ + headingText: this.#chartService.headingText(), + subtitleText: this.#chartService.subtitleText(), + chartTypeDescription$: chartTypeDescription$, + resources: this.#resources, + }); + } + // #endregion +} diff --git a/libs/components/charts/src/lib/modules/chart-legend/chart-legend-item.ts b/libs/components/charts/src/lib/modules/chart-legend/chart-legend-item.ts new file mode 100644 index 0000000000..f8f204d42f --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend-item.ts @@ -0,0 +1,15 @@ +/** + * A legend item in the chart legend. + */ +export interface SkyChartLegendItem { + /** The dataset index */ + datasetIndex: number; + /** The legend item index */ + index: number; + /** Is the dataset visible in the chart */ + isVisible: boolean; + /** The legend item's label */ + labelText: string; + /** The series color */ + seriesColor: string; +} diff --git a/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.html b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.html new file mode 100644 index 0000000000..c929702093 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.html @@ -0,0 +1,51 @@ +@if (hasLegendItems()) { + {{ + 'chart.legend.legend_item.aria_description' | skyLibResources + }} + + +} diff --git a/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.scss b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.scss new file mode 100644 index 0000000000..a71c1cf2ac --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.scss @@ -0,0 +1,87 @@ +@use 'libs/components/theme/src/lib/styles/compat-tokens-mixins' as compatMixins; + +@include compatMixins.sky-default-overrides('.sky-chart-legend') { + // TODO: Add overrides here +} + +.sky-chart-legend { + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +.sky-chart-legend-button { + display: flex; + align-items: center; + border-radius: var(--sky-border-radius-s, 3px); + border: none; + outline: none; + background-color: var( + --sky-color-background-action-tertiary-base, + transparent + ); + box-shadow: + inset 0 0 0 var(--sky-border-width-action-base, 1px) + var(--sky-color-border-action-tertiary-base, transparent), + var(--sky-elevation-action-tertiary-base, 0 0 0 0 transparent); + + padding-top: var(--sky-comp-chart-legend_item-space-inset-top, 6px); + padding-right: var(--sky-comp-chart-legend_item-space-inset-right, 12px); + padding-bottom: var(--sky-comp-chart-legend_item-space-inset-bottom, 6px); + padding-left: var(--sky-comp-chart-legend_item-space-inset-left, 12px); + + &:hover { + background-color: var( + --sky-color-background-action-tertiary-hover, + #eeeeef + ); + box-shadow: + inset 0 0 0 var(--sky-border-width-action-hover, 1px) + var(--sky-color-border-action-tertiary-hover, #0974a1), + var(--sky-elevation-action-tertiary-hover, 0 0 0 0 transparent); + cursor: pointer; + } + + &:active { + background-color: var( + --sky-color-background-action-tertiary-active, + transparent + ); + box-shadow: + inset 0 0 0 var(--sky-border-width-action-active, 2px) + var(--sky-color-border-action-tertiary-active, #0974a1), + var(--sky-elevation-action-tertiary-active, 0 0 0 0 transparent); + } + + &:focus-visible { + background-color: var( + --sky-color-background-action-tertiary-focus, + transparent + ); + box-shadow: + inset 0 0 0 var(--sky-border-width-action-focus, 2px) + var(--sky-color-border-action-tertiary-focus, #0974a1), + var(--sky-elevation-action-tertiary-focus, 0 0 0 0 transparent); + } +} + +.sky-chart-legend-button-icon { + display: inline-block; + flex-shrink: 0; + + height: var(--sky-size-icon-xs, 12px); + width: var(--sky-size-icon-xs, 12px); + + margin-right: var(--sky-space-gap-icon-s, var(--sky-margin-inline-xs)); + + // Makes the legend marker circular + border-radius: 50%; +} + +.sky-chart-legend-button-label { + font-family: var(--sky-font-family-primary); + font-size: var(--sky-font-size-body-s, 13px); + font-weight: var(--sky-font-style-body-s, 400); + line-height: var(--sky-font-line_height-body-s, 18px); + color: var(--sky-color-text-deemphasized, #686c73); +} diff --git a/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.spec.ts b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.spec.ts new file mode 100644 index 0000000000..81557e53eb --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.spec.ts @@ -0,0 +1,443 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SkyAppTestUtility, expect, expectAsync } from '@skyux-sdk/testing'; + +import { SkyChartLegendItem } from './chart-legend-item'; +import { SkyChartLegendComponent } from './chart-legend.component'; + +describe('SkyChartLegendComponent', () => { + let fixture: ComponentFixture; + + function createItems(count: number, allVisible = true): SkyChartLegendItem[] { + return Array.from({ length: count }, (_, i) => ({ + datasetIndex: 0, + index: i, + isVisible: allVisible, + labelText: `Series ${i + 1}`, + seriesColor: `#00000${i}`, + })); + } + + function setItems(items: SkyChartLegendItem[]): void { + fixture.componentRef.setInput('legendItems', items); + fixture.detectChanges(); + } + + function getLegendList(): HTMLElement { + const el: HTMLElement | null = + fixture.nativeElement.querySelector('.sky-chart-legend'); + if (!el) { + throw new Error('Legend list element not found'); + } + return el; + } + + function getLegendButtons(): NodeListOf { + return fixture.nativeElement.querySelectorAll('.sky-chart-legend-button'); + } + + function fireLegendKeydown(key: string): void { + const list = getLegendList(); + if (!list) { + throw new Error('Legend list not found'); + } + SkyAppTestUtility.fireDomEvent(list, 'keydown', { + keyboardEventInit: { key }, + }); + fixture.detectChanges(); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [SkyChartLegendComponent], + }); + + fixture = TestBed.createComponent(SkyChartLegendComponent); + }); + + describe('accessibility', () => { + it('should be accessible with all visible legend items', async () => { + setItems(createItems(3)); + await fixture.whenStable(); + + await expectAsync(fixture.nativeElement).toBeAccessible(); + }); + + it('should be accessible with mixed visible and hidden items', async () => { + const items = createItems(3); + items[1].isVisible = false; + setItems(items); + await fixture.whenStable(); + + await expectAsync(fixture.nativeElement).toBeAccessible(); + }); + }); + + describe('rendering', () => { + it('should not render the legend list when there are no items', () => { + setItems([]); + + expect( + fixture.nativeElement.querySelector('.sky-chart-legend'), + ).toBeNull(); + }); + + it('should render the legend list when items are provided', () => { + setItems(createItems(3)); + + expect( + fixture.nativeElement.querySelector('.sky-chart-legend'), + ).not.toBeNull(); + }); + + it('should render the correct number of buttons', () => { + setItems(createItems(4)); + + expect(getLegendButtons().length).toBe(4); + }); + + it('should display the correct label text for each item', () => { + const items = createItems(2); + setItems(items); + + const buttons = getLegendButtons(); + expect( + buttons[0] + .querySelector('.sky-chart-legend-button-label') + ?.textContent?.trim(), + ).toBe('Series 1'); + expect( + buttons[1] + .querySelector('.sky-chart-legend-button-label') + ?.textContent?.trim(), + ).toBe('Series 2'); + }); + + it('should set the correct series color as the icon background', () => { + const items = createItems(1); + items[0].seriesColor = 'rgb(255, 0, 0)'; + setItems(items); + + const icon: HTMLElement = fixture.nativeElement.querySelector( + '.sky-chart-legend-button-icon', + ); + expect(icon.style.background).toBe('rgb(255, 0, 0)'); + }); + + it('should have role="switch" on legend buttons', () => { + setItems(createItems(1)); + + expect(getLegendButtons()[0].getAttribute('role')).toBe('switch'); + }); + + it('should set aria-checked to true for visible items', () => { + const items = createItems(1); + items[0].isVisible = true; + setItems(items); + + expect(getLegendButtons()[0].getAttribute('aria-checked')).toBe('true'); + }); + + it('should set aria-checked to false for hidden items', () => { + const items = createItems(1); + items[0].isVisible = false; + setItems(items); + + expect(getLegendButtons()[0].getAttribute('aria-checked')).toBe('false'); + }); + + it('should set the correct aria-label on legend buttons', () => { + setItems(createItems(3)); + + const buttons = getLegendButtons(); + expect(buttons[0].getAttribute('aria-label')).toBe( + 'Series 1, Legend item 1 of 3', + ); + expect(buttons[1].getAttribute('aria-label')).toBe( + 'Series 2, Legend item 2 of 3', + ); + expect(buttons[2].getAttribute('aria-label')).toBe( + 'Series 3, Legend item 3 of 3', + ); + }); + + it('should set aria-describedby on buttons referencing the description span', () => { + setItems(createItems(1)); + + const button = getLegendButtons()[0]; + const describedById = button.getAttribute('aria-describedby'); + expect(describedById).toBeTruthy(); + + const descriptionEl: HTMLElement | null = + fixture.nativeElement.querySelector(`#${describedById}`); + expect(descriptionEl?.textContent?.trim()).toBe( + 'Press Space or Enter to toggle inclusion in chart.', + ); + }); + + it('should apply line-through text decoration for hidden items', () => { + const items = createItems(2); + items[1].isVisible = false; + setItems(items); + + const labels: NodeListOf = + fixture.nativeElement.querySelectorAll( + '.sky-chart-legend-button-label', + ); + expect(labels[0].style.textDecoration).toBe(''); + expect(labels[1].style.textDecoration).toBe('line-through'); + }); + + it('should have role="toolbar" on the legend list', () => { + setItems(createItems(1)); + + expect(getLegendList().getAttribute('role')).toBe('toolbar'); + }); + + it('should set the accessible label on the legend list', () => { + setItems(createItems(1)); + + expect(getLegendList().getAttribute('aria-label')).toBe('Chart legend'); + }); + + it('should set tabindex 0 on the first button and -1 on all others by default', () => { + setItems(createItems(3)); + + const buttons = getLegendButtons(); + expect(buttons[0].getAttribute('tabindex')).toBe('0'); + expect(buttons[1].getAttribute('tabindex')).toBe('-1'); + expect(buttons[2].getAttribute('tabindex')).toBe('-1'); + }); + }); + + describe('toggle legend item', () => { + it('should emit legendItemToggled when a visible item is clicked', () => { + const items = createItems(2); + setItems(items); + + const emitSpy = jasmine.createSpy('legendItemToggled'); + fixture.componentInstance.legendItemToggled.subscribe(emitSpy); + + getLegendButtons()[0].click(); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledOnceWith(items[0]); + }); + + it('should emit legendItemToggled when a hidden item is clicked', () => { + const items = createItems(2, false); + setItems(items); + + const emitSpy = jasmine.createSpy('legendItemToggled'); + fixture.componentInstance.legendItemToggled.subscribe(emitSpy); + + getLegendButtons()[0].click(); + fixture.detectChanges(); + + expect(emitSpy).toHaveBeenCalledOnceWith(items[0]); + }); + + it('should NOT emit legendItemToggled when the last visible item is clicked', () => { + const items = createItems(2, false); + items[0].isVisible = true; + setItems(items); + + const emitSpy = jasmine.createSpy('legendItemToggled'); + fixture.componentInstance.legendItemToggled.subscribe(emitSpy); + + getLegendButtons()[0].click(); + fixture.detectChanges(); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('keyboard navigation', () => { + it('should move to the next item on ArrowRight', () => { + setItems(createItems(3)); + + fireLegendKeydown('ArrowRight'); + + const buttons = getLegendButtons(); + expect(buttons[0].getAttribute('tabindex')).toBe('-1'); + expect(buttons[1].getAttribute('tabindex')).toBe('0'); + }); + + it('should move to the next item on ArrowDown', () => { + setItems(createItems(3)); + + fireLegendKeydown('ArrowDown'); + + const buttons = getLegendButtons(); + expect(buttons[0].getAttribute('tabindex')).toBe('-1'); + expect(buttons[1].getAttribute('tabindex')).toBe('0'); + }); + + it('should move to the previous item on ArrowLeft', () => { + setItems(createItems(3)); + fireLegendKeydown('ArrowRight'); + + fireLegendKeydown('ArrowLeft'); + + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should move to the previous item on ArrowUp', () => { + setItems(createItems(3)); + fireLegendKeydown('ArrowDown'); + + fireLegendKeydown('ArrowUp'); + + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should wrap from the first item to the last on ArrowLeft', () => { + setItems(createItems(3)); + + fireLegendKeydown('ArrowLeft'); + + expect(getLegendButtons()[2].getAttribute('tabindex')).toBe('0'); + }); + + it('should wrap from the last item to the first on ArrowRight', () => { + setItems(createItems(3)); + fireLegendKeydown('End'); + + fireLegendKeydown('ArrowRight'); + + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should move to the first item on Home', () => { + setItems(createItems(3)); + fireLegendKeydown('End'); + + fireLegendKeydown('Home'); + + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should move to the last item on End', () => { + setItems(createItems(3)); + + fireLegendKeydown('End'); + + expect(getLegendButtons()[2].getAttribute('tabindex')).toBe('0'); + }); + + it('should emit legendItemToggled for the active item on Enter', () => { + const items = createItems(2); + setItems(items); + + const emitSpy = jasmine.createSpy('legendItemToggled'); + fixture.componentInstance.legendItemToggled.subscribe(emitSpy); + + fireLegendKeydown('Enter'); + + expect(emitSpy).toHaveBeenCalledOnceWith(items[0]); + }); + + it('should emit legendItemToggled for the active item on Space', () => { + const items = createItems(2); + setItems(items); + + const emitSpy = jasmine.createSpy('legendItemToggled'); + fixture.componentInstance.legendItemToggled.subscribe(emitSpy); + + fireLegendKeydown(' '); + + expect(emitSpy).toHaveBeenCalledOnceWith(items[0]); + }); + + it('should not change the active index on unhandled keys', () => { + setItems(createItems(3)); + + fireLegendKeydown('Tab'); + + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should do nothing when no items are present and a key is pressed', () => { + // The legend list is not rendered when items are empty, so DOM events + // cannot reach onLegendKeydown. Invoke it directly to exercise the + // count === 0 guard branch. + setItems([]); + + const component = fixture.componentInstance as unknown as { + onLegendKeydown: (event: KeyboardEvent) => void; + }; + + expect(() => { + component.onLegendKeydown( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + }).not.toThrow(); + }); + }); + + describe('focus management', () => { + it('should reset to the first item when focus enters the legend from outside', () => { + setItems(createItems(3)); + fireLegendKeydown('End'); // Navigate to index 2. + + // Simulate focus coming from an external element (e.g., user tabbed in). + const externalEl = document.createElement('button'); + document.body.appendChild(externalEl); + + SkyAppTestUtility.fireDomEvent(getLegendList(), 'focusin', { + customEventInit: { relatedTarget: externalEl }, + }); + fixture.detectChanges(); + + document.body.removeChild(externalEl); + + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('0'); + }); + + it('should not reset active index when focus moves within the legend', () => { + setItems(createItems(3)); + fireLegendKeydown('ArrowRight'); // Navigate to index 1. + + // Simulate focus moving from one button (inside the legend) to another. + SkyAppTestUtility.fireDomEvent(getLegendList(), 'focusin', { + customEventInit: { relatedTarget: getLegendButtons()[0] }, + }); + fixture.detectChanges(); + + // Active index should remain at 1. + expect(getLegendButtons()[1].getAttribute('tabindex')).toBe('0'); + }); + + it('should update the active index when a button receives focus directly', () => { + setItems(createItems(3)); + + // Dispatch native focus on the third button to trigger onLegendButtonFocus. + SkyAppTestUtility.fireDomEvent(getLegendButtons()[2], 'focus'); + fixture.detectChanges(); + + expect(getLegendButtons()[2].getAttribute('tabindex')).toBe('0'); + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('-1'); + }); + }); + + describe('active index clamping effect', () => { + it('should clamp the active index when items are reduced below the current index', () => { + setItems(createItems(3)); + fireLegendKeydown('End'); // Navigate to index 2. + + setItems(createItems(2)); // Reduce to 2 items; index 2 is now out of bounds. + + expect(getLegendButtons()[1].getAttribute('tabindex')).toBe('0'); + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('-1'); + }); + + it('should reset active index to 0 when items become empty and are then re-added', () => { + setItems(createItems(3)); + fireLegendKeydown('End'); // Navigate to index 2. + + setItems([]); + setItems(createItems(2)); + + expect(getLegendButtons()[0].getAttribute('tabindex')).toBe('0'); + }); + }); +}); diff --git a/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.ts b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.ts new file mode 100644 index 0000000000..607b78ce1f --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.ts @@ -0,0 +1,145 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + effect, + input, + output, + signal, + viewChildren, +} from '@angular/core'; +import { SkyIdModule } from '@skyux/core'; + +import { SkyChartsResourcesModule } from '../shared/sky-charts-resources.module'; + +import { SkyChartLegendItem } from './chart-legend-item'; + +@Component({ + selector: 'sky-chart-legend', + templateUrl: './chart-legend.component.html', + styleUrl: './chart-legend.component.scss', + imports: [SkyChartsResourcesModule, SkyIdModule], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartLegendComponent { + /** The legend items */ + public readonly legendItems = input.required(); + + /** Emits when a legend item is toggled (clicked or activated via keyboard). */ + public readonly legendItemToggled = output(); + + /** The list of legend item buttons */ + protected readonly legendButtons = + viewChildren>('legendButton'); + + protected readonly hasLegendItems = computed( + () => this.legendItems().length > 0, + ); + protected readonly activeLegendIndex = signal(0); + + readonly #isLastVisible = computed(() => { + const visibleLegendItems = this.legendItems().filter( + (i) => i.isVisible, + ).length; + return visibleLegendItems === 1; + }); + + constructor() { + effect(() => { + const count = this.legendItems().length; + const index = this.activeLegendIndex(); + + if (count <= 0) { + this.activeLegendIndex.set(0); + } else if (index > count - 1) { + this.activeLegendIndex.set(count - 1); + } + }); + } + + /** + * When focus enters the legend from outside, set the active index to the first legend item and focus it. + * @param event The focus event + */ + protected onLegendFocusIn(event: FocusEvent): void { + const host = event.currentTarget; + const related = event.relatedTarget; + const enteredFromOutside = + host instanceof HTMLElement && + related instanceof HTMLElement && + !host.contains(related); + + if (enteredFromOutside) { + this.activeLegendIndex.set(0); + this.#focusLegendButton(0); + } + } + + protected onLegendKeydown(event: KeyboardEvent): void { + const count = this.legendItems().length; + + if (count === 0) { + return; + } + + const current = this.activeLegendIndex(); + let next = current; + + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + next = (current + 1) % count; + break; + case 'ArrowLeft': + case 'ArrowUp': + next = (current - 1 + count) % count; + break; + case 'Home': + next = 0; + break; + case 'End': + next = count - 1; + break; + case 'Enter': + case ' ': + event.preventDefault(); + this.toggleLegendItem(this.legendItems()[current], current); + return; + default: + return; + } + + event.preventDefault(); + this.activeLegendIndex.set(next); + this.#focusLegendButton(next); + } + + protected onLegendButtonFocus(index: number): void { + if (this.activeLegendIndex() !== index) { + this.activeLegendIndex.set(index); + } + } + + protected isItemDisabled(item: SkyChartLegendItem): boolean { + return item.isVisible && this.#isLastVisible(); + } + + protected toggleLegendItem(item: SkyChartLegendItem, index?: number): void { + // Guard against toggling the last visible item off, which would leave the chart without any visible data. + if (this.isItemDisabled(item)) { + return; + } + + if (item.isVisible && index !== undefined) { + this.activeLegendIndex.set(index); + } + + this.legendItemToggled.emit(item); + } + + #focusLegendButton(index: number): void { + const button = this.legendButtons()[index]?.nativeElement; + button?.focus(); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-line/chart-line-config.service.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-config.service.ts new file mode 100644 index 0000000000..7c7e7cb48c --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-config.service.ts @@ -0,0 +1,225 @@ +import { Injectable, inject } from '@angular/core'; + +import { + ChartConfiguration, + ChartDataset, + ChartOptions, + ChartTypeRegistry, + ScaleOptionsByType, +} from 'chart.js'; + +import { parseCategories } from '../shared/chart-helpers'; +import { + buildCategoryScale, + buildLinearMeasureScale, + buildLogarithmicMeasureScale, +} from '../shared/scale-mapping'; +import { + SkyChartStyleService, + SkyChartStyles, +} from '../shared/services/chart-style.service'; +import { SkyChartGlobalConfigService } from '../shared/services/global-chart-config.service'; +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; +import { SkyCategory } from '../shared/types/category'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; +import { SkyChartSeries } from '../shared/types/chart-series'; +import { DeepPartial } from '../shared/types/deep-partial-type'; + +import { SkyChartLineDatum, SkyChartLinePoint } from './chart-line-types'; + +/** + * Configuration service for the Line Chart component. + */ +@Injectable({ providedIn: 'root' }) +export class SkyChartLineConfigService { + readonly #chartStyleService = inject(SkyChartStyleService); + readonly #globalConfig = inject(SkyChartGlobalConfigService); + + /** + * Builds a Chart.js Line Chart configuration based on provided options. + * @remarks This uses the `SkyChartStyleService.styles` signal to support runtime theming recalculations + * @param options bar chart options + */ + public buildConfig(options: SkyChartLineOptions): ChartConfiguration<'line'> { + const styles = this.#chartStyleService.styles(); + + // Build categories from series data + const categories = parseCategories(options.series); + + // Build datasets from series + const datasets: ChartDataset<'line'>[] = options.series.map((series) => { + const dataByCategory = new Map(); + + for (const p of series.data) { + dataByCategory.set(p.category, p.value); + } + + // Backfill null for categories missing from this series + const data = categories.map((category) => { + return dataByCategory.get(category) ?? null; + }); + + const dataset: ChartDataset<'line'> = { + label: series.labelText, + data: data, + }; + + return dataset; + }); + + // Build Plugin options + const pluginOptions: ChartOptions<'line'>['plugins'] = { + sky_indicator: { dataPointsClickEnabled: options.dataPointsClickEnabled }, + sky_keyboard_nav: { + valueLabel: (datasetIndex, dataIndex) => { + const series = options.series[datasetIndex]; + const category = categories[dataIndex]; + const dataPoint = series.data.find((d) => d.category === category); + const label = dataPoint?.labelText ?? ''; + return label; + }, + }, + tooltip: { + intersect: false, + callbacks: { + label: function (context) { + const series = options.series[context.datasetIndex]; + const category = categories[context.dataIndex]; + const dataPoint = series.data.find((d) => d.category === category); + return `${series.labelText}: ${dataPoint?.labelText}`; + }, + }, + }, + }; + + // Build ChartJS options + const chartOptions: ChartOptions<'line'> = { + indexAxis: 'x', + interaction: { + mode: 'index', + intersect: true, + axis: 'xy', + }, + elements: { + line: { + tension: styles.charts.line.tension, + borderWidth: styles.charts.line.borderWidth, + }, + point: { + radius: styles.charts.line.pointRadius, + hoverRadius: styles.charts.line.pointHoverRadius, + borderWidth: styles.charts.line.pointBorderWidth, + hoverBorderWidth: styles.charts.line.pointBorderWidth, + pointStyle: 'circle', + }, + }, + scales: this.#createScales(styles, options), + plugins: pluginOptions, + onClick: (_, elements): void => { + if ( + !options.dataPointsClickEnabled || + !options.callbacks?.onDataPointClick || + elements.length === 0 + ) { + return; + } + + const element = elements[0]; + const series = options.series[element.datasetIndex]; + const category = categories[element.index]; + const dataPoint = series.data.find((d) => d.category === category); + + if (dataPoint) { + options.callbacks.onDataPointClick({ + series: series.labelText, + category: category, + value: dataPoint.value, + }); + } + }, + }; + + const config = this.#globalConfig.getMergedChartConfiguration<'line'>({ + type: 'line', + data: { labels: categories, datasets: datasets }, + options: chartOptions, + plugins: [], + }); + + return config; + } + + /** + * Gets the appropriate height for a line chart. + * @returns A CSS height value (e.g. '400px') for the chart container + */ + public getChartHeight(): string { + const styles = this.#chartStyleService.styles(); + return styles.height.default; + } + + #createScales( + styles: SkyChartStyles, + config: SkyChartLineOptions, + ): ChartOptions<'line'>['scales'] { + const categoryScale = buildCategoryScale({ + styles: styles, + stacked: config.stacked, + categoryAxis: config.categoryAxis, + }); + + const measureScale = this.#createMeasureScale(styles, config); + + return { x: categoryScale, y: measureScale }; + } + + #createMeasureScale( + styles: SkyChartStyles, + config: SkyChartLineOptions, + ): PartialLineScale { + const params = { + styles, + stacked: config.stacked ?? false, + measureAxis: config.measureAxis, + }; + + if (config.measureAxis?.scaleType === 'logarithmic') { + return buildLogarithmicMeasureScale(params); + } else { + return buildLinearMeasureScale(params); + } + } +} + +// #region Types +/** Configuration for the line chart component. */ +export interface SkyChartLineOptions { + /** The data series for the chart. */ + series: SkyChartSeries[]; + + /** Whether the chart should display stacked series. */ + stacked?: boolean; + + /** Configuration for the category axis. */ + categoryAxis?: SkyChartCategoryAxisConfig; + + /** Configuration for the measure axis. */ + measureAxis?: SkyChartMeasureAxisConfig; + + /** Are the data points clickable */ + dataPointsClickEnabled: boolean; + + callbacks?: { + onDataPointClick: ( + event: SkyChartDataPointClickArgs, + ) => void; + }; +} + +type PartialLineScale = DeepPartial< + ScaleOptionsByType +>; +// #endregion diff --git a/libs/components/charts/src/lib/modules/chart-line/chart-line-registry.service.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-registry.service.ts new file mode 100644 index 0000000000..a5b17d96dd --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-registry.service.ts @@ -0,0 +1,85 @@ +import { Injectable, signal } from '@angular/core'; + +import { SkyChartAxisRegistry } from '../axis/chart-axis-registry.service'; +import { SkyChartRegistry } from '../shared/services/chart-registry.service'; +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartLinePoint } from './chart-line-types'; + +@Injectable() +export class SkyChartLineRegistry + implements SkyChartRegistry, SkyChartAxisRegistry +{ + public readonly categoryAxis = signal( + undefined, + ); + public readonly measureAxis = signal( + undefined, + ); + public readonly series = signal[]>([]); + + public upsertCategoryAxis(axis: SkyChartCategoryAxisConfig): void { + this.categoryAxis.set(axis); + } + + public removeCategoryAxis(): void { + this.categoryAxis.set(undefined); + } + + public upsertMeasureAxis(axis: SkyChartMeasureAxisConfig): void { + this.measureAxis.set(axis); + } + + public removeMeasureAxis(): void { + this.measureAxis.set(undefined); + } + + public upsertSeries(series: SkyChartSeries): void { + this.series.update((list) => { + const idx = list.findIndex((s) => s.id === series.id); + + // Already exists, update it + if (idx >= 0) { + return list.map((s) => (s.id === series.id ? series : s)); + } + + // New series, add it + return [...list, series]; + }); + } + + public removeSeries(seriesId: number): void { + this.series.update((list) => list.filter((s) => s.id !== seriesId)); + } + + public upsertPoint(seriesId: number, point: SkyChartLinePoint): void { + this.series.update((list) => + list.map((s) => { + if (s.id !== seriesId) { + return s; + } + + const dataToKeep = s.data.filter((p) => p.id !== point.id); + const newData = [...dataToKeep, point]; + return { ...s, data: newData }; + }), + ); + } + + public removePoint(seriesId: number, pointId: number): void { + this.series.update((list) => + list.map((s) => { + if (s.id !== seriesId) { + return s; + } + + const newData = s.data.filter((p) => p.id !== pointId); + return { ...s, data: newData }; + }), + ); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-line/chart-line-series-data-point.component.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-series-data-point.component.ts new file mode 100644 index 0000000000..0262f3be77 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-series-data-point.component.ts @@ -0,0 +1,73 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyCategory } from '../shared/types/category'; + +import { SkyChartLineRegistry } from './chart-line-registry.service'; +import { + SKY_CHART_LINE_SERIES_ID, + SkyChartLineDatum, + SkyChartLinePoint, +} from './chart-line-types'; + +let nextId = 0; + +/** + * Represents a single data point within a line chart series. + */ +@Component({ + selector: 'sky-chart-line-series-datapoint', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartLineSeriesDataPointComponent implements OnDestroy { + readonly #registry = inject(SkyChartLineRegistry); + readonly #seriesId = inject(SKY_CHART_LINE_SERIES_ID); + + /** + * The category bucket this data point belongs to (e.g. a month name or a label on the category axis). + */ + public readonly category = input.required(); + + /** + * The human-readable label shown in tooltips for this data point (e.g. "$10,000"). + */ + public readonly labelText = input.required(); + + /** + * The numeric value for this data point. + */ + public readonly value = input.required(); + + /** + * A unique ID for this data point component instance. + */ + readonly #id = nextId++; + + readonly #datapoint = computed(() => { + return { + id: this.#id, + category: this.category(), + labelText: this.labelText(), + value: this.value(), + }; + }); + + constructor() { + effect(() => { + const datapoint = this.#datapoint(); + this.#registry.upsertPoint(this.#seriesId, datapoint); + }); + } + + public ngOnDestroy(): void { + this.#registry.removePoint(this.#seriesId, this.#id); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-line/chart-line-series.component.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-series.component.ts new file mode 100644 index 0000000000..0ab06274b8 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-series.component.ts @@ -0,0 +1,63 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyChartLineRegistry } from './chart-line-registry.service'; +import { + SKY_CHART_LINE_SERIES_ID, + SkyChartLinePoint, +} from './chart-line-types'; + +let nextId = 0; + +/** + * Represents a named data series in a chart. + */ +@Component({ + selector: 'sky-chart-line-series', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: SKY_CHART_LINE_SERIES_ID, + useFactory: (): number => nextId++, + }, + ], +}) +export class SkyChartLineSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyChartLineRegistry); + readonly #id = inject(SKY_CHART_LINE_SERIES_ID); + + /** + * The display label for this series. Shown in the chart legend and tooltips. + */ + public readonly labelText = input.required(); + + /** + * A unique ID for this series component instance. + */ + readonly #series = computed>(() => ({ + id: this.#id, + labelText: this.labelText(), + data: [], // Data will be dynamically set from children datapoints + })); + + constructor() { + effect(() => { + const series = this.#series(); + this.#registry.upsertSeries(series); + }); + } + + public ngOnDestroy(): void { + this.#registry.removeSeries(this.#id); + } +} diff --git a/libs/components/charts/src/lib/modules/chart-line/chart-line-types.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-types.ts new file mode 100644 index 0000000000..282b8ddcfe --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-types.ts @@ -0,0 +1,25 @@ +import { InjectionToken } from '@angular/core'; + +import { SkyChartDataPoint } from '../shared/types/chart-data-point'; + +/** + * A line chart data point, which is a single numeric value representing the position of the point on the y-axis. + */ +export type SkyChartLineDatum = number; + +/** + * Injection token for providing the series ID to datapoint components. + * @internal + */ +export const SKY_CHART_LINE_SERIES_ID = new InjectionToken( + 'SKY_CHART_LINE_SERIES_ID', +); + +/** + * A single data point within a line chart series. + * @internal + */ +export interface SkyChartLinePoint extends SkyChartDataPoint { + /** Numeric value */ + value: SkyChartLineDatum; +} diff --git a/libs/components/charts/src/lib/modules/chart-line/chart-line.component.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line.component.ts new file mode 100644 index 0000000000..c263e7cf3f --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line.component.ts @@ -0,0 +1,252 @@ +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + computed, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; + +import { SKY_CHART_AXIS_REGISTRY } from '../axis/chart-axis-registry.service'; +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; +import { SkyChartService } from '../chart/chart.service'; +import { SkyChartJsDirective } from '../chartjs/chartjs.directive'; +import { buildChartSummary, getLegendItems } from '../shared/chart-helpers'; +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { + SkyChartLineConfigService, + SkyChartLineOptions, +} from './chart-line-config.service'; +import { SkyChartLineRegistry } from './chart-line-registry.service'; +import { SkyChartLineDatum, SkyChartLinePoint } from './chart-line-types'; + +/** + * Displays a line chart visualization. + */ +@Component({ + selector: 'sky-chart-line', + template: ` + @if (chartConfiguration(); as config) { +
+ +
+ } + `, + // ChartJS requires the Canvas to have a parent container that is dedicated to the element + // See: https://www.chartjs.org/docs/latest/configuration/responsive.html + styles: '.chart-container { position: relative; }', + imports: [SkyChartJsDirective], + providers: [ + SkyChartLineRegistry, + { provide: SKY_CHART_AXIS_REGISTRY, useExisting: SkyChartLineRegistry }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartLineComponent { + // #region Dependency Injection + readonly #chartService = inject(SkyChartService); + readonly #chartRegistry = inject(SkyChartLineRegistry); + readonly #chartConfigService = inject(SkyChartLineConfigService); + readonly #resources = inject(SkyLibResourcesService); + // #endregion + + // #region Inputs + public readonly dataPointsClickEnabled = input(false, { + transform: booleanAttribute, + }); + public readonly stacked = input(false, { transform: booleanAttribute }); + + /** + * A CSS height value (e.g. `'400px'`, `'20rem'`, `'50vh'`) for the chart. + * When unspecified, the chart uses internal sizing logic. + */ + public readonly height = input(); + // #endregion + + // #region Outputs + public readonly dataPointClick = + output>(); + // #endregion + + // #region View Children + protected readonly chartDirective = viewChild(SkyChartJsDirective); + // #endregion + + readonly #chart = computed(() => this.chartDirective()?.chart()); + readonly #chartUpdated = signal(0); + readonly #refreshLegendItems = signal(0); + + readonly #chartOptions = computed(() => { + const dataPointsClickEnabled = this.dataPointsClickEnabled(); + const stacked = this.stacked(); + + const categoryAxis = this.#chartRegistry.categoryAxis(); + const measureAxis = this.#chartRegistry.measureAxis(); + const series = this.#chartRegistry.series(); + + const options = this.#parseOptions({ + dataPointsClickEnabled: dataPointsClickEnabled, + stacked: stacked, + categoryAxis: categoryAxis, + measureAxis: measureAxis, + series: series, + }); + + return options; + }); + + /** The height of the chart */ + protected readonly chartHeight = computed(() => { + const explicitHeight = this.height(); + return explicitHeight ?? this.#chartConfigService.getChartHeight(); + }); + + protected readonly canvasAriaLabel = toSignal( + this.#resources.getString('chart.canvas.label.line'), + { initialValue: '' }, + ); + + protected readonly chartSummary = toSignal( + toObservable(this.#chartOptions).pipe( + switchMap((options) => (options ? this.#buildChartSummary(options) : '')), + ), + { initialValue: '' }, + ); + + protected readonly chartConfiguration = computed(() => { + const options = this.#chartOptions(); + + if (!options) { + return undefined; + } + + return this.#chartConfigService.buildConfig(options); + }); + + readonly #legendItems = computed(() => { + // Track chart, chart updates, series, and refresh triggers to update legend items + const chart = this.#chart(); + this.#chartUpdated(); // Recalculate on ChartJs updates since we rely on it to track visibility and color state + const series = this.#chartService.series(); + this.#refreshLegendItems(); + + return getLegendItems({ + chart: chart, + legendMode: 'series', + labels: series.map((s) => s.labelText), + }); + }); + + constructor() { + // Sync the generated chart summary to the chart service + effect(() => { + const summary = this.chartSummary(); + this.#chartService.generatedChartSummary.set(summary); + }); + + // Sync series data to the chart service + effect(() => { + const config = this.#chartOptions(); + this.#chartService.setSeries(config?.series ?? []); + }); + + // Sync legend items to the chart service + effect(() => { + const items = this.#legendItems(); + this.#chartService.setLegendItems(items); + }); + + // Handle legend toggle requests + effect(() => { + const item = this.#chartService.legendItemToggleRequested(); + if (item) { + this.#onLegendItemToggled(item); + } + }); + } + + /** Handle chart updates */ + protected onChartUpdated(): void { + this.#chartUpdated.update((v) => v + 1); + } + + // #region Private + #parseOptions(context: { + dataPointsClickEnabled: boolean; + stacked: boolean; + categoryAxis: Readonly | undefined; + measureAxis: Readonly | undefined; + series: SkyChartSeries[]; + }): SkyChartLineOptions { + const { + dataPointsClickEnabled, + stacked, + categoryAxis, + measureAxis, + series, + } = context; + + return { + stacked: stacked, + series: series, + categoryAxis: categoryAxis ? categoryAxis : undefined, + measureAxis: measureAxis ? measureAxis : undefined, + dataPointsClickEnabled: dataPointsClickEnabled, + callbacks: { + onDataPointClick: (dataPoint) => this.dataPointClick.emit(dataPoint), + }, + }; + } + + #onLegendItemToggled(item: SkyChartLegendItem): void { + const chart = this.#chart(); + + if (!chart) { + return; + } + + const isVisible = chart.isDatasetVisible(item.datasetIndex); + chart.setDatasetVisibility(item.datasetIndex, !isVisible); + chart.update(); + + // Refetch the legend items to reflect the updated visibility state + this.#refreshLegendItems.update((v) => v + 1); + } + + #buildChartSummary(options: SkyChartLineOptions): Observable { + const chartTypeDescription$ = this.#resources.getString( + 'chart.summary.line_chart', + options.series.length, + ); + + return buildChartSummary({ + headingText: this.#chartService.headingText(), + subtitleText: this.#chartService.subtitleText(), + chartTypeDescription$: chartTypeDescription$, + categoryAxis: options.categoryAxis, + measureAxis: options.measureAxis, + resources: this.#resources, + }); + } + // #endregion +} diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.html b/libs/components/charts/src/lib/modules/chart/chart.component.html new file mode 100644 index 0000000000..a106126f0a --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart.component.html @@ -0,0 +1,87 @@ +
+ @if (!headingHidden() || (subtitleText() && !subtitleHidden())) { +
+ + @if (!headingHidden()) { +
+ @switch (headingLevel()) { + @case (2) { + +

{{ headingText() }}

+ } + @case (4) { + +

{{ headingText() }}

+ } + @case (5) { + +
{{ headingText() }}
+ } + @default { + +

{{ headingText() }}

+ } + } + + @if (helpPopoverContent() || helpKey()) { + + + + } +
+ } + + + @if (subtitleText() && !subtitleHidden()) { +
+ {{ subtitleText() }} +
+ } +
+ } + + +
+ +
+ + + @if (showLegend()) { + + } + + +
+ + + + + + + +
+
diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.scss b/libs/components/charts/src/lib/modules/chart/chart.component.scss new file mode 100644 index 0000000000..c174d23ba6 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart.component.scss @@ -0,0 +1,49 @@ +@use 'libs/components/theme/src/lib/styles/compat-tokens-mixins' as compatMixins; + +@include compatMixins.sky-default-overrides('.sky-chart') { + // TODO: Add overrides here +} + +.sky-chart { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: var(--sky-space-gap-text_action-m, 12px); +} + +.sky-chart-header { + grid-column: 1; +} + +.sky-chart-heading { + h2, + h3, + h4, + h5 { + display: inline; + margin-block: 0; + } +} + +.sky-chart-subtitle { + grid-column: 1; + font-family: var(--sky-font-family-primary); + font-size: var(--sky-font-size-body-m); + font-weight: var(--sky-font-style-body-m); + line-height: var(--sky-font-line_height-body-m); + color: var(--sky-color-text-deemphasized); +} + +.sky-chart-content { + grid-column: 1 / -1; + margin: 0; +} + +sky-chart-legend { + grid-column: 1 / -1; +} + +.sky-chart-actions { + grid-column: 2; + grid-row: 1; + align-self: start; +} diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.ts b/libs/components/charts/src/lib/modules/chart/chart.component.ts new file mode 100644 index 0000000000..65a6bbf2e4 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart.component.ts @@ -0,0 +1,152 @@ +import { + ChangeDetectionStrategy, + Component, + TemplateRef, + booleanAttribute, + computed, + effect, + inject, + input, +} from '@angular/core'; +import { SkyHelpInlineModule } from '@skyux/help-inline'; +import { SkyModalService } from '@skyux/modals'; +import { SkyDropdownModule } from '@skyux/popovers'; + +import { SkyChartGridModalContext } from '../chart-data-grid-modal/chart-data-grid-modal-context'; +import { SkyChartDataGridModalComponent } from '../chart-data-grid-modal/chart-data-grid-modal.component'; +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; +import { SkyChartLegendComponent } from '../chart-legend/chart-legend.component'; +import { SkyChartsResourcesModule } from '../shared/sky-charts-resources.module'; +import { + DefaultHeadingLevel, + SkyChartHeadingLevel, + headingLevelInputTransformer, +} from '../shared/types/chart-heading-level'; +import { + DefaultHeadingStyle, + SkyChartHeadingStyle, + headingStyleInputTransformer, +} from '../shared/types/chart-heading-style'; + +import { SkyChartService } from './chart.service'; + +/** + * Wrapper component that provides heading and subtitle context to nested chart components. + * Use this as the outer container and place a chart type component (e.g. `sky-chart-bar`) inside it. + */ +@Component({ + selector: 'sky-chart', + templateUrl: 'chart.component.html', + styleUrl: 'chart.component.scss', + imports: [ + SkyChartsResourcesModule, + SkyDropdownModule, + SkyHelpInlineModule, + SkyChartLegendComponent, + ], + providers: [SkyChartService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartComponent { + // #region Dependency Injection + readonly #modalService = inject(SkyModalService); + readonly #chartService = inject(SkyChartService); + // #endregion + + // #region Inputs + + /** + * The text to display as the chart's heading. + */ + public readonly headingText = input.required(); + + /** + * Indicates whether to hide the `headingText`. + */ + public readonly headingHidden = input(false, { transform: booleanAttribute }); + + /** + * The semantic heading level in the document structure. The default is 3. + * @default 3 + */ + public readonly headingLevel = input( + DefaultHeadingLevel, + { transform: headingLevelInputTransformer }, + ); + + /** + * The heading [font style](https://developer.blackbaud.com/skyux/design/styles/typography#headings). + * @default 3 + */ + public readonly headingStyle = input( + DefaultHeadingStyle, + { transform: headingStyleInputTransformer }, + ); + + /** + * The text to display as the chart's subtitle. + */ + public readonly subtitleText = input(); + + /** + * Indicates whether to hide the `subtitleText`. + */ + public readonly subtitleHidden = input(false, { + transform: booleanAttribute, + }); + + /** + * The content of the help popover. When specified along with `headingText`, a [help inline](https://developer.blackbaud.com/skyux/components/help-inline) + * button is added to the chart heading. The help inline button displays a [popover](https://developer.blackbaud.com/skyux/components/popover) + * when clicked using the specified content and optional title. This property only applies when `headingText` is also specified. + */ + public helpPopoverContent = input< + string | TemplateRef | undefined + >(); + + /** + * The title of the help popover. This property only applies when `helpPopoverContent` is also specified. + */ + public helpPopoverTitle = input(); + + /** + * A help key that identifies the global help content to display. When specified along with `headingText`, a [help inline](https://developer.blackbaud.com/skyux/components/help-inline) + * button is placed beside the chart heading. Clicking the button invokes [global help](https://developer.blackbaud.com/skyux/learn/develop/global-help) + * as configured by the application. This property only applies when `headingText` is also specified. + */ + public helpKey = input(); + // #endregion + + protected readonly headingClass = computed( + () => `sky-font-heading-${this.headingStyle()}`, + ); + protected readonly generatedChartSummary = + this.#chartService.generatedChartSummary; + protected readonly legendItems = this.#chartService.legendItems; + protected readonly showLegend = computed(() => this.legendItems().length > 1); + + constructor() { + effect(() => this.#chartService.headingText.set(this.headingText())); + effect(() => + this.#chartService.subtitleText.set(this.subtitleText() ?? ''), + ); + } + + protected onLegendItemToggled(item: SkyChartLegendItem): void { + this.#chartService.toggleLegendItem(item); + } + + protected openChartDataGridModal(): void { + const modalContext = new SkyChartGridModalContext({ + modalTitle: this.headingText(), + series: this.#chartService.series(), + }); + + this.#modalService.open(SkyChartDataGridModalComponent, { + size: 'large', + providers: [ + { provide: SkyChartGridModalContext, useValue: modalContext }, + ], + }); + } +} diff --git a/libs/components/charts/src/lib/modules/chart/chart.service.ts b/libs/components/charts/src/lib/modules/chart/chart.service.ts new file mode 100644 index 0000000000..98037dac37 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart.service.ts @@ -0,0 +1,36 @@ +import { Injectable, signal } from '@angular/core'; + +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; +import { SkyChartDataPoint } from '../shared/types/chart-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +/** + * Service for sharing state between the chart component and nested chart type components. + */ +@Injectable() +export class SkyChartService { + public readonly headingText = signal(''); + + public readonly subtitleText = signal(''); + + public readonly generatedChartSummary = signal(''); + + public readonly series = signal[]>([]); + + public readonly legendItems = signal([]); + public readonly legendItemToggleRequested = signal< + SkyChartLegendItem | undefined + >(undefined); + + public setSeries(series: SkyChartSeries[]): void { + this.series.set(series); + } + + public setLegendItems(legendItems: SkyChartLegendItem[]): void { + this.legendItems.set(legendItems); + } + + public toggleLegendItem(item: SkyChartLegendItem): void { + this.legendItemToggleRequested.set(item); + } +} diff --git a/libs/components/charts/src/lib/modules/chartjs/chartjs.directive.spec.ts b/libs/components/charts/src/lib/modules/chartjs/chartjs.directive.spec.ts new file mode 100644 index 0000000000..f2e589cafd --- /dev/null +++ b/libs/components/charts/src/lib/modules/chartjs/chartjs.directive.spec.ts @@ -0,0 +1,232 @@ +import { + ChangeDetectionStrategy, + Component, + signal, + viewChild, +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Chart, ChartConfiguration } from 'chart.js'; + +import { SkyChartsResourcesModule } from '../shared/sky-charts-resources.module'; + +import { SkyChartJsDirective } from './chartjs.directive'; + +describe('SkyChartJsDirective', () => { + describe('initialization', () => { + it('should create a Chart instance after view init', () => { + const { component } = setupTest(); + + expect(component.directive().chart()).toBeInstanceOf(Chart); + }); + }); + + describe('host attributes', () => { + it('should set tabindex="0" on the canvas element', () => { + const { fixture } = setupTest(); + + expect(getCanvas(fixture).getAttribute('tabindex')).toBe('0'); + }); + + it('should set role="application" on the canvas element', () => { + const { fixture } = setupTest(); + + expect(getCanvas(fixture).getAttribute('role')).toBe('application'); + }); + + it('should set aria-label from the ariaLabel input', () => { + const { fixture } = setupTest(); + + expect(getCanvas(fixture).getAttribute('aria-label')).toBe('Test Chart'); + }); + + it('should update aria-label when the input changes', () => { + const { fixture, component } = setupTest(); + + component.ariaLabel.set('Updated Label'); + fixture.detectChanges(); + + expect(getCanvas(fixture).getAttribute('aria-label')).toBe( + 'Updated Label', + ); + }); + + it('should set aria-roledescription from resources', () => { + const { fixture } = setupTest(); + + expect(getCanvas(fixture).getAttribute('aria-roledescription')).toBe( + 'chart', + ); + }); + + it('should set the outline style to none', () => { + const { fixture } = setupTest(); + + expect(getCanvas(fixture).style.outline).toBe('none'); + }); + }); + + describe('chart configuration updates', () => { + it('should call chart.update() when chartConfiguration changes', () => { + const { fixture, component } = setupTest(); + const chart = getChartInstance(component); + const updateSpy = spyOn(chart, 'update').and.callThrough(); + + const newConfig = createBaseConfig(); + newConfig.data.datasets[0].data = [3, 4]; + component.chartConfiguration.set(newConfig); + fixture.detectChanges(); + + expect(updateSpy).toHaveBeenCalled(); + }); + + it('should update chart.data when configuration changes', () => { + const { fixture, component } = setupTest(); + const chart = getChartInstance(component); + const newData: ChartConfiguration['data'] = { + labels: ['X', 'Y'], + datasets: [{ data: [5, 6] }], + }; + + component.chartConfiguration.set({ + type: 'bar', + data: newData, + options: { responsive: false, animation: false }, + }); + fixture.detectChanges(); + + expect(chart.data).toEqual(newData); + }); + + it('should merge new options into chart.config.options', () => { + const { fixture, component } = setupTest(); + const chart = getChartInstance(component); + + component.chartConfiguration.set({ + ...createBaseConfig(), + options: { + responsive: false, + animation: false, + plugins: { legend: { display: false } }, + }, + }); + fixture.detectChanges(); + + expect(chart.config.options?.plugins?.legend?.display).toBeFalse(); + }); + + it('should emit chartUpdated after the chart is updated', () => { + const { fixture, component } = setupTest(); + const initialCount = component.chartUpdatedCount; + + component.chartConfiguration.set({ + ...createBaseConfig(), + data: { labels: ['C', 'D'], datasets: [{ data: [9, 10] }] }, + }); + fixture.detectChanges(); + + expect(component.chartUpdatedCount).toBe(initialCount + 1); + }); + }); + + describe('error handling', () => { + it('should throw if canvas context cannot be created', () => { + spyOn(HTMLCanvasElement.prototype, 'getContext').and.returnValue(null); + + TestBed.configureTestingModule({ + imports: [SkyChartTypeFixture, SkyChartsResourcesModule], + }); + + expect(() => TestBed.createComponent(SkyChartTypeFixture)).toThrowError( + 'Cannot create chart without a canvas', + ); + }); + }); + + describe('destruction', () => { + it('should call chart.destroy() when the directive is destroyed', () => { + const { fixture, component } = setupTest(); + const chart = getChartInstance(component); + const destroySpy = spyOn(chart, 'destroy').and.callThrough(); + + fixture.destroy(); + + expect(destroySpy).toHaveBeenCalled(); + }); + + it('should set the chart signal to undefined after destruction', () => { + const { fixture, component } = setupTest(); + const directive = component.directive(); + + fixture.destroy(); + + expect(directive.chart()).toBeUndefined(); + }); + }); +}); + +// #region Test Helpers +function setupTest(): { + fixture: ComponentFixture; + component: SkyChartTypeFixture; +} { + TestBed.configureTestingModule({ + imports: [SkyChartTypeFixture, SkyChartsResourcesModule], + }); + + const fixture = TestBed.createComponent(SkyChartTypeFixture); + const component = fixture.componentInstance; + fixture.detectChanges(); + + return { fixture, component }; +} + +@Component({ + selector: 'sky-chart-type-fixture', + template: ` + + `, + imports: [SkyChartJsDirective], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +class SkyChartTypeFixture { + public readonly chartConfiguration = + signal(createBaseConfig()); + public readonly ariaLabel = signal('Test Chart'); + public chartUpdatedCount = 0; + public readonly directive = viewChild.required(SkyChartJsDirective); + + public onChartUpdated(): void { + this.chartUpdatedCount++; + } +} + +function createBaseConfig(): ChartConfiguration { + return { + type: 'bar', + data: { + labels: ['A', 'B'], + datasets: [{ data: [1, 2], label: 'Dataset 1' }], + }, + }; +} + +function getCanvas( + fixture: ComponentFixture, +): HTMLCanvasElement { + return fixture.nativeElement.querySelector('canvas') as HTMLCanvasElement; +} + +function getChartInstance(component: SkyChartTypeFixture): Chart { + const chart = component.directive().chart(); + if (!chart) { + throw new Error('Chart instance is not defined'); + } + return chart; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/chartjs/chartjs.directive.ts b/libs/components/charts/src/lib/modules/chartjs/chartjs.directive.ts new file mode 100644 index 0000000000..0cd110f2fd --- /dev/null +++ b/libs/components/charts/src/lib/modules/chartjs/chartjs.directive.ts @@ -0,0 +1,159 @@ +import { + AfterViewInit, + Directive, + ElementRef, + NgZone, + OnDestroy, + effect, + inject, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { Chart, ChartConfiguration, registerables } from 'chart.js'; + +// Register Chart.js components globally +Chart.register(...registerables); + +/** + * Directive that renders a Chart.js chart on a canvas element and manages its lifecycle. + * @internal + */ +@Directive({ + selector: 'canvas[skyChartJs]', + host: { + tabindex: '0', + // role="application" tells screen readers to switch to Application Mode, + // which passes keyboard events through to the app instead + // of intercepting them for the screen reader's own virtual cursor navigation. + role: 'application', + '[attr.aria-roledescription]': 'roleDescription()', + '[attr.aria-label]': 'ariaLabel()', + // Remove the default focus outline from the canvas element since we'll be implementing our own focus styles for keyboard navigation. + '[style.outline]': '"none"', + }, +}) +export class SkyChartJsDirective implements OnDestroy, AfterViewInit { + // #region Dependency Injection + readonly #element: ElementRef = inject(ElementRef); + readonly #zone = inject(NgZone); + readonly #resources = inject(SkyLibResourcesService); + // #endregion + + // #region Inputs + /** + * The Chart.js configuration object that defines the chart's data and options. + */ + public readonly chartConfiguration = input.required(); + + /** + * The text used for the chart's ARIA label. + */ + public readonly ariaLabel = input.required(); + // #endregion + + // #region Outputs + /** + * An event emitted after the chart has been updated with new data or options. + * @remarks This allows parent components to react to chart updates, such as by performing additional calculations or triggering change detection. + */ + public readonly chartUpdated = output(); + // #endregion + + protected readonly roleDescription = toSignal( + this.#resources.getString('chart.canvas.role_description'), + { initialValue: 'chart' }, + ); + + readonly #canvasContext: CanvasRenderingContext2D; + + /** + * Signal containing the Chart.js Chart instance. + * This is undefined until the chart is created. + */ + public readonly chart = signal(undefined); + + constructor() { + this.#canvasContext = this.#getCanvasContext(); + + // Update the chart whenever the ChartJS configuration changes. This allows dynamic updates to the chart when inputs change. + effect(() => { + const newConfig = this.chartConfiguration(); + const chart = untracked(() => this.chart()); + + if (chart) { + // Update chart config options + if (chart?.config.options && newConfig.options) { + const newOptions = newConfig.options; + Object.assign(chart.config.options, newOptions); + } + + // Update chart data + chart.data = newConfig.data; + + this.#updateChart(); + } + }); + } + + /** + * @inheritdoc + */ + public ngOnDestroy(): void { + this.#destroyChart(); + } + + /** + * @inheritdoc + */ + public ngAfterViewInit(): void { + this.#renderChart(); + } + + #destroyChart(): void { + this.chart()?.destroy(); + this.chart.set(undefined); + } + + /** + * Creates the initial Chart.js chart instance. + * This is called once in `ngAfterViewInit` after the canvas element is ready. + */ + #renderChart(): void { + const config = this.chartConfiguration(); + + this.#zone.runOutsideAngular(() => { + this.chart.set(new Chart(this.#canvasContext, config)); + }); + } + + /** + * Updates the chart by calling its `update()` method. + * This should be called whenever the chart's data or options change to re-render the chart with the new configuration. + */ + #updateChart(): void { + if (this.chart()) { + this.#zone.runOutsideAngular(() => this.chart()?.update()); + this.chartUpdated.emit(); + } + } + + /** + * Gets the 2D rendering context for the canvas element. + * @throws Error if the canvas context cannot be created + */ + #getCanvasContext(): CanvasRenderingContext2D { + const canvasEle = this.#element.nativeElement; + const canvasContext = canvasEle.getContext('2d'); + + if (!canvasContext) { + throw new Error('Cannot create chart without a canvas'); + } + + return canvasContext; + } +} diff --git a/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts b/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts new file mode 100644 index 0000000000..6ca5044731 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts @@ -0,0 +1,551 @@ +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { Chart, LegendItem } from 'chart.js'; +import { of } from 'rxjs'; + +import { + buildChartSummary, + getChartType, + getDatasetType, + getLegendItems, + isDatasetType, + isDonutChart, + parseCategories, +} from './chart-helpers'; +import { SkyChartAxisConfig } from './types/axis-types'; +import { SkyChartDataPoint } from './types/chart-data-point'; +import { SkyChartSeries } from './types/chart-series'; + +describe('chart-helpers', () => { + describe('getDatasetType', () => { + it('should return the dataset type when explicitly set', () => { + const chart = createMockChart('bar'); + const dataset = { type: 'line' as const, data: [] }; + expect(getDatasetType(chart, dataset)).toBe('line'); + }); + + it('should fall back to the chart root type when dataset type is not set', () => { + const chart = createMockChart('bar'); + const dataset = { data: [] }; + expect(getDatasetType(chart, dataset)).toBe('bar'); + }); + }); + + describe('isDatasetType', () => { + it('should return true when dataset type matches', () => { + const chart = createMockChart('line'); + const dataset = { data: [] }; + expect(isDatasetType(chart, dataset, 'line')).toBeTrue(); + }); + + it('should return false when dataset type does not match', () => { + const chart = createMockChart('bar'); + const dataset = { data: [] }; + expect(isDatasetType(chart, dataset, 'line')).toBeFalse(); + }); + }); + + describe('getChartType', () => { + it('should return the chart type from a valid config', () => { + const chart = createMockChart('bar'); + expect(getChartType(chart)).toBe('bar'); + }); + + it('should throw when config has no type property', () => { + const chart = { config: {} } as Chart; + expect(() => getChartType(chart)).toThrowError('Unknown chart type'); + }); + + it('should throw when config type is not a string', () => { + const chart = { config: { type: 42 } } as unknown as Chart; + expect(() => getChartType(chart)).toThrowError('Unknown chart type'); + }); + }); + + describe('isDonutChart', () => { + it('should return true for doughnut charts', () => { + const chart = createMockChart('doughnut'); + expect(isDonutChart(chart)).toBeTrue(); + }); + + it('should return false for non-doughnut charts', () => { + const chart = createMockChart('bar'); + expect(isDonutChart(chart)).toBeFalse(); + }); + }); + + describe('parseCategories', () => { + it('should return unique categories from series', () => { + const series: SkyChartSeries[] = [ + { + id: 1, + labelText: 'Series 1', + data: [ + { id: 1, labelText: 'A', category: 'Q1' }, + { id: 2, labelText: 'B', category: 'Q2' }, + ], + }, + { + id: 2, + labelText: 'Series 2', + data: [ + { id: 3, labelText: 'C', category: 'Q1' }, + { id: 4, labelText: 'D', category: 'Q3' }, + ], + }, + ]; + + expect(parseCategories(series)).toEqual(['Q1', 'Q2', 'Q3']); + }); + + it('should return an empty array when series is empty', () => { + expect(parseCategories([])).toEqual([]); + }); + + it('should return an empty array when all series have no data', () => { + const series: SkyChartSeries[] = [ + { id: 1, labelText: 'Series 1', data: [] }, + ]; + expect(parseCategories(series)).toEqual([]); + }); + }); + + describe('getLegendItems', () => { + it('should return an empty array when chart is undefined', () => { + expect( + getLegendItems({ chart: undefined, legendMode: 'series', labels: [] }), + ).toEqual([]); + }); + + it('should return an empty array when generateLabels is not defined', () => { + const chart = { + ...createMockChart('bar'), + options: { plugins: { legend: { labels: {} } } }, + isDatasetVisible: () => true, + getDataVisibility: () => true, + } as unknown as Chart; + + const result = getLegendItems({ + chart, + legendMode: 'series', + labels: ['Series 1'], + }); + + expect(result).toEqual([]); + }); + + it('should map legend items in series mode using datasetIndex', () => { + const legendItems: LegendItem[] = [ + { text: '', datasetIndex: 0, index: 0, fillStyle: '#ff0000' }, + { text: '', datasetIndex: 1, index: 1, fillStyle: '#00ff00' }, + ]; + + const chart = { + ...createMockChart('bar'), + options: { + plugins: { + legend: { + labels: { generateLabels: () => legendItems }, + }, + }, + }, + isDatasetVisible: (i: number) => i === 0, + getDataVisibility: () => false, + } as unknown as Chart; + + const result = getLegendItems({ + chart, + legendMode: 'series', + labels: ['Series A', 'Series B'], + }); + + expect(result).toEqual([ + { + datasetIndex: 0, + index: 0, + isVisible: true, + labelText: 'Series A', + seriesColor: '#ff0000', + }, + { + datasetIndex: 1, + index: 1, + isVisible: false, + labelText: 'Series B', + seriesColor: '#00ff00', + }, + ]); + }); + + it('should map legend items in category mode using index', () => { + const legendItems: LegendItem[] = [ + { text: '', datasetIndex: 0, index: 0, fillStyle: '#aabbcc' }, + { text: '', datasetIndex: 0, index: 1, fillStyle: '#ddeeff' }, + ]; + + const chart = { + ...createMockChart('doughnut'), + options: { + plugins: { + legend: { + labels: { generateLabels: () => legendItems }, + }, + }, + }, + isDatasetVisible: () => true, + getDataVisibility: (i: number) => i === 1, + } as unknown as Chart; + + const result = getLegendItems({ + chart, + legendMode: 'category', + labels: ['Q1', 'Q2'], + }); + + expect(result).toEqual([ + { + datasetIndex: 0, + index: 0, + isVisible: false, + labelText: 'Q1', + seriesColor: '#aabbcc', + }, + { + datasetIndex: 0, + index: 1, + isVisible: true, + labelText: 'Q2', + seriesColor: '#ddeeff', + }, + ]); + }); + + it('should default fillStyle to "transparent" when not defined', () => { + const legendItems: LegendItem[] = [ + { text: '', datasetIndex: 0, index: 0 }, + ]; + + const chart = { + ...createMockChart('bar'), + options: { + plugins: { + legend: { + labels: { generateLabels: () => legendItems }, + }, + }, + }, + isDatasetVisible: () => true, + getDataVisibility: () => true, + } as unknown as Chart; + + const result = getLegendItems({ + chart, + legendMode: 'series', + labels: ['Series A'], + }); + + expect(result[0].seriesColor).toBe('transparent'); + }); + + it('should default datasetIndex and index to 0 when undefined on legendItem', () => { + const legendItems: LegendItem[] = [{ text: '' } as LegendItem]; + + const chart = { + ...createMockChart('bar'), + options: { + plugins: { + legend: { + labels: { generateLabels: () => legendItems }, + }, + }, + }, + isDatasetVisible: () => true, + getDataVisibility: () => true, + } as unknown as Chart; + + const result = getLegendItems({ + chart, + legendMode: 'series', + labels: ['Series A'], + }); + + expect(result[0].datasetIndex).toBe(0); + expect(result[0].index).toBe(0); + }); + }); + + describe('buildChartSummary', () => { + let mockResourcesService: jasmine.SpyObj; + + beforeEach(() => { + mockResourcesService = jasmine.createSpyObj('SkyLibResourcesService', [ + 'getString', + ]); + mockResourcesService.getString.and.callFake( + (key: string, ...args: unknown[]) => { + if (key === 'chart.summary.category_axis') { + return of(`Category axis: ${args[0]}`); + } + if (key === 'chart.summary.measure_axis') { + return of(`Measure axis: ${args[0]}`); + } + return of(key); + }, + ); + }); + + it('should build summary with all parameters', (done) => { + const categoryAxis: SkyChartAxisConfig = { + labelText: 'Quarter', + }; + const measureAxis: SkyChartAxisConfig = { + labelText: 'Revenue', + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: 'Sales Chart', + subtitleText: 'Quarterly Results', + chartTypeDescription$: of('Bar chart'), + categoryAxis, + measureAxis, + }).subscribe((result) => { + expect(result).toBe( + 'Sales Chart Bar chart Quarterly Results Category axis: Quarter Measure axis: Revenue', + ); + done(); + }); + }); + + it('should build summary without headingText', (done) => { + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: 'Quarterly Results', + chartTypeDescription$: of('Bar chart'), + }).subscribe((result) => { + expect(result).toBe('Bar chart Quarterly Results'); + done(); + }); + }); + + it('should build summary without subtitleText', (done) => { + buildChartSummary({ + resources: mockResourcesService, + headingText: 'Sales Chart', + subtitleText: undefined, + chartTypeDescription$: of('Bar chart'), + }).subscribe((result) => { + expect(result).toBe('Sales Chart Bar chart'); + done(); + }); + }); + + it('should build summary with only chart type description', (done) => { + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Donut chart'), + }).subscribe((result) => { + expect(result).toBe('Donut chart'); + done(); + }); + }); + + it('should build summary with categoryAxis with string labelText', (done) => { + const categoryAxis: SkyChartAxisConfig = { + labelText: 'Month', + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Line chart'), + categoryAxis, + }).subscribe((result) => { + expect(result).toBe('Line chart Category axis: Month'); + done(); + }); + }); + + it('should build summary with categoryAxis with array labelText', (done) => { + const categoryAxis: SkyChartAxisConfig = { + labelText: ['Quarter', 'Year'], + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Bar chart'), + categoryAxis, + }).subscribe((result) => { + expect(result).toBe('Bar chart Category axis: Quarter,Year'); + done(); + }); + }); + + it('should build summary with measureAxis with string labelText', (done) => { + const measureAxis: SkyChartAxisConfig = { + labelText: 'Sales', + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Bar chart'), + measureAxis, + }).subscribe((result) => { + expect(result).toBe('Bar chart Measure axis: Sales'); + done(); + }); + }); + + it('should build summary with measureAxis with array labelText', (done) => { + const measureAxis: SkyChartAxisConfig = { + labelText: ['Revenue', 'USD'], + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Line chart'), + measureAxis, + }).subscribe((result) => { + expect(result).toBe('Line chart Measure axis: Revenue,USD'); + done(); + }); + }); + + it('should build summary with both axes', (done) => { + const categoryAxis: SkyChartAxisConfig = { + labelText: 'Quarter', + }; + const measureAxis: SkyChartAxisConfig = { + labelText: 'Revenue', + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Bar chart'), + categoryAxis, + measureAxis, + }).subscribe((result) => { + expect(result).toBe( + 'Bar chart Category axis: Quarter Measure axis: Revenue', + ); + done(); + }); + }); + + it('should ignore categoryAxis without labelText', (done) => { + const categoryAxis: SkyChartAxisConfig = { + labelText: undefined, + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Bar chart'), + categoryAxis, + }).subscribe((result) => { + expect(result).toBe('Bar chart'); + done(); + }); + }); + + it('should ignore measureAxis without labelText', (done) => { + const measureAxis: SkyChartAxisConfig = { + labelText: undefined, + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Line chart'), + measureAxis, + }).subscribe((result) => { + expect(result).toBe('Line chart'); + done(); + }); + }); + + it('should ignore undefined categoryAxis', (done) => { + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Bar chart'), + categoryAxis: undefined, + }).subscribe((result) => { + expect(result).toBe('Bar chart'); + done(); + }); + }); + + it('should ignore undefined measureAxis', (done) => { + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Line chart'), + measureAxis: undefined, + }).subscribe((result) => { + expect(result).toBe('Line chart'); + done(); + }); + }); + + it('should handle empty string labelText in categoryAxis', (done) => { + const categoryAxis: SkyChartAxisConfig = { + labelText: '', + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Bar chart'), + categoryAxis, + }).subscribe((result) => { + expect(result).toBe('Bar chart'); + done(); + }); + }); + + it('should handle empty string labelText in measureAxis', (done) => { + const measureAxis: SkyChartAxisConfig = { + labelText: '', + }; + + buildChartSummary({ + resources: mockResourcesService, + headingText: undefined, + subtitleText: undefined, + chartTypeDescription$: of('Line chart'), + measureAxis, + }).subscribe((result) => { + expect(result).toBe('Line chart'); + done(); + }); + }); + }); +}); + +// #region Test Helpers +function createMockChart(type: string): Chart { + return { + config: { type }, + data: { datasets: [] }, + options: {}, + } as unknown as Chart; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/chart-helpers.ts b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts new file mode 100644 index 0000000000..12402bebca --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts @@ -0,0 +1,211 @@ +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { Chart, ChartConfiguration, ChartDataset, ChartType } from 'chart.js'; +import { Observable, combineLatest, of } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; + +import { SkyChartAxisConfig } from './types/axis-types'; +import { SkyCategory } from './types/category'; +import { SkyChartDataPoint } from './types/chart-data-point'; +import { SkyChartSeries } from './types/chart-series'; + +/** + * Determines the dataset type for the given dataset. + * @remarks This takes into account both the dataset's explicit type and the chart's root type. + * @param chart The ChartJS chart instance that the dataset belongs to + * @param dataset The dataset to determine the type of + * @returns The ChartJS Chart Type of the dataset + */ +export function getDatasetType(chart: Chart, dataset: ChartDataset): ChartType { + const datasetType = dataset.type; + + // If the dataset has an explicit type, use it + if (datasetType !== undefined) { + return datasetType; + } + + // Otherwise, use the root chart type + const chartType = getChartType(chart); + + return chartType; +} + +/** + * Type guard to check if the given dataset is of the specified type. + * @remarks This takes into account both the dataset's explicit type and the chart's root type. + * @param chart The ChartJS chart instance that the dataset belongs to + * @param dataset The dataset to check the type of + * @param type The chart type to check against + * @returns Type Guard asserting that the dataset is of the specified type + */ +export function isDatasetType( + chart: Chart, + dataset: ChartDataset, + type: T, +): dataset is ChartDataset & { type: T } { + return getDatasetType(chart, dataset) === type; +} + +/** + * Gets the chart type of the given chart + * @param chart + * @returns the ChartJS Chart Type + */ +export function getChartType(chart: Chart): ChartType { + if (isChartConfiguration(chart.config)) { + return chart.config.type; + } + + throw new Error('Unknown chart type'); +} + +/** + * Checks if the given chart is a Pie or Donut chart + */ +export function isDonutChart(chart: Chart): chart is Chart<'doughnut'> { + const chartType = getChartType(chart); + return chartType === 'doughnut'; +} + +/** + * Checks if the given configuration is a ChartConfiguration + * @param config + * @returns Type Guard asserting that the configuration is `ChartConfiguration` + */ +function isChartConfiguration( + config: Chart['config'], +): config is ChartConfiguration { + return 'type' in config && typeof config.type === 'string'; +} + +/** + * Parses categories from the given series data. + * @param series + */ +export function parseCategories( + series: readonly SkyChartSeries[], +): SkyCategory[] { + const allCategories = series.flatMap((s) => s.data.map((dp) => dp.category)); + const uniqueCategories = Array.from(new Set(allCategories)); + + return uniqueCategories; +} + +/** + * Gets legend items for the given chart + * + * @param context.chart The ChartJS chart instance + * @param context.legendMode The legend mode determines whether the legend items correspond to series or categories in the chart. + * @param context.labels The labels corresponding to the categories or series in the chart + * @returns An array of legend items + */ +export function getLegendItems(context: { + chart: Chart | undefined; + legendMode: 'series' | 'category'; + labels: readonly string[]; +}): SkyChartLegendItem[] { + const { chart, legendMode, labels } = context; + + if (!chart) { + return []; + } + + const chartJsLabels = chart.options.plugins?.legend?.labels; + const chartJsLegendItems = chartJsLabels?.generateLabels?.(chart) ?? []; + + return chartJsLegendItems.map((legendItem) => { + const datasetIndex = legendItem.datasetIndex ?? 0; + const dataIndex = legendItem.index ?? 0; + + const index = legendMode === 'series' ? datasetIndex : dataIndex; + const label = labels[index]; + + const isVisible = + legendMode === 'series' + ? chart.isDatasetVisible(datasetIndex) + : chart.getDataVisibility(dataIndex); + + const item: SkyChartLegendItem = { + datasetIndex: legendItem.datasetIndex ?? 0, + index: legendItem.index ?? 0, + isVisible: isVisible, + labelText: label, + seriesColor: String(legendItem.fillStyle ?? 'transparent'), + }; + + return item; + }); +} + +/** + * Returns the label string from an axis config's `labelText`, or `undefined` when no label is set. + */ +function getAxisLabelText( + config: Readonly | undefined, +): string | undefined { + const labelText = config?.labelText; + if (!labelText) { + return undefined; + } + return Array.isArray(labelText) ? labelText.join(',') : labelText; +} + +/** + * Builds a chart summary by combining heading, chart type description, subtitle, and optional axes. + * @param context.headingText Optional heading text for the chart + * @param context.subtitleText Optional subtitle text for the chart + * @param context.chartTypeDescription$ Observable of the chart type description + * @param context.categoryAxis Optional category axis configuration + * @param context.measureAxis Optional measure axis configuration + * @param context.resources The resources service for i18n strings + * @returns Observable of the assembled chart summary + * @internal + */ +export function buildChartSummary(context: { + resources: SkyLibResourcesService; + headingText: string | undefined; + subtitleText: string | undefined; + chartTypeDescription$: Observable; + categoryAxis?: Readonly; + measureAxis?: Readonly; +}): Observable { + const { + headingText, + subtitleText, + chartTypeDescription$, + categoryAxis, + measureAxis, + resources, + } = context; + + const observables: Observable[] = []; + + if (headingText) { + observables.push(of(headingText)); + } + + observables.push(chartTypeDescription$); + + if (subtitleText) { + observables.push(of(subtitleText)); + } + + const categoryAxisLabel = getAxisLabelText(categoryAxis); + if (categoryAxisLabel) { + observables.push( + resources.getString('chart.summary.category_axis', categoryAxisLabel), + ); + } + + const measureAxisLabel = getAxisLabelText(measureAxis); + if (measureAxisLabel) { + observables.push( + resources.getString('chart.summary.measure_axis', measureAxisLabel), + ); + } + + // Combine all sentence parts into a single string + return combineLatest(observables).pipe(map((values) => values.join(' '))); +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.spec.ts new file mode 100644 index 0000000000..0c145a689b --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.spec.ts @@ -0,0 +1,246 @@ +import { Chart, ChartDataset } from 'chart.js'; + +import { SkyChartStyleService } from '../../services/chart-style.service'; + +import { createAutoColorPlugin } from './auto-color-plugin'; + +describe('createAutoColorPlugin', () => { + describe('plugin identity', () => { + it('should have the correct plugin id', () => { + const styleService = createMockStyleService(['red', 'blue']); + const plugin = createAutoColorPlugin(styleService); + expect(plugin.id).toBe('sky_auto_color'); + }); + }); + + describe('dataset mode (non-donut charts)', () => { + it('should assign a unique color from the palette to each dataset', () => { + const colors = ['#color1', '#color2', '#color3']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const datasets: ChartDataset[] = [ + { data: [1, 2, 3] }, + { data: [4, 5, 6] }, + { data: [7, 8, 9] }, + ]; + const chart = createMockChart('bar', datasets); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(datasets[0].backgroundColor).toBe('#color1'); + expect(datasets[1].backgroundColor).toBe('#color2'); + expect(datasets[2].backgroundColor).toBe('#color3'); + }); + + it('should wrap colors when there are more datasets than palette colors', () => { + const colors = ['#color1', '#color2']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const datasets: ChartDataset[] = [ + { data: [1] }, + { data: [2] }, + { data: [3] }, + ]; + const chart = createMockChart('bar', datasets); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(datasets[0].backgroundColor).toBe('#color1'); + expect(datasets[1].backgroundColor).toBe('#color2'); + expect(datasets[2].backgroundColor).toBe('#color1'); + }); + + it('should set hoverBackgroundColor to the same color as backgroundColor', () => { + const colors = ['#color1', '#color2']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const datasets: ChartDataset[] = [{ data: [1] }, { data: [2] }]; + const chart = createMockChart('bar', datasets); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(datasets[0].hoverBackgroundColor).toBe('#color1'); + expect(datasets[1].hoverBackgroundColor).toBe('#color2'); + }); + + it('should not set borderColor or pointBackgroundColor for bar charts', () => { + const colors = ['#color1']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const dataset: ChartDataset = { data: [1, 2, 3] }; + const chart = createMockChart('bar', [dataset]); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(dataset.borderColor).toBeUndefined(); + expect( + (dataset as ChartDataset<'line'>).pointBackgroundColor, + ).toBeUndefined(); + }); + + it('should set borderColor and pointBackgroundColor for line chart datasets', () => { + const colors = ['#color1', '#color2']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const datasets: ChartDataset<'line'>[] = [ + { type: 'line', data: [1, 2] }, + { type: 'line', data: [3, 4] }, + ]; + const chart = createMockChart('line', datasets); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(datasets[0].borderColor).toBe('#color1'); + expect(datasets[0].pointBackgroundColor).toBe('#color1'); + expect(datasets[1].borderColor).toBe('#color2'); + expect(datasets[1].pointBackgroundColor).toBe('#color2'); + }); + + it('should set line-specific colors when the root chart type is line', () => { + const colors = ['#color1']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + // Dataset without explicit type — inherits chart type (line) + const dataset: ChartDataset = { data: [1, 2, 3] }; + const chart = createMockChart('line', [dataset]); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(dataset.borderColor).toBe('#color1'); + expect((dataset as ChartDataset<'line'>).pointBackgroundColor).toBe( + '#color1', + ); + }); + + it('should handle an empty datasets array without errors', () => { + const styleService = createMockStyleService(['#color1']); + const plugin = createAutoColorPlugin(styleService); + + const chart = createMockChart('bar', []); + + expect(() => + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}), + ).not.toThrow(); + }); + }); + + describe('data mode (donut charts)', () => { + it('should assign a unique color to each data point', () => { + const colors = ['#color1', '#color2', '#color3']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const dataset: ChartDataset = { data: [10, 20, 30] }; + const chart = createMockChart('doughnut', [dataset]); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(dataset.backgroundColor).toEqual([ + '#color1', + '#color2', + '#color3', + ]); + }); + + it('should wrap colors when data points exceed palette length', () => { + const colors = ['#color1', '#color2']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const dataset: ChartDataset = { data: [10, 20, 30, 40] }; + const chart = createMockChart('doughnut', [dataset]); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(dataset.backgroundColor).toEqual([ + '#color1', + '#color2', + '#color1', + '#color2', + ]); + }); + + it('should set hoverBackgroundColor to the same array as backgroundColor', () => { + const colors = ['#color1', '#color2']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const dataset: ChartDataset = { data: [10, 20] }; + const chart = createMockChart('doughnut', [dataset]); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(dataset.hoverBackgroundColor).toEqual(dataset.backgroundColor); + }); + + it('should apply data mode colors to multiple datasets', () => { + const colors = ['#color1', '#color2']; + const styleService = createMockStyleService(colors); + const plugin = createAutoColorPlugin(styleService); + + const datasets: ChartDataset[] = [{ data: [10, 20] }, { data: [30, 40] }]; + const chart = createMockChart('doughnut', datasets); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(datasets[0].backgroundColor).toEqual(['#color1', '#color2']); + expect(datasets[1].backgroundColor).toEqual(['#color1', '#color2']); + }); + + it('should handle a dataset with no data points', () => { + const styleService = createMockStyleService(['#color1']); + const plugin = createAutoColorPlugin(styleService); + + const dataset: ChartDataset = { data: [] }; + const chart = createMockChart('doughnut', [dataset]); + + expect(() => + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}), + ).not.toThrow(); + expect(dataset.backgroundColor).toEqual([]); + }); + }); + + describe('color freshness', () => { + it('should read styles from the service on every beforeUpdate call', () => { + let callCount = 0; + const serviceWithSpy = { + styles: jasmine.createSpy('styles').and.callFake(() => { + callCount++; + return { palettes: { categorical: [`#call${callCount}`] } }; + }), + } as unknown as SkyChartStyleService; + + const plugin = createAutoColorPlugin(serviceWithSpy); + const chart = createMockChart('bar', [{ data: [1] }]); + + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + plugin.beforeUpdate?.(chart, beforeUpdateArgs, {}); + + expect(serviceWithSpy.styles).toHaveBeenCalledTimes(2); + }); + }); +}); + +// #region Test Helpers +const beforeUpdateArgs = { mode: 'active', cancelable: true } as const; + +function createMockChart(type: string, datasets: ChartDataset[]): Chart { + return { + config: { type }, + data: { datasets }, + } as Chart; +} + +function createMockStyleService(colors: string[]): SkyChartStyleService { + return { + styles: () => ({ palettes: { categorical: colors } }), + } as unknown as SkyChartStyleService; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.ts b/libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.ts new file mode 100644 index 0000000000..ce3f00e468 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.ts @@ -0,0 +1,87 @@ +import { Chart, ChartDataset, ChartType, Plugin } from 'chart.js'; + +import { isDatasetType, isDonutChart } from '../../chart-helpers'; +import { SkyChartStyleService } from '../../services/chart-style.service'; + +/** + * Creates a ChartJS plugin that automatically applies SKY UX color palette to chart datasets. + * + * @returns Plugin that auto-colors datasets based on the specified mode + */ +export function createAutoColorPlugin( + styleService: SkyChartStyleService, +): Plugin { + const plugin: Plugin = { + id: 'sky_auto_color', + beforeUpdate(chart): boolean | void { + // Get styles at runtime to ensure colors are up to date with current theme + const styles = styleService.styles(); + + // Today only the Categorical Palette is supported but this can be extended in the future to support different palettes + const colors = styles.palettes.categorical; + + // For donut charts, always apply colors in 'data' mode + if (isDonutChart(chart)) { + applyDataMode(chart, colors); + } else { + applyDatasetMode(chart, colors); + } + }, + }; + + return plugin; +} + +/** + * Applies colors in 'dataset' mode - each `dataset` (series) gets a unique color. + * @param chart The chart instance + * @param colors The color palette to apply to the datasets + */ +function applyDatasetMode(chart: Chart, colors: string[]): void { + const datasets = chart.data.datasets; + + datasets.forEach((dataset, datasetIndex) => { + const color = colors[datasetIndex % colors.length]; + setDatasetColors(chart, dataset, color); + }); +} + +/** + * Applies colors in 'data' mode - each `dataset.data` (series datapoint) gets a unique color. + * @param chart The chart instance + * @param colors The color palette to apply to the datasets + */ +function applyDataMode(chart: Chart, colors: string[]): void { + const datasets = chart.data.datasets; + + datasets.forEach((dataset) => { + const backgroundColors: string[] = []; + + for (let i = 0; i < dataset.data.length; i++) { + const color = colors[i % colors.length]; + backgroundColors.push(color); + } + + setDatasetColors(chart, dataset, backgroundColors); + }); +} + +/** + * Sets the colors on the given dataset based on the chart type. + * @param chart The chart instance + * @param dataset The dataset to update + * @param backgroundColor The background color(s) to apply to the dataset + */ +function setDatasetColors( + chart: Chart, + dataset: ChartDataset, + backgroundColor: string | string[], +): void { + dataset.backgroundColor = backgroundColor; + dataset.hoverBackgroundColor = backgroundColor; + + if (isDatasetType(chart, dataset, 'line')) { + dataset.borderColor = backgroundColor; + dataset.pointBackgroundColor = backgroundColor; + } +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.spec.ts new file mode 100644 index 0000000000..03409f7173 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.spec.ts @@ -0,0 +1,276 @@ +import { ActiveElement, BarElement, ChartArea } from 'chart.js'; + +import { getBarIndicatorBounds } from './bar-indicator-bounds'; +import type { IndicatorStyles } from './indicator-types'; + +describe('getBarIndicatorBounds', () => { + describe('vertical bar', () => { + it('should calculate correct bounds for a vertical bar', () => { + const element = createMockBarElement({ + x: 100, + y: 200, + width: 20, + height: 50, + base: 250, + horizontal: false, + }); + + const result = getBarIndicatorBounds( + defaultChartArea, + [element], + defaultStyles, + ); + + // x = 100 - 20/2 - 4 = 86 + // y = 200 - 4 = 196 + // width = 20 + 4*2 = 28 + // height = 50 + 4*2 = 58 (no clamping since 254 < bottom=300) + expect(result).toEqual({ x: 86, y: 196, width: 28, height: 58 }); + }); + + it('should clamp the bottom edge to the chart area bottom', () => { + const element = createMockBarElement({ + x: 100, + y: 240, + width: 20, + height: 80, + base: 300, + horizontal: false, + }); + + // y = 240 - 4 = 236 + // y + height = 236 + 88 = 324 > chartArea.bottom (300) + // clampedBottom = 300 → effective height = 300 - 236 = 64 + const result = getBarIndicatorBounds( + defaultChartArea, + [element], + defaultStyles, + ); + + expect(result.y).toBe(236); + expect(result.y + result.height).toBe(300); + }); + + it('should not clamp the top edge above the chart area', () => { + const element = createMockBarElement({ + x: 100, + y: 12, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + // y = 12 - 4 = 8 (below chart top=10) — vertical bars do not clamp the top + const result = getBarIndicatorBounds( + defaultChartArea, + [element], + defaultStyles, + ); + + expect(result.y).toBe(8); + }); + + it('should apply padding to the bounds', () => { + const styles: IndicatorStyles = { ...defaultStyles, padding: 10 }; + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 150, + horizontal: false, + }); + + // x = 100 - 10 - 10 = 80 + // y = 100 - 10 = 90 + // width = 20 + 10*2 = 40 + // height = 50 + 10*2 = 70 + const result = getBarIndicatorBounds(defaultChartArea, [element], styles); + + expect(result).toEqual({ x: 80, y: 90, width: 40, height: 70 }); + }); + }); + + describe('horizontal bar', () => { + it('should calculate correct bounds for a horizontal bar', () => { + const element = createMockBarElement({ + x: 200, + y: 150, + width: 100, + height: 15, + base: 100, + horizontal: true, + }); + + // x = 200 - 100 - 4 = 96 + // y = 150 - 15/2 - 4 = 138.5 + // width = 100 + 4*2 = 108 + // height = 15 + 4*2 = 23 + // clampedLeft = Math.max(96, 50) = 96 (no clamping) + const result = getBarIndicatorBounds( + defaultChartArea, + [element], + defaultStyles, + ); + + expect(result).toEqual({ x: 96, y: 138.5, width: 108, height: 23 }); + }); + + it('should clamp the left edge to the chart area left', () => { + const element = createMockBarElement({ + x: 60, + y: 150, + width: 20, + height: 15, + base: 50, + horizontal: true, + }); + + // x = 60 - 20 - 4 = 36 < chartArea.left (50) + // clampedLeft = 50 + // clampedRight = 36 + 28 = 64 + // width = 64 - 50 = 14 + const result = getBarIndicatorBounds( + defaultChartArea, + [element], + defaultStyles, + ); + + expect(result.x).toBe(50); + expect(result.width).toBe(14); + }); + + it('should not clamp the right edge beyond the chart area', () => { + const element = createMockBarElement({ + x: 390, + y: 150, + width: 100, + height: 15, + base: 50, + horizontal: true, + }); + + // x = 390 - 100 - 4 = 286; right = 286 + 108 = 394 — not clamped for horizontal + const result = getBarIndicatorBounds( + defaultChartArea, + [element], + defaultStyles, + ); + + expect(result.x + result.width).toBe(394); + }); + + it('should apply padding to the bounds', () => { + const styles: IndicatorStyles = { ...defaultStyles, padding: 6 }; + const element = createMockBarElement({ + x: 200, + y: 150, + width: 100, + height: 20, + base: 100, + horizontal: true, + }); + + // x = 200 - 100 - 6 = 94 + // y = 150 - 10 - 6 = 134 + // width = 100 + 6*2 = 112 + // height = 20 + 6*2 = 32 + const result = getBarIndicatorBounds(defaultChartArea, [element], styles); + + expect(result).toEqual({ x: 94, y: 134, width: 112, height: 32 }); + }); + }); + + it('should fall back to 0 when x and y props are undefined', () => { + const element: ActiveElement = { + element: { + getProps: () => ({ + x: undefined, + y: undefined, + width: 20, + height: 50, + base: 300, + horizontal: false, + }), + } as unknown as BarElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; + + // x = 0 - 10 - 4 = -14; y = 0 - 4 = -4 + const result = getBarIndicatorBounds( + defaultChartArea, + [element], + defaultStyles, + ); + + expect(result.x).toBe(-14); + expect(result.y).toBe(-4); + }); + + it('should use only the first active element', () => { + const element1 = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + const element2 = createMockBarElement({ + x: 200, + y: 100, + width: 20, + height: 80, + base: 300, + horizontal: false, + }); + + // Expected bounds based on element1 only + const result = getBarIndicatorBounds( + defaultChartArea, + [element1, element2], + defaultStyles, + ); + + // x = 100 - 10 - 4 = 86 + expect(result.x).toBe(86); + }); +}); + +// #region Test Helpers +const defaultStyles: IndicatorStyles = { + padding: 4, + borderRadius: 3, + borderColor: '#000', + borderWidth: 1, + backgroundColor: '#fff', +}; + +const defaultChartArea: ChartArea = { + left: 50, + top: 10, + right: 400, + bottom: 300, + width: 350, + height: 290, +}; + +function createMockBarElement(props: { + x: number; + y: number; + width: number; + height: number; + base: number; + horizontal: boolean; +}): ActiveElement { + return { + element: { + getProps: () => props, + } as unknown as BarElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.ts new file mode 100644 index 0000000000..66c98de847 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.ts @@ -0,0 +1,105 @@ +import { ActiveElement, BarElement, ChartArea } from 'chart.js'; + +import { IndicatorBounds, IndicatorStyles } from './indicator-types'; + +/** + * Returns the geometry of an indicator box for the given active bar elements. + * @param chartArea the chart area to clamp the indicator bounds within + * @param activeElements the active elements to get bounds for. + * @param styles the styles to apply to the indicator + */ +export function getBarIndicatorBounds( + chartArea: ChartArea, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): IndicatorBounds { + const bars = activeElements.map((el) => getGeometry(el)); + return getBounds(bars[0], chartArea, styles); +} + +function getGeometry(activeElement: ActiveElement): BarGeometry { + const bar = activeElement.element as BarElement; + const props = bar.getProps( + ['x', 'y', 'width', 'height', 'base', 'horizontal'], + true, + ); + + return { + x: props.x ?? 0, + y: props.y ?? 0, + width: props.width, + height: props.height, + base: props.base, + horizontal: props.horizontal, + }; +} + +function getBounds( + bar: BarGeometry, + chartArea: ChartArea, + styles: IndicatorStyles, +): IndicatorBounds { + let x: number, y: number, width: number, height: number; + + if (bar.horizontal) { + // x = right edge, y = center, width = bar length, height = bar thickness + x = bar.x - bar.width - styles.padding; + y = bar.y - bar.height / 2 - styles.padding; + width = bar.width + styles.padding * 2; + height = bar.height + styles.padding * 2; + } else { + // x = center, y = top edge, width = bar thickness, height = bar length + x = bar.x - bar.width / 2 - styles.padding; + y = bar.y - styles.padding; + width = bar.width + styles.padding * 2; + height = bar.height + styles.padding * 2; + } + + // Clamp only the axis-end so the indicator doesn't overflow into axes/scales, + // while allowing the non-axis end to extend beyond the chart area. + let clampedLeft: number; + let clampedTop: number; + let clampedRight: number; + let clampedBottom: number; + + if (bar.horizontal) { + // Axis is on the left → clamp left edge only. + clampedLeft = Math.max(x, chartArea.left); + clampedTop = y; + clampedRight = x + width; + clampedBottom = y + height; + } else { + // Axis is on the bottom → clamp bottom edge only. + clampedLeft = x; + clampedTop = y; + clampedRight = x + width; + clampedBottom = Math.min(y + height, chartArea.bottom); + } + + return { + x: clampedLeft, + y: clampedTop, + width: clampedRight - clampedLeft, + height: clampedBottom - clampedTop, + }; +} + +/** The geometry of a bar element */ +interface BarGeometry { + /** The x center */ + x: number; + /** The y center */ + y: number; + /** The width of the bar */ + width: number; + /** The height of the bar */ + height: number; + /** + * The coordinate of the bar's base (the end attached to the axis). + * For vertical bars, this is the y coordinate of the bottom edge; + * For horizontal bars, this is the x coordinate of the left edge. + */ + base: number; + /** Is the bar horizontal */ + horizontal: boolean; +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-bounds.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-bounds.spec.ts new file mode 100644 index 0000000000..64bca4c3ec --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-bounds.spec.ts @@ -0,0 +1,137 @@ +import { ActiveElement, ArcElement } from 'chart.js'; + +import { getDonutIndicatorBounds } from './donut-indicator-bounds'; + +describe('getDonutIndicatorBounds', () => { + it('should return the center coordinates from the arc element', () => { + const element = createMockArcElement({ + x: 200, + y: 150, + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + }); + + const result = getDonutIndicatorBounds([element]); + + expect(result.x).toBe(200); + expect(result.y).toBe(150); + }); + + it('should return the start and end angles from the arc element', () => { + const startAngle = Math.PI / 4; + const endAngle = Math.PI * 1.5; + const element = createMockArcElement({ + x: 200, + y: 150, + startAngle, + endAngle, + innerRadius: 50, + outerRadius: 100, + }); + + const result = getDonutIndicatorBounds([element]); + + expect(result.startAngle).toBe(startAngle); + expect(result.endAngle).toBe(endAngle); + }); + + it('should expand the outer radius by Math.PI', () => { + const outerRadius = 100; + const element = createMockArcElement({ + x: 200, + y: 150, + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius, + }); + + const result = getDonutIndicatorBounds([element]); + + expect(result.outerRadius).toBe(outerRadius + Math.PI); + }); + + it('should use only the first active element', () => { + const element1 = createMockArcElement({ + x: 100, + y: 75, + startAngle: 0, + endAngle: Math.PI, + innerRadius: 30, + outerRadius: 60, + }); + const element2 = createMockArcElement({ + x: 200, + y: 150, + startAngle: Math.PI, + endAngle: Math.PI * 2, + innerRadius: 30, + outerRadius: 60, + }); + + const result = getDonutIndicatorBounds([element1, element2]); + + expect(result.x).toBe(100); + expect(result.y).toBe(75); + }); + + it('should fall back to 0 when x and y props are undefined', () => { + const element: ActiveElement = { + element: { + getProps: () => ({ + x: undefined, + y: undefined, + startAngle: 0, + endAngle: Math.PI, + innerRadius: 30, + outerRadius: 60, + }), + } as unknown as ArcElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; + + const result = getDonutIndicatorBounds([element]); + + expect(result.x).toBe(0); + expect(result.y).toBe(0); + }); + + it('should handle a slice covering the full circle', () => { + const element = createMockArcElement({ + x: 150, + y: 150, + startAngle: 0, + endAngle: Math.PI * 2, + innerRadius: 40, + outerRadius: 80, + }); + + const result = getDonutIndicatorBounds([element]); + + expect(result.startAngle).toBe(0); + expect(result.endAngle).toBe(Math.PI * 2); + expect(result.outerRadius).toBeCloseTo(80 + Math.PI, 5); + }); +}); + +// #region Test Helpers +function createMockArcElement(props: { + x: number; + y: number; + startAngle: number; + endAngle: number; + innerRadius: number; + outerRadius: number; +}): ActiveElement { + return { + element: { + getProps: () => props, + } as unknown as ArcElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-bounds.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-bounds.ts new file mode 100644 index 0000000000..81fff3edf1 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-bounds.ts @@ -0,0 +1,57 @@ +import type { ActiveElement, ArcElement } from 'chart.js'; + +import type { ArcIndicatorBounds } from './indicator-types'; + +/** + * Returns the geometry of an indicator for the given active donut slice element. + * @param activeElements the active elements to get bounds for. + */ +export function getDonutIndicatorBounds( + activeElements: ActiveElement[], +): ArcIndicatorBounds { + const geometry = getArcGeometry(activeElements[0]); + return getBounds(geometry); +} + +function getArcGeometry(activeElement: ActiveElement): ArcGeometry { + const arc = activeElement.element as ArcElement; + const props = arc.getProps( + ['x', 'y', 'startAngle', 'endAngle', 'innerRadius', 'outerRadius'], + true, + ); + + return { + x: props.x ?? 0, + y: props.y ?? 0, + startAngle: props.startAngle, + endAngle: props.endAngle, + innerRadius: props.innerRadius, + outerRadius: props.outerRadius, + }; +} + +function getBounds(slice: ArcGeometry): ArcIndicatorBounds { + return { + x: slice.x, + y: slice.y, + startAngle: slice.startAngle, + endAngle: slice.endAngle, + outerRadius: slice.outerRadius + Math.PI, + }; +} + +/** Geometric properties of a donut chart. */ +interface ArcGeometry { + /** The x-coordinate of the arc's center point. */ + x: number; + /** The y-coordinate of the arc's center point. */ + y: number; + /** The angle (in radians) where the slice begins. */ + startAngle: number; + /** The angle (in radians) where the slice ends. */ + endAngle: number; + /** The radius of the inner edge of the donut slice. */ + innerRadius: number; + /** The radius of the outer edge of the donut slice. */ + outerRadius: number; +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.spec.ts new file mode 100644 index 0000000000..1de318e82e --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.spec.ts @@ -0,0 +1,499 @@ +import { + ActiveElement, + ArcElement, + BarElement, + Chart, + PointElement, +} from 'chart.js'; + +import { drawIndicatorFill, drawIndicatorStroke } from './indicator-draw'; +import type { IndicatorStyles } from './indicator-types'; + +describe('drawIndicatorFill', () => { + it('should not draw when there are no active elements', () => { + const { chart, ctx } = createMockBarChart(); + + drawIndicatorFill(chart, [], defaultStyles); + + expect(ctx.save).not.toHaveBeenCalled(); + expect(ctx.fill).not.toHaveBeenCalled(); + }); + + it('should save and restore the canvas context', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.save).toHaveBeenCalledTimes(1); + expect(ctx.restore).toHaveBeenCalledTimes(1); + }); + + it('should set fillStyle to the styles backgroundColor', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.fillStyle).toBe(defaultStyles.backgroundColor); + }); + + it('should call fill to render the background', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.fill).toHaveBeenCalledTimes(1); + }); + + describe('bar chart path', () => { + it('should call roundRect with the bar bounds', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.roundRect).toHaveBeenCalled(); + }); + + it('should round only the top corners for vertical bars', () => { + const { chart, ctx } = createMockBarChart(false); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + const radii = ctx.roundRect.calls.mostRecent().args[4]; + expect(radii).toEqual([ + defaultStyles.borderRadius, + defaultStyles.borderRadius, + 0, + 0, + ]); + }); + + it('should round only the right corners for horizontal bars', () => { + const { chart, ctx } = createMockBarChart(true); + const element = createMockBarElement({ + x: 200, + y: 150, + width: 100, + height: 15, + base: 50, + horizontal: true, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + const radii = ctx.roundRect.calls.mostRecent().args[4]; + expect(radii).toEqual([ + 0, + defaultStyles.borderRadius, + defaultStyles.borderRadius, + 0, + ]); + }); + }); + + describe('line chart path', () => { + it('should call roundRect for line chart elements', () => { + const { chart, ctx } = createMockLineChart(); + const element = createMockPointElement({ x: 100, y: 100 }, 5); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.roundRect).toHaveBeenCalled(); + }); + + it('should round all corners for line chart elements', () => { + const { chart, ctx } = createMockLineChart(); + const element = createMockPointElement({ x: 100, y: 100 }, 5); + + drawIndicatorFill(chart, [element], defaultStyles); + + const radii = ctx.roundRect.calls.mostRecent().args[4]; + expect(radii).toEqual([ + defaultStyles.borderRadius, + defaultStyles.borderRadius, + defaultStyles.borderRadius, + defaultStyles.borderRadius, + ]); + }); + }); + + describe('doughnut chart path', () => { + it('should call arc for doughnut chart elements', () => { + const { chart, ctx } = createMockDoughnutChart(); + const element = createMockArcElement({ + x: 200, + y: 150, + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.arc).toHaveBeenCalled(); + }); + + it('should call beginPath and closePath for doughnut chart elements', () => { + const { chart, ctx } = createMockDoughnutChart(); + const element = createMockArcElement({ + x: 200, + y: 150, + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.beginPath).toHaveBeenCalled(); + expect(ctx.closePath).toHaveBeenCalled(); + }); + + it('should draw two arcs for the donut slice indicator', () => { + const { chart, ctx } = createMockDoughnutChart(); + const element = createMockArcElement({ + x: 200, + y: 150, + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.arc).toHaveBeenCalledTimes(2); + }); + + it('should set lineWidth to borderWidth + Math.PI for doughnut arcs', () => { + const { chart, ctx } = createMockDoughnutChart(); + const element = createMockArcElement({ + x: 200, + y: 150, + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + }); + + drawIndicatorFill(chart, [element], defaultStyles); + + expect(ctx.lineWidth).toBe(defaultStyles.borderWidth + Math.PI); + }); + }); +}); + +describe('drawIndicatorStroke', () => { + it('should not draw when there are no active elements', () => { + const { chart, ctx } = createMockBarChart(); + + drawIndicatorStroke(chart, [], defaultStyles); + + expect(ctx.save).not.toHaveBeenCalled(); + expect(ctx.stroke).not.toHaveBeenCalled(); + }); + + it('should save and restore the canvas context', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorStroke(chart, [element], defaultStyles); + + expect(ctx.save).toHaveBeenCalledTimes(1); + expect(ctx.restore).toHaveBeenCalledTimes(1); + }); + + it('should set strokeStyle to the styles borderColor', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorStroke(chart, [element], defaultStyles); + + expect(ctx.strokeStyle).toBe(defaultStyles.borderColor); + }); + + it('should set lineWidth to the styles borderWidth', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorStroke(chart, [element], defaultStyles); + + expect(ctx.lineWidth).toBe(defaultStyles.borderWidth); + }); + + it('should call stroke to render the border', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorStroke(chart, [element], defaultStyles); + + expect(ctx.stroke).toHaveBeenCalledTimes(1); + }); + + it('should call roundRect for bar charts', () => { + const { chart, ctx } = createMockBarChart(); + const element = createMockBarElement({ + x: 100, + y: 100, + width: 20, + height: 50, + base: 300, + horizontal: false, + }); + + drawIndicatorStroke(chart, [element], defaultStyles); + + expect(ctx.roundRect).toHaveBeenCalled(); + }); + + it('should call roundRect for line charts', () => { + const { chart, ctx } = createMockLineChart(); + const element = createMockPointElement({ x: 100, y: 100 }, 5); + + drawIndicatorStroke(chart, [element], defaultStyles); + + expect(ctx.roundRect).toHaveBeenCalled(); + }); + + it('should call arc for doughnut charts', () => { + const { chart, ctx } = createMockDoughnutChart(); + const element = createMockArcElement({ + x: 200, + y: 150, + startAngle: 0, + endAngle: Math.PI / 2, + innerRadius: 50, + outerRadius: 100, + }); + + drawIndicatorStroke(chart, [element], defaultStyles); + + expect(ctx.arc).toHaveBeenCalled(); + }); + + it('should throw for unsupported dataset types on cartesian charts', () => { + const ctx = createMockCtx(); + const chart = { + ctx, + config: { type: 'scatter', options: {} }, + data: { datasets: [{ data: [1] }] }, + chartArea: defaultChartArea, + } as unknown as Chart; + const element: ActiveElement = { + element: { + getProps: () => ({ x: 100, y: 100 }), + } as unknown as PointElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; + + expect(() => + drawIndicatorStroke(chart, [element], defaultStyles), + ).toThrowError('Unsupported dataset type for indicator plugin: scatter'); + }); +}); + +// #region Test Helpers +const defaultStyles: IndicatorStyles = { + padding: 4, + borderRadius: 3, + borderColor: '#112233', + borderWidth: 2, + backgroundColor: '#aabbcc', +}; + +const defaultChartArea = { + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, +}; + +interface MockCtx { + save: jasmine.Spy; + restore: jasmine.Spy; + beginPath: jasmine.Spy; + closePath: jasmine.Spy; + fill: jasmine.Spy; + stroke: jasmine.Spy; + arc: jasmine.Spy; + roundRect: jasmine.Spy; + fillStyle: string; + strokeStyle: string; + lineWidth: number; +} + +function createMockCtx(): MockCtx { + return { + save: jasmine.createSpy('save'), + restore: jasmine.createSpy('restore'), + beginPath: jasmine.createSpy('beginPath'), + closePath: jasmine.createSpy('closePath'), + fill: jasmine.createSpy('fill'), + stroke: jasmine.createSpy('stroke'), + arc: jasmine.createSpy('arc'), + roundRect: jasmine.createSpy('roundRect'), + fillStyle: '', + strokeStyle: '', + lineWidth: 0, + }; +} + +function createMockBarChart(horizontal = false): { + chart: Chart; + ctx: MockCtx; +} { + const ctx = createMockCtx(); + const chart = { + ctx, + config: { + type: 'bar', + options: horizontal ? { indexAxis: 'y' } : {}, + }, + data: { datasets: [{ data: [1] }] }, + chartArea: defaultChartArea, + } as unknown as Chart; + return { chart, ctx }; +} + +function createMockLineChart(): { chart: Chart; ctx: MockCtx } { + const ctx = createMockCtx(); + const chart = { + ctx, + config: { type: 'line', options: {} }, + data: { datasets: [{ data: [1] }] }, + chartArea: defaultChartArea, + } as unknown as Chart; + return { chart, ctx }; +} + +function createMockDoughnutChart(): { chart: Chart; ctx: MockCtx } { + const ctx = createMockCtx(); + const chart = { + ctx, + config: { type: 'doughnut', options: {} }, + data: { datasets: [{ data: [10, 20] }] }, + chartArea: defaultChartArea, + } as unknown as Chart; + return { chart, ctx }; +} + +function createMockBarElement(props: { + x: number; + y: number; + width: number; + height: number; + base: number; + horizontal: boolean; +}): ActiveElement { + return { + element: { + getProps: () => props, + } as unknown as BarElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; +} + +function createMockPointElement( + props: { x: number; y: number }, + radius: number, +): ActiveElement { + return { + element: { + getProps: () => props, + options: { radius }, + } as unknown as PointElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; +} + +function createMockArcElement(props: { + x: number; + y: number; + startAngle: number; + endAngle: number; + innerRadius: number; + outerRadius: number; +}): ActiveElement { + return { + element: { + getProps: () => props, + } as unknown as ArcElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.ts new file mode 100644 index 0000000000..6b32ec85d4 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.ts @@ -0,0 +1,221 @@ +import type { ActiveElement, Chart, ChartType } from 'chart.js'; + +import { getChartType, getDatasetType } from '../../chart-helpers'; + +import { getBarIndicatorBounds } from './bar-indicator-bounds'; +import { getDonutIndicatorBounds } from './donut-indicator-bounds'; +import { IndicatorBounds, IndicatorStyles } from './indicator-types'; +import { getLineIndicatorBounds } from './line-indicator-bounds'; + +/** + * Draws the filled background of an indicator box around the given active elements. + * Call from `beforeDatasetsDraw` so the fill sits above grid lines but below the data elements. + * @param chart the chart instance to draw on + * @param activeElements the active elements to draw the indicator around + * @param styles the styles to apply to the indicator fill + */ +export function drawIndicatorFill( + chart: Chart, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): void { + if (activeElements.length === 0) { + return; + } + + const ctx = chart.ctx; + ctx.save(); + ctx.fillStyle = styles.backgroundColor; + buildIndicatorPath(ctx, chart, activeElements, styles); + ctx.fill(); + ctx.restore(); +} + +/** + * Draws the border stroke of an indicator box around the given active elements. + * Call from `afterDatasetsDraw` so the stroke sits on top of the data elements. + * @param chart the chart instance to draw on + * @param activeElements the active elements to draw the indicator around + * @param styles the styles to apply to the indicator stroke + */ +export function drawIndicatorStroke( + chart: Chart, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): void { + if (activeElements.length === 0) { + return; + } + + const ctx = chart.ctx; + ctx.save(); + ctx.strokeStyle = styles.borderColor; + ctx.lineWidth = styles.borderWidth; + buildIndicatorPath(ctx, chart, activeElements, styles); + ctx.stroke(); + ctx.restore(); +} + +/** + * Builds the path for an indicator based on the active elements and chart type. + * @param ct the canvas rendering context to build the path on + * @param chart the chart instance to get necessary context from + * @param activeElements the active elements to build the indicator path around + * @param styles the styles to apply to the indicator, used for padding calculations + */ +function buildIndicatorPath( + ctx: CanvasRenderingContext2D, + chart: Chart, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): void { + if (getChartType(chart) === 'doughnut') { + const { x, y, outerRadius, startAngle, endAngle } = + getDonutIndicatorBounds(activeElements); + + ctx.lineWidth = styles.borderWidth + Math.PI; + ctx.beginPath(); + ctx.arc(x, y, outerRadius, startAngle, endAngle); + ctx.arc(x, y, outerRadius, endAngle, startAngle, true); + ctx.closePath(); + } else { + const bounds = getCartesianIndicatorBounds(chart, activeElements, styles); + const radii = getIndicatorCornerRadii(chart, styles.borderRadius); + + ctx.beginPath(); + ctx.roundRect(bounds.x, bounds.y, bounds.width, bounds.height, radii); + } +} + +// #region Cartesian Charts +/** + * Returns per-corner border radii so that only the non-axis end of the indicator box is rounded. + * + * For indicator boxes with Vertical Bar Datasets the top corners are rounded; + * For indicator boxes with Horizontal Bar Datasets the the right corners are rounded. + * For indicator boxes without Bar Datasets all corners are rounded. + * + * @returns Return order matches the Canvas `roundRect` spec: [top-left, top-right, bottom-right, bottom-left] + */ +function getIndicatorCornerRadii( + chart: Chart, + borderRadius: number, +): [number, number, number, number] { + const hasBarDataset = chart.data.datasets.some( + (ds) => getDatasetType(chart, ds) === 'bar', + ); + + if (hasBarDataset) { + const isHorizontal = chart.config.options?.indexAxis === 'y'; + + // Axis on the left → round the right (non-axis) end. + if (isHorizontal) { + return [0, borderRadius, borderRadius, 0]; + } + + // Axis on the bottom → round the top (non-axis) end. + return [borderRadius, borderRadius, 0, 0]; + } + + return [borderRadius, borderRadius, borderRadius, borderRadius]; +} + +/** + * Calculates the bounding box for an indicator based on the active elements and chart type. + * @param chart the chart instance to get necessary context from + * @param activeElements the active elements to calculate bounds for + * @param styles the styles to apply to the indicator, used for padding calculations + * @returns the bounding rectangle for the indicator + */ +function getCartesianIndicatorBounds( + chart: Chart, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): IndicatorBounds { + const groups = groupByDatasetType(chart, activeElements); + const allBounds: IndicatorBounds[] = []; + + for (const [type, elements] of groups) { + allBounds.push(getBoundsForType(chart, elements, type, styles)); + } + + return mergeBounds(allBounds); +} + +/** + * Partitions active elements into groups keyed by their dataset's resolved type. + * @param chart the chart instance to get dataset types from + * @param activeElements the active elements to group + * @returns a Map where each key is a dataset type and each value is an array of active elements belonging to datasets of that type + */ +function groupByDatasetType( + chart: Chart, + activeElements: ActiveElement[], +): Map { + const groups = new Map(); + + for (const el of activeElements) { + const dataset = chart.data.datasets[el.datasetIndex]; + const type = getDatasetType(chart, dataset); + + let group = groups.get(type); + if (!group) { + group = []; + groups.set(type, group); + } + group.push(el); + } + + return groups; +} + +/** + * Dispatches to the correct bounds calculator for the given type. + * @param chart the chart instance to get necessary context from + * @param elements the active elements to get bounds for + * @param type the chart type to determine the bounds calculation strategy + * @returns the bounding rectangle for the given elements and type + * @throws if the type is unsupported or if bounds cannot be calculated for the given elements + */ +function getBoundsForType( + chart: Chart, + elements: ActiveElement[], + type: ChartType, + styles: IndicatorStyles, +): IndicatorBounds { + if (type === 'bar') { + return getBarIndicatorBounds(chart.chartArea, elements, styles); + } + if (type === 'line') { + return getLineIndicatorBounds(elements, styles); + } + + throw new Error(`Unsupported dataset type for indicator plugin: ${type}`); +} + +/** + * Merges multiple bounding rectangles into the smallest rectangle that encloses all of them. + * @param bounds an array of bounding rectangles to merge + * @returns a single bounding rectangle that encloses all input rectangles + */ +function mergeBounds(bounds: IndicatorBounds[]): IndicatorBounds { + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + + for (const b of bounds) { + left = Math.min(left, b.x); + top = Math.min(top, b.y); + right = Math.max(right, b.x + b.width); + bottom = Math.max(bottom, b.y + b.height); + } + + return { + x: left, + y: top, + width: right - left, + height: bottom - top, + }; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin-options.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin-options.ts new file mode 100644 index 0000000000..c9bddaf3ef --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin-options.ts @@ -0,0 +1,17 @@ +import type { ChartType } from 'chart.js'; + +/** + * Per-chart options for the `sky_indicator` plugin. + */ +export interface SkyIndicatorPluginOptions { + /** Whether data points should be clickable */ + dataPointsClickEnabled?: boolean; +} + +// Augment Chart.js so `chart.options.plugins.sky_indicator` is strongly typed. +declare module 'chart.js' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface PluginOptionsByType { + sky_indicator?: SkyIndicatorPluginOptions; + } +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.spec.ts new file mode 100644 index 0000000000..5403c6df85 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.spec.ts @@ -0,0 +1,615 @@ +import { ActiveElement, Chart, PointElement } from 'chart.js'; + +import type { + SkyChartIndicatorStateStyles, + SkyChartStyleService, + SkyChartStyles, +} from '../../services/chart-style.service'; +import { focusedElementsState } from '../plugin-state/focused-elements-state'; + +import { createIndicatorPlugin } from './indicator-plugin'; + +describe('createIndicatorPlugin', () => { + describe('plugin identity', () => { + it('should have the correct plugin id', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + + expect(plugin.id).toBe('sky_indicator'); + }); + }); + + describe('afterInit', () => { + it('should register all required event listeners on the canvas', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + const registeredEvents = canvas.addEventListener.calls + .allArgs() + .map((args) => args[0] as string); + + expect(registeredEvents).toContain('pointerdown'); + expect(registeredEvents).toContain('pointerup'); + expect(registeredEvents).toContain('pointerleave'); + expect(registeredEvents).toContain('pointercancel'); + expect(registeredEvents).toContain('keydown'); + expect(registeredEvents).toContain('keyup'); + expect(registeredEvents).toContain('blur'); + }); + }); + + describe('afterDestroy', () => { + it('should remove all registered event listeners from the canvas', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + const listenerCount = canvas.addEventListener.calls.count(); + + plugin.afterDestroy?.(chart, {}, {}); + + expect(canvas.removeEventListener).toHaveBeenCalledTimes(listenerCount); + }); + + it('should not throw when called without a prior afterInit', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart } = createMockChart(); + + expect(() => plugin.afterDestroy?.(chart, {}, {})).not.toThrow(); + }); + }); + + describe('afterEvent', () => { + it('should set cursor to pointer when hovering over a clickable element', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, getActiveElements } = createMockChart(); + const element = createMockPointElement(100, 100); + + getActiveElements.and.returnValue([element]); + + plugin.afterEvent?.( + chart, + { + event: { type: 'mousemove' }, + inChartArea: true, + changed: false, + } as never, + { dataPointsClickEnabled: true }, + ); + + expect(canvas.style.cursor).toBe('pointer'); + }); + + it('should set cursor to default when hovering but data points are not clickable', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + + plugin.afterEvent?.( + chart, + { + event: { type: 'mousemove' }, + inChartArea: true, + changed: false, + } as never, + { dataPointsClickEnabled: false }, + ); + + expect(canvas.style.cursor).toBe('default'); + }); + + it('should set cursor to default when no elements are hovered', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas } = createMockChart(); + + plugin.afterEvent?.( + chart, + { + event: { type: 'mousemove' }, + inChartArea: true, + changed: false, + } as never, + { dataPointsClickEnabled: true }, + ); + + expect(canvas.style.cursor).toBe('default'); + }); + + it('should not change the cursor for non-mousemove events', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas } = createMockChart(); + canvas.style.cursor = 'pointer'; + + plugin.afterEvent?.( + chart, + { + event: { type: 'click' }, + inChartArea: true, + changed: false, + } as never, + { dataPointsClickEnabled: true }, + ); + + expect(canvas.style.cursor).toBe('pointer'); + }); + }); + + describe('pointer pressed state', () => { + it('should set pressed elements and redraw on pointerdown with active elements', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new PointerEvent('pointerdown')); + + expect(draw).toHaveBeenCalled(); + }); + + it('should not redraw on pointerdown when no elements are active', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new PointerEvent('pointerdown')); + + expect(draw).not.toHaveBeenCalled(); + }); + + it('should clear pressed state and redraw on pointerup', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new PointerEvent('pointerdown')); + draw.calls.reset(); + + canvas.dispatchEvent(new PointerEvent('pointerup')); + + expect(draw).toHaveBeenCalled(); + }); + + it('should not redraw on pointerup when no pressed state exists', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new PointerEvent('pointerup')); + + expect(draw).not.toHaveBeenCalled(); + }); + + it('should clear pressed state on pointerleave', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new PointerEvent('pointerdown')); + draw.calls.reset(); + + canvas.dispatchEvent(new PointerEvent('pointerleave')); + + expect(draw).toHaveBeenCalled(); + }); + + it('should clear pressed state on pointercancel', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new PointerEvent('pointerdown')); + draw.calls.reset(); + + canvas.dispatchEvent(new PointerEvent('pointercancel')); + + expect(draw).toHaveBeenCalled(); + }); + }); + + describe('keyboard pressed state', () => { + it('should set pressed elements on keydown with the Space key', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + + expect(draw).toHaveBeenCalled(); + }); + + it('should set pressed elements on keydown with the Enter key', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(draw).toHaveBeenCalled(); + }); + + it('should not set pressed elements on keydown with non-activation keys', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + + expect(draw).not.toHaveBeenCalled(); + }); + + it('should not set pressed elements on repeated keydown events', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', repeat: true }), + ); + + expect(draw).not.toHaveBeenCalled(); + }); + + it('should prefer focused elements over hovered elements on keydown', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + const focusedElement = createMockPointElement(200, 200); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + focusedElementsState.set(chart, [focusedElement]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + + expect(draw).toHaveBeenCalled(); + + focusedElementsState.delete(chart); + }); + + it('should clear pressed state on keyup with an activation key', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + draw.calls.reset(); + + canvas.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + + expect(draw).toHaveBeenCalled(); + }); + + it('should not clear pressed state on keyup with non-activation keys', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + draw.calls.reset(); + + canvas.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + + expect(draw).not.toHaveBeenCalled(); + }); + + it('should clear pressed state on blur', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, draw, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + canvas.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + draw.calls.reset(); + + canvas.dispatchEvent(new FocusEvent('blur')); + + expect(draw).toHaveBeenCalled(); + }); + }); + + describe('beforeDatasetsDraw', () => { + it('should draw the hover indicator fill when elements are hovered and data points are clickable', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, ctx, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + + plugin.beforeDatasetsDraw?.(chart, {} as never, { + dataPointsClickEnabled: true, + }); + + expect(ctx.fill).toHaveBeenCalled(); + }); + + it('should not draw hover indicator when data points are not clickable', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, ctx, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + + plugin.beforeDatasetsDraw?.(chart, {} as never, { + dataPointsClickEnabled: false, + }); + + expect(ctx.fill).not.toHaveBeenCalled(); + }); + + it('should draw the focus indicator fill when elements are in the focused state', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, ctx, getActiveElements } = createMockChart(); + const focusedElement = createMockPointElement(100, 100); + + getActiveElements.and.returnValue([]); + focusedElementsState.set(chart, [focusedElement]); + + plugin.beforeDatasetsDraw?.(chart, {} as never, { + dataPointsClickEnabled: false, + }); + + expect(ctx.fill).toHaveBeenCalled(); + + focusedElementsState.delete(chart); + }); + + it('should not draw when there are no active or focused elements', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, ctx } = createMockChart(); + + plugin.beforeDatasetsDraw?.(chart, {} as never, { + dataPointsClickEnabled: true, + }); + + expect(ctx.fill).not.toHaveBeenCalled(); + }); + + it('should draw the active indicator fill when pressed state is set', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, canvas, ctx, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + plugin.afterInit?.(chart, {}, {}); + + // Set pressed state via pointerdown. + canvas.dispatchEvent(new PointerEvent('pointerdown')); + ctx.fill.calls.reset(); + + plugin.beforeDatasetsDraw?.(chart, {} as never, { + dataPointsClickEnabled: true, + }); + + // Both hover and active states draw, so fill is called twice. + expect(ctx.fill).toHaveBeenCalledTimes(2); + }); + }); + + describe('afterDatasetsDraw', () => { + it('should draw the hover indicator stroke when elements are hovered and data points are clickable', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, ctx, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + + plugin.afterDatasetsDraw?.( + chart, + {} as never, + { dataPointsClickEnabled: true }, + false, + ); + + expect(ctx.stroke).toHaveBeenCalled(); + }); + + it('should not draw hover stroke when data points are not clickable', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, ctx, getActiveElements } = createMockChart(); + + getActiveElements.and.returnValue([createMockPointElement(100, 100)]); + + plugin.afterDatasetsDraw?.( + chart, + {} as never, + { dataPointsClickEnabled: false }, + false, + ); + + expect(ctx.stroke).not.toHaveBeenCalled(); + }); + + it('should draw the focus indicator stroke when elements are in the focused state', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, ctx, getActiveElements } = createMockChart(); + const focusedElement = createMockPointElement(100, 100); + + getActiveElements.and.returnValue([]); + focusedElementsState.set(chart, [focusedElement]); + + plugin.afterDatasetsDraw?.( + chart, + {} as never, + { dataPointsClickEnabled: false }, + false, + ); + + expect(ctx.stroke).toHaveBeenCalled(); + + focusedElementsState.delete(chart); + }); + + it('should not draw when there are no active or focused elements', () => { + const plugin = createIndicatorPlugin(createMockStyleService()); + const { chart, ctx } = createMockChart(); + + plugin.afterDatasetsDraw?.( + chart, + {} as never, + { dataPointsClickEnabled: true }, + false, + ); + + expect(ctx.stroke).not.toHaveBeenCalled(); + }); + }); +}); + +// #region Test Helpers +interface MockCtx { + save: jasmine.Spy; + restore: jasmine.Spy; + beginPath: jasmine.Spy; + closePath: jasmine.Spy; + fill: jasmine.Spy; + stroke: jasmine.Spy; + arc: jasmine.Spy; + roundRect: jasmine.Spy; + fillStyle: string; + strokeStyle: string; + lineWidth: number; +} + +interface MockCanvas { + style: { cursor: string }; + addEventListener: jasmine.Spy; + removeEventListener: jasmine.Spy; + dispatchEvent: (event: Event) => void; +} + +function createMockCtx(): MockCtx { + return { + save: jasmine.createSpy('save'), + restore: jasmine.createSpy('restore'), + beginPath: jasmine.createSpy('beginPath'), + closePath: jasmine.createSpy('closePath'), + fill: jasmine.createSpy('fill'), + stroke: jasmine.createSpy('stroke'), + arc: jasmine.createSpy('arc'), + roundRect: jasmine.createSpy('roundRect'), + fillStyle: '', + strokeStyle: '', + lineWidth: 0, + }; +} + +function createMockCanvas(): MockCanvas { + const listeners = new Map(); + + return { + style: { cursor: '' }, + addEventListener: jasmine + .createSpy('addEventListener') + .and.callFake((event: string, handler: EventListener) => { + if (!listeners.has(event)) { + listeners.set(event, []); + } + listeners.get(event)?.push(handler); + }), + removeEventListener: jasmine.createSpy('removeEventListener'), + dispatchEvent(event: Event): void { + const handlers = listeners.get(event.type) ?? []; + handlers.forEach((h) => h(event)); + }, + }; +} + +function createMockChart(): { + chart: Chart; + canvas: MockCanvas; + ctx: MockCtx; + draw: jasmine.Spy; + getActiveElements: jasmine.Spy; +} { + const canvas = createMockCanvas(); + const ctx = createMockCtx(); + const draw = jasmine.createSpy('draw'); + const getActiveElements = jasmine + .createSpy('getActiveElements') + .and.returnValue([]); + + const chart = { + canvas, + ctx, + draw, + getActiveElements, + data: { datasets: [{ data: [1] }] }, + config: { type: 'line', options: {} }, + chartArea: { + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, + }, + } as unknown as Chart; + + return { chart, canvas, ctx, draw, getActiveElements }; +} + +function createMockPointElement( + x: number, + y: number, + radius = 4, +): ActiveElement { + return { + element: { + getProps: () => ({ x, y }), + options: { radius }, + } as unknown as PointElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; +} + +function createMockStyleService(): SkyChartStyleService { + const stateStyles: SkyChartIndicatorStateStyles = { + borderWidth: 1, + borderColor: '#border', + backgroundColor: '#bg', + }; + const indicator: SkyChartStyles['indicator'] = { + padding: 4, + borderRadius: 3, + hover: { + ...stateStyles, + borderColor: '#hover-border', + backgroundColor: '#hover-bg', + }, + active: { + ...stateStyles, + borderColor: '#active-border', + backgroundColor: '#active-bg', + }, + focus: { + ...stateStyles, + borderColor: '#focus-border', + backgroundColor: '#focus-bg', + }, + }; + + return { + styles: jasmine.createSpy('styles').and.returnValue({ indicator }), + } as unknown as SkyChartStyleService; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.ts new file mode 100644 index 0000000000..f3f4c62ca0 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.ts @@ -0,0 +1,217 @@ +import type { ActiveElement, Chart, ChartType, Plugin } from 'chart.js'; + +import { SkyChartStyleService } from '../../services/chart-style.service'; +import { isActivationKey } from '../keyboard-nav/keys'; +import { focusedElementsState } from '../plugin-state/focused-elements-state'; + +import { drawIndicatorFill, drawIndicatorStroke } from './indicator-draw'; +import type { SkyIndicatorPluginOptions } from './indicator-plugin-options'; +import type { IndicatorStyles } from './indicator-types'; + +/** + * Indicator plugin that draws hover, active, and focus visual states around chart data elements. + * + * All applicable states draw each frame so multiple indicators can be visible simultaneously. + * This supports the keyboard navigation plugin's WAI-ARIA model where hover and focus are independent. + * + * ### Visual States (drawn in z-order, last wins on overlap) + * 1. **Hover** — Pointer-driven; drawn when the mouse is over a data element. + * 2. **Active** — Drawn while either the Pointer or Activation Keys are held on a data element. + * 3. **Focus** — Keyboard-driven; drawn for the element tracked by the keyboard navigation plugin. + */ +export function createIndicatorPlugin( + styleService: SkyChartStyleService, +): Plugin { + // Track pressed (active) state per chart instance. + const pressedElements = new WeakMap(); + + // Store bound event listeners per chart for proper cleanup in afterDestroy. + const chartListeners = new WeakMap< + Chart, + { canvas: HTMLCanvasElement; listeners: [string, EventListener][] } + >(); + + return { + id: 'sky_indicator', + + afterInit(chart): void { + const canvas = chart.canvas; + const listeners: [string, EventListener][] = []; + + const addListener = (event: string, handler: EventListener): void => { + canvas.addEventListener(event, handler); + listeners.push([event, handler]); + }; + + // Pointer support: show active indicator while pointer is down on a data element. + addListener('pointerdown', () => { + const elements = chart.getActiveElements(); + + if (elements.length) { + pressedElements.set(chart, elements); + chart.draw(); + } + }); + + // Clear pressed state when pointer up/leaves/cancels on the canvas. + const clearPressed = (): void => { + if (pressedElements.has(chart)) { + pressedElements.delete(chart); + chart.draw(); + } + }; + addListener('pointerup', clearPressed); + addListener('pointerleave', clearPressed); + addListener('pointercancel', clearPressed); + + // Keyboard support: show active indicator while an activation key is held down. + // Prefer the keyboard-focused element when navigating; fall back to hover. + addListener('keydown', ((e: KeyboardEvent) => { + if (!isActivationKey(e.key) || e.repeat) { + return; + } + + const focused = focusedElementsState.get(chart); + const elements = focused?.length ? focused : chart.getActiveElements(); + + if (elements?.length) { + pressedElements.set(chart, [...elements]); + chart.draw(); + } + }) as EventListener); + + addListener('keyup', ((e: KeyboardEvent) => { + if (!isActivationKey(e.key)) { + return; + } + + clearPressed(); + }) as EventListener); + + // Also clear on blur in case the key is released after focus leaves. + addListener('blur', clearPressed); + + chartListeners.set(chart, { canvas, listeners }); + }, + + afterDestroy(chart): void { + const entry = chartListeners.get(chart); + + if (entry) { + for (const [event, handler] of entry.listeners) { + entry.canvas.removeEventListener(event, handler); + } + chartListeners.delete(chart); + } + + pressedElements.delete(chart); + }, + + // Draw background fills BEFORE datasets (above grid lines, below data elements). + beforeDatasetsDraw(chart, args, options): void { + const states = resolveIndicatorStates( + chart, + pressedElements, + styleService, + options, + ); + + for (const state of states) { + drawIndicatorFill(chart, state.elements, state.styles); + } + }, + + // Draw border strokes AFTER datasets (on top of everything). + afterDatasetsDraw(chart, args, options): void { + const states = resolveIndicatorStates( + chart, + pressedElements, + styleService, + options, + ); + + for (const state of states) { + drawIndicatorStroke(chart, state.elements, state.styles); + } + }, + + // Update cursor style based on hover state and clickable flag. + afterEvent(chart, args, options): void { + if (args.event.type === 'mousemove') { + const elements = chart.getActiveElements(); + const clickable = options.dataPointsClickEnabled; + const showPointer = clickable && elements.length > 0; + chart.canvas.style.cursor = showPointer ? 'pointer' : 'default'; + } + }, + }; +} + +interface IndicatorState { + elements: ActiveElement[]; + styles: IndicatorStyles; +} + +/** + * Collects all indicator states that should draw this frame. + * States are returned in visual z-order (hover first, focus last) + * so the last-drawn state takes visual precedence on overlapping elements. + */ +function resolveIndicatorStates( + chart: Chart, + pressedElements: WeakMap, + styleService: SkyChartStyleService, + options: SkyIndicatorPluginOptions, +): IndicatorState[] { + const indicatorStyles = styleService.styles().indicator; + const states: IndicatorState[] = []; + + // Hover is the baseline (drawn first, lowest visual precedence). + // Only shown when datapoint activation is enabled. + const hovered = chart.getActiveElements(); + if (hovered?.length && options.dataPointsClickEnabled) { + states.push({ + elements: hovered, + styles: { + padding: indicatorStyles.padding, + borderRadius: indicatorStyles.borderRadius, + borderWidth: indicatorStyles.hover.borderWidth, + borderColor: indicatorStyles.hover.borderColor, + backgroundColor: indicatorStyles.hover.backgroundColor, + }, + }); + } + + // Active overlays hover (pointer down / Space held). + // Only shown when datapoint activation is enabled. + const pressed = pressedElements.get(chart); + if (pressed?.length && options.dataPointsClickEnabled) { + states.push({ + elements: pressed, + styles: { + padding: indicatorStyles.padding, + borderRadius: indicatorStyles.borderRadius, + borderWidth: indicatorStyles.active.borderWidth, + borderColor: indicatorStyles.active.borderColor, + backgroundColor: indicatorStyles.active.backgroundColor, + }, + }); + } + + // Focus draws last (highest visual precedence, keyboard-owned). + const focused = focusedElementsState.get(chart); + if (focused?.length) { + states.push({ + elements: focused, + styles: { + padding: indicatorStyles.padding, + borderRadius: indicatorStyles.borderRadius, + borderWidth: indicatorStyles.focus.borderWidth, + borderColor: indicatorStyles.focus.borderColor, + backgroundColor: indicatorStyles.focus.backgroundColor, + }, + }); + } + + return states; +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-types.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-types.ts new file mode 100644 index 0000000000..d6b2294cbb --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-types.ts @@ -0,0 +1,45 @@ +/** + * Visual style properties applied when drawing an indicator box. + */ +export interface IndicatorStyles { + /** The inner spacing between the indicator border and the element it surrounds. */ + padding: number; + /** The corner radius of the indicator border box. */ + borderRadius: number; + /** The CSS color of the indicator border. */ + borderColor: string; + /** The pixel width of the indicator border. */ + borderWidth: number; + /** The CSS color of the indicator background fill. */ + backgroundColor: string; +} + +/** + * The geometry of an indicator box, used for cartesian charts. + */ +export interface IndicatorBounds { + /** The x center */ + x: number; + /** The y center */ + y: number; + /** The width of the bar */ + width: number; + /** The height of the bar */ + height: number; +} + +/** + * The geometry of an indicator arc, used for donut charts. + */ +export interface ArcIndicatorBounds { + /** The x-coordinate of the arc's center point. */ + x: number; + /** The y-coordinate of the arc's center point. */ + y: number; + /** The angle (in radians) where the slice begins. */ + startAngle: number; + /** The angle (in radians) where the slice ends. */ + endAngle: number; + /** The radius of the outer edge of the donut slice. */ + outerRadius: number; +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.spec.ts new file mode 100644 index 0000000000..56c4c9777c --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.spec.ts @@ -0,0 +1,115 @@ +import { ActiveElement, PointElement } from 'chart.js'; + +import type { IndicatorStyles } from './indicator-types'; +import { getLineIndicatorBounds } from './line-indicator-bounds'; + +describe('getLineIndicatorBounds', () => { + it('should return a square bounding box centered on the point', () => { + const element = createMockPointElement({ x: 100, y: 150 }, 5); + + const result = getLineIndicatorBounds([element], defaultStyles); + + expect(result.width).toBe(result.height); + }); + + it('should position the bounding box at the correct coordinates', () => { + // effective padding = styles.padding + 1.5 = 4 + 1.5 = 5.5 + // x = 100 - radius(5) - padding(5.5) = 89.5 + // y = 150 - radius(5) - padding(5.5) = 139.5 + const element = createMockPointElement({ x: 100, y: 150 }, 5); + + const result = getLineIndicatorBounds([element], defaultStyles); + + expect(result.x).toBe(89.5); + expect(result.y).toBe(139.5); + }); + + it('should add styles.padding + 1.5 of space around the point radius', () => { + const radius = 5; + const effectivePadding = defaultStyles.padding + 1.5; // 5.5 + const element = createMockPointElement({ x: 100, y: 150 }, radius); + + const result = getLineIndicatorBounds([element], defaultStyles); + + const expectedSize = radius * 2 + effectivePadding * 2; + expect(result.width).toBe(expectedSize); + expect(result.height).toBe(expectedSize); + }); + + it('should scale the bounding box with the point radius', () => { + const radius = 8; + const effectivePadding = defaultStyles.padding + 1.5; + const element = createMockPointElement({ x: 50, y: 50 }, radius); + + const result = getLineIndicatorBounds([element], defaultStyles); + + const expectedSize = radius * 2 + effectivePadding * 2; + expect(result.width).toBe(expectedSize); + expect(result.height).toBe(expectedSize); + }); + + it('should adjust dimensions when styles padding changes', () => { + const styles: IndicatorStyles = { ...defaultStyles, padding: 8 }; + const radius = 5; + const element = createMockPointElement({ x: 100, y: 100 }, radius); + + const result = getLineIndicatorBounds([element], styles); + + const effectivePadding = 8 + 1.5; + const expectedSize = radius * 2 + effectivePadding * 2; + expect(result.width).toBe(expectedSize); + }); + + it('should fall back to 0 when x and y props are undefined', () => { + const element: ActiveElement = { + element: { + getProps: () => ({ x: undefined, y: undefined }), + options: { radius: 5 }, + } as unknown as PointElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; + + // effective padding = 4 + 1.5 = 5.5; x = 0 - 5 - 5.5 = -10.5 + const result = getLineIndicatorBounds([element], defaultStyles); + + expect(result.x).toBe(-10.5); + expect(result.y).toBe(-10.5); + }); + + it('should use only the first active element', () => { + const element1 = createMockPointElement({ x: 100, y: 150 }, 5); + const element2 = createMockPointElement({ x: 200, y: 250 }, 5); + + const result = getLineIndicatorBounds([element1, element2], defaultStyles); + + // effective padding = 5.5; x = 100 - 5 - 5.5 = 89.5 + expect(result.x).toBe(89.5); + expect(result.y).toBe(139.5); + }); +}); + +// #region Test Helpers +const defaultStyles: IndicatorStyles = { + padding: 4, + borderRadius: 3, + borderColor: '#000', + borderWidth: 1, + backgroundColor: '#fff', +}; + +function createMockPointElement( + props: { x: number; y: number }, + radius: number, +): ActiveElement { + return { + element: { + getProps: () => props, + options: { radius }, + } as unknown as PointElement, + datasetIndex: 0, + index: 0, + } as ActiveElement; +} + +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.ts new file mode 100644 index 0000000000..82d4ebebae --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.ts @@ -0,0 +1,54 @@ +import { ActiveElement, PointElement } from 'chart.js'; + +import { IndicatorBounds, IndicatorStyles } from './indicator-types'; + +/** + * Returns the geometry of an indicator box for the given active line elements. + * @param activeElements the active elements to get bounds for. + * @param styles the styles to apply to the indicator + */ +export function getLineIndicatorBounds( + activeElements: ActiveElement[], + styles: IndicatorStyles, +): IndicatorBounds { + const points = activeElements.map((el) => getGeometry(el)); + return getBounds(points[0], styles); +} + +function getGeometry(activeElement: ActiveElement): LinePointGeometry { + const point = activeElement.element as PointElement; + const props = point.getProps(['x', 'y'], true); + + return { + x: props.x ?? 0, + y: props.y ?? 0, + radius: point.options.radius, + }; +} + +function getBounds( + point: LinePointGeometry, + styles: IndicatorStyles, +): IndicatorBounds { + const padding = styles.padding + 1.5; + const radius = point.radius; + const diameter = radius * 2; + const diameterWithPadding = diameter + padding * 2; + + return { + x: point.x - radius - padding, + y: point.y - radius - padding, + width: diameterWithPadding, + height: diameterWithPadding, + }; +} + +/** The geometry of a point element */ +interface LinePointGeometry { + /** The x center */ + x: number; + /** The y center */ + y: number; + /** The points radius */ + radius: number; +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.spec.ts new file mode 100644 index 0000000000..d7f3407475 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.spec.ts @@ -0,0 +1,348 @@ +import type { ActiveElement, Chart } from 'chart.js'; + +import { CartesianNavigationStrategy } from './cartesian-navigation-strategy'; +import type { FocusedElement } from './navigation-strategy'; + +describe('CartesianNavigationStrategy', () => { + describe('navigate (vertical chart, indexAxis=x)', () => { + it('should move to the next data point on ArrowRight', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowRight', current); + + expect(result).toEqual({ datasetIndex: 0, index: 1 }); + }); + + it('should move to the previous data point on ArrowLeft', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 2 }; + + const result = strategy.navigate('ArrowLeft', current); + + expect(result).toEqual({ datasetIndex: 0, index: 1 }); + }); + + it('should clamp at the last data point when ArrowRight is pressed at the end', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 2 }; + + const result = strategy.navigate('ArrowRight', current); + + expect(result).toEqual({ datasetIndex: 0, index: 2 }); + }); + + it('should clamp at the first data point when ArrowLeft is pressed at the start', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowLeft', current); + + expect(result).toEqual({ datasetIndex: 0, index: 0 }); + }); + + it('should move to the next series on ArrowDown', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowDown', current); + + expect(result).toEqual({ datasetIndex: 1, index: 0 }); + }); + + it('should move to the previous series on ArrowUp', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 1, index: 0 }; + + const result = strategy.navigate('ArrowUp', current); + + expect(result).toEqual({ datasetIndex: 0, index: 0 }); + }); + + it('should wrap to the next series when at the last series', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 1, index: 0 }; + + const result = strategy.navigate('ArrowDown', current); + + expect(result).toEqual({ datasetIndex: 0, index: 0 }); + }); + + it('should wrap to the previous series when at the first series', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowUp', current); + + expect(result).toEqual({ datasetIndex: 1, index: 0 }); + }); + + it('should clamp index when switching to a shorter series', () => { + const chart = createMockChart({ + datasets: [ + { data: [1, 2, 3], label: 'Series A' }, + { data: [4], label: 'Series B' }, + ], + }); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 2 }; + + const result = strategy.navigate('ArrowDown', current); + + expect(result).toEqual({ datasetIndex: 1, index: 0 }); + }); + + it('should return current when the chart has no datasets (datasetCount === 0)', () => { + const chart = createMockChart({ datasets: [] }); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowDown', current); + + expect(result).toEqual(current); + }); + + it('should return current when the focused datasetIndex is out of bounds (newIndex < 0)', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + // Out-of-bounds datasetIndex causes #getDataLength to return 0 via ?? 0, + // making maxIndex = -1, so newIndex = -1 which triggers the guard. + const current: FocusedElement = { datasetIndex: 99, index: 0 }; + + const result = strategy.navigate('ArrowRight', current); + + expect(result).toEqual(current); + }); + + it('should skip hidden datasets and move to the next visible series', () => { + const chart = createMockChart({ + datasets: [ + { data: [1, 2], label: 'Series A' }, + { data: [3, 4], label: 'Series B', hidden: true }, + { data: [5, 6], label: 'Series C' }, + ], + }); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowDown', current); + + expect(result).toEqual({ datasetIndex: 2, index: 0 }); + }); + + it('should stay on the current dataset when all other datasets are hidden', () => { + const chart = createMockChart({ + datasets: [ + { data: [1, 2], label: 'Series A' }, + { data: [3, 4], label: 'Series B', hidden: true }, + ], + }); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowDown', current); + + expect(result).toEqual({ datasetIndex: 0, index: 0 }); + }); + }); + + describe('navigate (horizontal chart, indexAxis=y)', () => { + it('should move to the next series on ArrowRight', () => { + const chart = createMockChart({ indexAxis: 'y' }); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowRight', current); + + expect(result).toEqual({ datasetIndex: 1, index: 0 }); + }); + + it('should move to the previous series on ArrowLeft', () => { + const chart = createMockChart({ indexAxis: 'y' }); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 1, index: 0 }; + + const result = strategy.navigate('ArrowLeft', current); + + expect(result).toEqual({ datasetIndex: 0, index: 0 }); + }); + + it('should move to the next data point on ArrowDown', () => { + const chart = createMockChart({ indexAxis: 'y' }); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowDown', current); + + expect(result).toEqual({ datasetIndex: 0, index: 1 }); + }); + + it('should move to the previous data point on ArrowUp', () => { + const chart = createMockChart({ indexAxis: 'y' }); + const strategy = new CartesianNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 2 }; + + const result = strategy.navigate('ArrowUp', current); + + expect(result).toEqual({ datasetIndex: 0, index: 1 }); + }); + }); + + describe('getTooltipElements', () => { + it('should return all visible elements at the focused index', () => { + const chart = createMockChart(); + const strategy = new CartesianNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 1 }; + + const result = strategy.getTooltipElements(chart, focused); + + // Both datasets are visible, so both should be in the tooltip. + expect(result.length).toBe(2); + expect(result[0].datasetIndex).toBe(0); + expect(result[0].index).toBe(1); + expect(result[1].datasetIndex).toBe(1); + expect(result[1].index).toBe(1); + }); + + it('should exclude hidden datasets from the tooltip', () => { + const chart = createMockChart({ + datasets: [ + { data: [1, 2], label: 'Series A' }, + { data: [3, 4], label: 'Series B', hidden: true }, + ], + }); + const strategy = new CartesianNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.getTooltipElements(chart, focused); + + expect(result.length).toBe(1); + expect(result[0].datasetIndex).toBe(0); + }); + + it('should return empty array when data element does not exist', () => { + const chart = createMockChart({ + datasets: [{ data: [], label: 'Empty' }], + }); + const strategy = new CartesianNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 5 }; + + const result = strategy.getTooltipElements(chart, focused); + + expect(result.length).toBe(0); + }); + }); + + describe('describeElement', () => { + it('should describe the focused element with correct values', () => { + const chart = createMockChart({ + datasets: [{ data: [10, 20, 30], label: 'Revenue' }], + labels: ['Jan', 'Feb', 'Mar'], + }); + const strategy = new CartesianNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 1 }; + + const result = strategy.describeElement(chart, focused); + + expect(result.seriesLabel).toBe('Revenue'); + expect(result.seriesIndex).toBe(1); + expect(result.totalSeries).toBe(1); + expect(result.categoryLabel).toBe('Feb'); + expect(result.value).toBe('20'); + expect(result.index).toBe(2); + expect(result.total).toBe(3); + }); + + it('should handle missing labels gracefully', () => { + const chart = createMockChart({ + datasets: [{ data: [5], label: 'S1' }], + labels: [], + }); + const strategy = new CartesianNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.describeElement(chart, focused); + + expect(result.categoryLabel).toBe(''); + }); + + it('should return empty string value and zero total when datasetIndex is out of bounds', () => { + const chart = createMockChart({ + datasets: [{ data: [10, 20], label: 'S1' }], + labels: ['A', 'B'], + }); + const strategy = new CartesianNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 99, index: 0 }; + + const result = strategy.describeElement(chart, focused); + + expect(result.value).toBe(''); + expect(result.total).toBe(0); + }); + + it('should return empty string for series label when dataset has no label', () => { + const chart = { + config: { options: { indexAxis: 'x' } }, + data: { + datasets: [{ data: [1] }], + labels: ['A'], + }, + isDatasetVisible: () => true, + getDatasetMeta: () => ({ data: [createMockDataElement()] }), + } as unknown as Chart; + const strategy = new CartesianNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.describeElement(chart, focused); + + expect(result.seriesLabel).toBe(''); + }); + }); +}); + +// #region Test Helpers +function createMockDataElement(): ActiveElement['element'] { + return {} as ActiveElement['element']; +} + +function createMockChart( + options: { + datasets?: { data: unknown[]; label?: string; hidden?: boolean }[]; + labels?: string[]; + indexAxis?: 'x' | 'y'; + } = {}, +): Chart { + const datasets = options.datasets ?? [ + { data: [1, 2, 3], label: 'Series A' }, + { data: [4, 5, 6], label: 'Series B' }, + ]; + const labels = options.labels ?? ['Cat 1', 'Cat 2', 'Cat 3']; + const indexAxis = options.indexAxis ?? 'x'; + + const isDatasetVisible = jasmine + .createSpy('isDatasetVisible') + .and.callFake((i: number) => !datasets[i]?.hidden); + + const getDatasetMeta = jasmine + .createSpy('getDatasetMeta') + .and.callFake((i: number) => ({ + data: datasets[i]?.data.map(() => createMockDataElement()) ?? [], + hidden: datasets[i]?.hidden ?? false, + })); + + return { + config: { options: { indexAxis } }, + data: { datasets, labels }, + isDatasetVisible, + getDatasetMeta, + } as unknown as Chart; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.ts new file mode 100644 index 0000000000..76de10bd05 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.ts @@ -0,0 +1,171 @@ +import type { ActiveElement, Chart } from 'chart.js'; + +import type { NavigationKey } from './keys'; +import type { + ElementDescription, + FocusedElement, + NavigationStrategy, +} from './navigation-strategy'; + +/** + * Navigation strategy for cartesian charts (bar, line, combo). + * - Left/Right navigate between data points (categories) in vertical charts, + * and between series (datasets) in horizontal charts. + * - Up/Down navigate between series (datasets) in vertical charts, + * and between data points (categories) in horizontal charts. + * + * Handles variable-length datasets and skips hidden datasets when cycling series. + * Tooltip shows all datasets at the focused index (grouped tooltip). + */ +export class CartesianNavigationStrategy implements NavigationStrategy { + readonly #chart: Chart; + readonly #isHorizontal: boolean; + + constructor(chart: Chart) { + this.#chart = chart; + this.#isHorizontal = chart.config.options?.indexAxis === 'y'; + } + + /** @inheritdoc */ + public navigate(key: NavigationKey, current: FocusedElement): FocusedElement { + const datasets = this.#chart.data.datasets; + const datasetCount = datasets.length; + + let newDatasetIndex = current.datasetIndex; + let newIndex = current.index; + + const direction = this.#getDirection(key); + + switch (direction) { + case 'nextSeries': + newDatasetIndex = this.#findVisibleDataset(current.datasetIndex, 1); + break; + case 'prevSeries': + newDatasetIndex = this.#findVisibleDataset(current.datasetIndex, -1); + break; + case 'nextPoint': { + const maxIndex = this.#getDataLength(newDatasetIndex) - 1; + newIndex = Math.min(newIndex + 1, maxIndex); + break; + } + case 'prevPoint': + newIndex = Math.max(newIndex - 1, 0); + break; + } + + // After switching series, clamp the index to the new dataset's range. + if (direction === 'nextSeries' || direction === 'prevSeries') { + const newDataLength = this.#getDataLength(newDatasetIndex); + newIndex = Math.min(newIndex, newDataLength - 1); + } + + // Guard against empty charts. + if (datasetCount === 0 || newIndex < 0) { + return current; + } + + return { datasetIndex: newDatasetIndex, index: newIndex }; + } + + /** @inheritdoc */ + public getTooltipElements( + chart: Chart, + focused: FocusedElement, + ): ActiveElement[] { + const elements: ActiveElement[] = []; + const datasets = chart.data.datasets; + + // Collect all visible elements at the focused index across all datasets (grouped tooltip). + for (let i = 0; i < datasets.length; i++) { + if (!chart.isDatasetVisible(i)) { + continue; + } + + const element = this.#getActiveElement(i, focused.index); + if (element) { + elements.push(element); + } + } + + return elements; + } + + /** @inheritdoc */ + public describeElement( + chart: Chart, + focused: FocusedElement, + ): ElementDescription { + const dataset = chart.data.datasets[focused.datasetIndex]; + + return { + seriesLabel: String(dataset?.label ?? ''), + seriesIndex: focused.datasetIndex + 1, + totalSeries: chart.data.datasets.length, + categoryLabel: String(chart.data.labels?.[focused.index] ?? ''), + value: String(dataset?.data[focused.index] ?? ''), + index: focused.index + 1, + total: dataset?.data.length ?? 0, + }; + } + + #getDirection(key: NavigationKey): Direction { + const mapping: Record = this.#isHorizontal + ? { + ArrowRight: 'nextSeries', + ArrowLeft: 'prevSeries', + ArrowDown: 'nextPoint', + ArrowUp: 'prevPoint', + } + : { + ArrowRight: 'nextPoint', + ArrowLeft: 'prevPoint', + ArrowDown: 'nextSeries', + ArrowUp: 'prevSeries', + }; + + return mapping[key]; + } + + /** + * Finds the next visible dataset index in the given step direction, wrapping around. + * Returns the current index if no other visible dataset exists. + */ + #findVisibleDataset(currentIndex: number, step: 1 | -1): number { + const count = this.#chart.data.datasets.length; + let candidate = currentIndex; + + for (let i = 0; i < count; i++) { + candidate = (candidate + step + count) % count; + + if (this.#chart.isDatasetVisible(candidate)) { + return candidate; + } + } + + // All datasets hidden — stay put. + return currentIndex; + } + + #getDataLength(datasetIndex: number): number { + return this.#chart.data.datasets[datasetIndex]?.data.length ?? 0; + } + + #getActiveElement( + datasetIndex: number, + index: number, + ): ActiveElement | undefined { + const meta = this.#chart.getDatasetMeta(datasetIndex); + const dataElement = meta?.data[index]; + + if (!dataElement) { + return undefined; + } + + return { datasetIndex, index, element: dataElement }; + } +} + +/** + * Defines the possible navigation directions based on key input and chart orientation. + */ +type Direction = 'nextSeries' | 'prevSeries' | 'nextPoint' | 'prevPoint'; diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/create-navigation-strategy.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/create-navigation-strategy.spec.ts new file mode 100644 index 0000000000..284b743aee --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/create-navigation-strategy.spec.ts @@ -0,0 +1,39 @@ +import type { Chart } from 'chart.js'; + +import { CartesianNavigationStrategy } from './cartesian-navigation-strategy'; +import { createNavigationStrategy } from './create-navigation-strategy'; +import { RadialNavigationStrategy } from './radial-navigation-strategy'; + +describe('createNavigationStrategy', () => { + it('should return a RadialNavigationStrategy for doughnut charts', () => { + const chart = createMockChart('doughnut'); + + const strategy = createNavigationStrategy(chart); + + expect(strategy).toBeInstanceOf(RadialNavigationStrategy); + }); + + it('should return a CartesianNavigationStrategy for bar charts', () => { + const chart = createMockChart('bar'); + + const strategy = createNavigationStrategy(chart); + + expect(strategy).toBeInstanceOf(CartesianNavigationStrategy); + }); + + it('should return a CartesianNavigationStrategy for line charts', () => { + const chart = createMockChart('line'); + + const strategy = createNavigationStrategy(chart); + + expect(strategy).toBeInstanceOf(CartesianNavigationStrategy); + }); +}); + +// #region Test Helpers +function createMockChart(type: string): Chart { + return { + config: { type }, + } as unknown as Chart; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/create-navigation-strategy.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/create-navigation-strategy.ts new file mode 100644 index 0000000000..1baf9f0ef6 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/create-navigation-strategy.ts @@ -0,0 +1,20 @@ +import type { Chart } from 'chart.js'; + +import { isDonutChart } from '../../chart-helpers'; + +import { CartesianNavigationStrategy } from './cartesian-navigation-strategy'; +import type { NavigationStrategy } from './navigation-strategy'; +import { RadialNavigationStrategy } from './radial-navigation-strategy'; + +/** + * Factory function to create the appropriate navigation strategy based on the chart type. + * @param chart + * @returns An instance of NavigationStrategy tailored to the chart's layout (cartesian vs. radial). + */ +export function createNavigationStrategy(chart: Chart): NavigationStrategy { + if (isDonutChart(chart)) { + return new RadialNavigationStrategy(chart); + } + + return new CartesianNavigationStrategy(chart); +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin-options.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin-options.ts new file mode 100644 index 0000000000..f9055f8ee6 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin-options.ts @@ -0,0 +1,36 @@ +import type { ChartType } from 'chart.js'; + +/** + * A callback that returns a human-readable label for a specific data point, + * identified by its dataset index and data index. + * + * Used by the keyboard navigation plugin to announce consumer-formatted values + * (e.g. "$10,000") instead of raw numeric data. + * @param datasetIndex - The ChartJS dataset index that the data point belongs to + * @param dataIndex - The ChartJS data index of the data point within its dataset + * @returns A human-readable label for the specified data point + */ +export type SkyValueLabelFn = ( + datasetIndex: number, + dataIndex: number, +) => string; + +/** + * Per-chart options for the `sky_keyboard_nav` plugin. + */ +export interface SkyKeyboardNavPluginOptions { + /** + * Optional callback that provides a human-readable label for a data point. + * When set, the keyboard navigation screen reader announcement uses this + * value instead of the raw numeric data from the dataset. + */ + valueLabel?: SkyValueLabelFn; +} + +// Augment Chart.js so `chart.options.plugins.sky_keyboard_nav` is strongly typed. +declare module 'chart.js' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface PluginOptionsByType { + sky_keyboard_nav?: SkyKeyboardNavPluginOptions; + } +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.spec.ts new file mode 100644 index 0000000000..b1cdc3c8d5 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.spec.ts @@ -0,0 +1,602 @@ +import type { SkyLiveAnnouncerService } from '@skyux/core'; +import type { SkyLibResourcesService } from '@skyux/i18n'; + +import type { ActiveElement, Chart } from 'chart.js'; +import { of } from 'rxjs'; + +import { focusedElementsState } from '../plugin-state/focused-elements-state'; + +import { createKeyboardNavPlugin } from './keyboard-nav-plugin'; + +describe('createKeyboardNavPlugin', () => { + describe('plugin identity', () => { + it('should have the correct plugin id', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + + expect(plugin.id).toBe('sky_keyboard_nav'); + }); + }); + + describe('afterInit / afterDestroy', () => { + it('should not throw on afterInit', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart } = createMockChart(); + + expect(() => plugin.afterInit?.(chart, {}, {})).not.toThrow(); + }); + + it('should remove event listeners on afterDestroy', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, removeEventListenerSpy } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + plugin.afterDestroy?.(chart, {}, {}); + + expect(removeEventListenerSpy).toHaveBeenCalledTimes(5); + }); + + it('should not throw on afterDestroy without a prior afterInit', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart } = createMockChart(); + + expect(() => plugin.afterDestroy?.(chart, {}, {})).not.toThrow(); + }); + + it('should clean up focused elements state on afterDestroy', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + focusedElementsState.set(chart, []); + plugin.afterDestroy?.(chart, {}, {}); + + expect(focusedElementsState.has(chart)).toBeFalse(); + }); + }); + + describe('keyboard focus navigation', () => { + it('should start navigation and focus first element when chart receives keyboard focus', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, tooltipSetActiveElements, chartUpdate } = + createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + dispatchEvent(new FocusEvent('focus')); + + expect(tooltipSetActiveElements).toHaveBeenCalled(); + expect(chartUpdate).toHaveBeenCalledWith('none'); + }); + + it('should not start navigation on focus when triggered by mouse click', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + // Simulate mouse-triggered focus: mousedown then focus. + dispatchEvent(new MouseEvent('mousedown')); + chartUpdate.calls.reset(); + dispatchEvent(new FocusEvent('focus')); + + expect(chartUpdate).not.toHaveBeenCalled(); + }); + + it('should navigate right with ArrowRight key when already navigating', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + + const state = focusedElementsState.get(chart); + const initialIndex = state?.[0]?.index ?? 0; + + dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + + const newState = focusedElementsState.get(chart); + expect(newState?.[0]?.index ?? 0).toBe(initialIndex + 1); + }); + + it('should start navigation with ArrowRight key even when not yet navigating', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + + expect(chartUpdate).toHaveBeenCalledWith('none'); + }); + + it('should end navigation on Tab key', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + chartUpdate.calls.reset(); + + dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab' })); + + expect(chartUpdate).toHaveBeenCalledWith('none'); + expect(focusedElementsState.get(chart)).toEqual([]); + }); + + it('should end navigation on Escape key', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + chartUpdate.calls.reset(); + + dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + + expect(chartUpdate).toHaveBeenCalledWith('none'); + expect(focusedElementsState.get(chart)).toEqual([]); + }); + + it('should end navigation on blur', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + chartUpdate.calls.reset(); + + dispatchEvent(new FocusEvent('blur')); + + expect(chartUpdate).toHaveBeenCalledWith('none'); + expect(focusedElementsState.get(chart)).toEqual([]); + }); + + it('should end navigation on mousedown', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + chartUpdate.calls.reset(); + + dispatchEvent(new MouseEvent('mousedown')); + + expect(chartUpdate).toHaveBeenCalledWith('none'); + }); + + it('should trigger onClick when Enter is pressed on a focused element', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, onClickSpy } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + + dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + + expect(onClickSpy).toHaveBeenCalled(); + }); + + it('should trigger onClick when Space is pressed on a focused element', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, onClickSpy } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + + dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + + expect(onClickSpy).toHaveBeenCalled(); + }); + + it('should not trigger onClick when Enter keyup fires outside navigation', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, onClickSpy } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + + expect(onClickSpy).not.toHaveBeenCalled(); + }); + + it('should announce position on navigation', () => { + const liveAnnouncer = createMockLiveAnnouncer(); + const plugin = createKeyboardNavPlugin( + createMockResources('position announced'), + liveAnnouncer, + ); + const { chart, dispatchEvent } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + + expect(liveAnnouncer.announce).toHaveBeenCalledWith('position announced'); + }); + + it('should use custom valueLabel callback when provided', () => { + const liveAnnouncer = createMockLiveAnnouncer(); + const resources = createMockResources(); + const plugin = createKeyboardNavPlugin(resources, liveAnnouncer); + const valueLabel = jasmine + .createSpy('valueLabel') + .and.returnValue('$100'); + const { chart, dispatchEvent } = createMockChart(); + + plugin.afterInit?.(chart, {}, { valueLabel }); + dispatchEvent(new FocusEvent('focus')); + + expect(valueLabel).toHaveBeenCalledWith(0, 0); + }); + + it('should use multi-series resource string when chart has multiple datasets', () => { + const resources = createMockResources(); + const plugin = createKeyboardNavPlugin( + resources, + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent } = createMockChart({ + datasets: [ + { data: [1, 2, 3], label: 'Series A' }, + { data: [4, 5, 6], label: 'Series B' }, + ], + }); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + + expect(resources.getString).toHaveBeenCalledWith( + 'chart.focus_element.multi_series.description', + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + ); + }); + + it('should use single-series resource string when chart has one dataset', () => { + const resources = createMockResources(); + const plugin = createKeyboardNavPlugin( + resources, + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent } = createMockChart({ + datasets: [{ data: [1, 2, 3], label: 'Only Series' }], + }); + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + + expect(resources.getString).toHaveBeenCalledWith( + 'chart.focus_element.single_series.description', + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + ); + }); + + it('should handle charts with no visible datasets gracefully', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart({ + datasets: [{ data: [1, 2], label: 'Hidden', hidden: true }], + }); + + plugin.afterInit?.(chart, {}, {}); + + expect(() => dispatchEvent(new FocusEvent('focus'))).not.toThrow(); + expect(chartUpdate).not.toHaveBeenCalled(); + }); + + it('should not update chart when ArrowKey is pressed with no focusable elements', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart({ + datasets: [{ data: [1, 2], label: 'Hidden', hidden: true }], + }); + + plugin.afterInit?.(chart, {}, {}); + + // ArrowKey triggers #startNavigation (no element found) then #navigate guard fires + dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + + expect(chartUpdate).not.toHaveBeenCalled(); + }); + + it('should not trigger onClick when Enter is activated with no focused element', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, onClickSpy } = createMockChart({ + datasets: [{ data: [1, 2], label: 'Hidden', hidden: true }], + }); + + plugin.afterInit?.(chart, {}, {}); + // Start navigation without finding an element + dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + // Attempt activation — #handleActivation guard fires + dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + + expect(onClickSpy).not.toHaveBeenCalled(); + }); + + it('should not call onClick when the chart element cannot be found in metadata', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + // Dataset has data but getDatasetMeta returns empty data array (unrendered chart) + const { canvas, dispatchEvent, removeEventListenerSpy } = + createMockCanvas(); + const onClickSpy = jasmine.createSpy('onClick'); + const chart = { + canvas, + config: { + type: 'bar', + options: { indexAxis: 'x', onClick: onClickSpy }, + }, + data: { + datasets: [{ data: [1, 2, 3], label: 'S' }], + labels: ['A', 'B', 'C'], + }, + tooltip: { setActiveElements: jasmine.createSpy('setActiveElements') }, + update: jasmine.createSpy('update'), + getDatasetMeta: jasmine + .createSpy('getDatasetMeta') + .and.returnValue({ hidden: false, data: [] }), + isDatasetVisible: jasmine + .createSpy('isDatasetVisible') + .and.returnValue(true), + } as unknown as Chart; + + plugin.afterInit?.(chart, {}, {}); + dispatchEvent(new FocusEvent('focus')); + // Enter activation: #getActiveElement returns null, onClick is NOT called + dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + + expect(onClickSpy).not.toHaveBeenCalled(); + + // Clean up unused spy reference + void removeEventListenerSpy; + }); + + it('should ignore non-navigation keys when not navigating', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + expect(chartUpdate).not.toHaveBeenCalled(); + }); + + it('should not navigate on Escape key when not navigating', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const { chart, dispatchEvent, chartUpdate } = createMockChart(); + + plugin.afterInit?.(chart, {}, {}); + + // Escape when not navigating should not crash or update chart. + expect(() => + dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })), + ).not.toThrow(); + expect(chartUpdate).not.toHaveBeenCalled(); + }); + }); + + describe('multiple chart instances', () => { + it('should manage navigation state independently for each chart', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const mock1 = createMockChart(); + const mock2 = createMockChart(); + + plugin.afterInit?.(mock1.chart, {}, {}); + plugin.afterInit?.(mock2.chart, {}, {}); + + mock1.dispatchEvent(new FocusEvent('focus')); + mock1.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })); + + // Chart 2 should be unaffected. + expect(focusedElementsState.get(mock2.chart)).toBeUndefined(); + }); + + it('should destroy each chart manager independently', () => { + const plugin = createKeyboardNavPlugin( + createMockResources(), + createMockLiveAnnouncer(), + ); + const mock1 = createMockChart(); + const mock2 = createMockChart(); + + plugin.afterInit?.(mock1.chart, {}, {}); + plugin.afterInit?.(mock2.chart, {}, {}); + plugin.afterDestroy?.(mock1.chart, {}, {}); + + expect(mock1.removeEventListenerSpy).toHaveBeenCalledTimes(5); + expect(mock2.removeEventListenerSpy).not.toHaveBeenCalled(); + }); + }); +}); + +// #region Test Helpers +function createMockDataElement(): ActiveElement['element'] { + return {} as ActiveElement['element']; +} + +function createMockCanvas(): { + canvas: HTMLCanvasElement; + dispatchEvent: (event: Event) => void; + removeEventListenerSpy: jasmine.Spy; +} { + const listeners = new Map(); + const removeEventListenerSpy = jasmine.createSpy('removeEventListener'); + const canvas = { + style: { cursor: '' }, + tabIndex: 0, + addEventListener: (event: string, handler: EventListener): void => { + if (!listeners.has(event)) { + listeners.set(event, []); + } + listeners.get(event)?.push(handler); + }, + removeEventListener: removeEventListenerSpy, + dispatchEvent(event: Event): void { + const handlers = listeners.get(event.type) ?? []; + handlers.forEach((h) => h(event)); + }, + } as unknown as HTMLCanvasElement; + + return { + canvas, + dispatchEvent: canvas.dispatchEvent.bind(canvas), + removeEventListenerSpy, + }; +} + +interface MockChart { + chart: Chart; + canvas: HTMLCanvasElement; + dispatchEvent: (event: Event) => void; + tooltipSetActiveElements: jasmine.Spy; + chartUpdate: jasmine.Spy; + onClickSpy: jasmine.Spy; + removeEventListenerSpy: jasmine.Spy; +} + +function createMockChart( + options: { + datasets?: { data: unknown[]; label?: string; hidden?: boolean }[]; + labels?: string[]; + type?: string; + onClick?: jasmine.Spy; + } = {}, +): MockChart { + const datasets = options.datasets ?? [{ data: [1, 2, 3], label: 'Series A' }]; + const labels = options.labels ?? ['Cat 1', 'Cat 2', 'Cat 3']; + const type = options.type ?? 'bar'; + + const { canvas, dispatchEvent, removeEventListenerSpy } = createMockCanvas(); + const tooltipSetActiveElements = jasmine.createSpy('setActiveElements'); + const chartUpdate = jasmine.createSpy('update'); + const onClickSpy = options.onClick ?? jasmine.createSpy('onClick'); + + const getDatasetMeta = jasmine + .createSpy('getDatasetMeta') + .and.callFake((i: number) => ({ + hidden: datasets[i]?.hidden ?? false, + data: datasets[i]?.data.map(() => createMockDataElement()) ?? [], + })); + + const isDatasetVisible = jasmine + .createSpy('isDatasetVisible') + .and.callFake((i: number) => !datasets[i]?.hidden); + + const chart = { + canvas, + config: { + type, + options: { + indexAxis: 'x', + onClick: onClickSpy, + }, + }, + data: { datasets, labels }, + tooltip: { setActiveElements: tooltipSetActiveElements }, + update: chartUpdate, + getDatasetMeta, + isDatasetVisible, + } as unknown as Chart; + + return { + chart, + canvas, + dispatchEvent, + tooltipSetActiveElements, + chartUpdate, + onClickSpy, + removeEventListenerSpy, + }; +} + +function createMockResources(message = 'announced'): SkyLibResourcesService { + return { + getString: jasmine.createSpy('getString').and.returnValue(of(message)), + } as unknown as SkyLibResourcesService; +} + +function createMockLiveAnnouncer(): SkyLiveAnnouncerService { + return { + announce: jasmine.createSpy('announce'), + } as unknown as SkyLiveAnnouncerService; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.ts new file mode 100644 index 0000000000..ea7716b0c3 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.ts @@ -0,0 +1,380 @@ +import type { SkyLiveAnnouncerService } from '@skyux/core'; +import type { SkyLibResourcesService } from '@skyux/i18n'; + +import type { + ActiveElement, + Chart, + ChartEvent, + ChartType, + Plugin, +} from 'chart.js'; + +import { focusedElementsState } from '../plugin-state/focused-elements-state'; + +import { createNavigationStrategy } from './create-navigation-strategy'; +import type { + SkyKeyboardNavPluginOptions, + SkyValueLabelFn, +} from './keyboard-nav-plugin-options'; +import { ChartKeys, isActivationKey, isNavigationKey } from './keys'; +import type { FocusedElement, NavigationStrategy } from './navigation-strategy'; + +/** + * Plugin that adds keyboard navigation support to ChartJS charts. + * Enables users to navigate through data points using arrow keys and interact using Enter/Space. + * + * Follows the WAI-ARIA recommendation that pointer interaction must not steal keyboard focus: + * - **Focus is keyboard-owned.** Once a data point receives keyboard focus (via Tab or arrow keys), + * it persists across pointer hover events. Hovering another data point does not move or revoke focus. + * - **Click clears focus.** Clicking anywhere on the canvas ends keyboard navigation and removes the + * focus indicator, returning the chart to pointer-only interaction. + * - **Hover is independent and pointer-driven.** Moving the mouse over a data point applies hover + * styling and updates the tooltip without affecting keyboard focus. + * - **Both states can be visible simultaneously.** The focus indicator and hover indicator draw + * independently; two different data points can be highlighted at the same time. + * + * ## Keyboard Navigation + * + * ### Getting Started + * - **Tab** into the chart canvas to start navigation; a focus indicator appears on the first visible data point + * + * ### Navigation Keys + * - **Arrow Keys**: Navigate between data points and series (direction depends on chart type) + * - **Enter/Space**: Activate the focused element (triggers the chart's `onClick` handler) + * - **Escape**: Exit navigation and clear the focus indicator + * - **Tab**: Exit navigation and continue to the next focusable element + * - **Click**: Exit navigation and clear the focus indicator + * + * ### Visual Feedback + * - Focus indicator highlights the current data point (drawn by the indicator plugin) + * - Tooltip displays for the focused element + * + * ### Screen Reader Support + * - Current position and value are announced via a live region + */ +export function createKeyboardNavPlugin( + resources: SkyLibResourcesService, + liveAnnouncer: SkyLiveAnnouncerService, +): Plugin { + // Maintain a mapping of Chart instances to their corresponding keyboard managers. + // This allows the plugin to manage keyboard interactions for multiple charts on the same page. + const chartManagers = new Map(); + + return { + id: 'sky_keyboard_nav', + afterInit: (chart, args, options): void => { + const getValueLabel = options.valueLabel; + const manager = new ChartKeyboardManager( + chart, + resources, + liveAnnouncer, + getValueLabel, + ); + chartManagers.set(chart, manager); + }, + afterDestroy: (chart): void => { + const manager = chartManagers.get(chart); + + manager?.destroy(); + chartManagers.delete(chart); + focusedElementsState.delete(chart); + }, + }; +} + +/** + * Manages keyboard interactions for a single chart instance. + * Navigation logic is delegated to a {@link NavigationStrategy} selected + * at the start of each navigation session based on the chart type. + */ +class ChartKeyboardManager { + readonly #chart: Chart; + readonly #canvas: HTMLCanvasElement; + readonly #resources: SkyLibResourcesService; + readonly #liveAnnouncer: SkyLiveAnnouncerService; + readonly #getValueLabel: SkyValueLabelFn | undefined; + + readonly #boundKeyDownHandler: (e: KeyboardEvent) => void; + readonly #boundKeyUpHandler: (e: KeyboardEvent) => void; + readonly #boundFocusHandler: () => void; + readonly #boundBlurHandler: () => void; + readonly #boundMouseDownHandler: () => void; + + #focusedElement: FocusedElement | undefined = undefined; + #strategy: NavigationStrategy | undefined = undefined; + #isNavigating = false; + #focusFromMouse = false; + + constructor( + chart: Chart, + resources: SkyLibResourcesService, + liveAnnouncer: SkyLiveAnnouncerService, + getValueLabel: SkyValueLabelFn | undefined, + ) { + this.#chart = chart; + this.#canvas = chart.canvas; + this.#resources = resources; + this.#liveAnnouncer = liveAnnouncer; + this.#getValueLabel = getValueLabel; + + // Bind handlers + this.#boundKeyDownHandler = this.#handleKeyDown.bind(this); + this.#boundKeyUpHandler = this.#handleKeyUp.bind(this); + this.#boundFocusHandler = this.#handleFocus.bind(this); + this.#boundBlurHandler = this.#handleBlur.bind(this); + this.#boundMouseDownHandler = this.#handleMouseDown.bind(this); + + // Attach handlers to the canvas element + this.#canvas.addEventListener('keydown', this.#boundKeyDownHandler); + this.#canvas.addEventListener('keyup', this.#boundKeyUpHandler); + this.#canvas.addEventListener('focus', this.#boundFocusHandler); + this.#canvas.addEventListener('blur', this.#boundBlurHandler); + this.#canvas.addEventListener('mousedown', this.#boundMouseDownHandler); + } + + /** + * Cleans up after the chart keyboard manager is destroyed to prevent memory leaks. + */ + public destroy(): void { + this.#canvas.removeEventListener('keydown', this.#boundKeyDownHandler); + this.#canvas.removeEventListener('keyup', this.#boundKeyUpHandler); + this.#canvas.removeEventListener('focus', this.#boundFocusHandler); + this.#canvas.removeEventListener('blur', this.#boundBlurHandler); + this.#canvas.removeEventListener('mousedown', this.#boundMouseDownHandler); + } + + #handleKeyDown(event: KeyboardEvent): void { + // Handle Tab key — exit navigation and allow normal tab behavior. + if (event.key === ChartKeys.Tab) { + this.#endNavigation(); + return; + } + + if (!this.#isNavigating) { + // Start navigation on first arrow key press + if (isNavigationKey(event.key)) { + event.preventDefault(); + this.#startNavigation(); + this.#navigate(event.key); + } + return; + } + + if (isNavigationKey(event.key)) { + event.preventDefault(); + this.#navigate(event.key); + return; + } + + // Prevent activation keys running their default behavior on key down since we trigger them on key up. + if (isActivationKey(event.key)) { + event.preventDefault(); + return; + } + + if (event.key === ChartKeys.Escape) { + event.preventDefault(); + this.#endNavigation(); + } + } + + #handleKeyUp(event: KeyboardEvent): void { + if (this.#isNavigating && isActivationKey(event.key)) { + event.preventDefault(); + this.#handleActivation(); + } + } + + #handleMouseDown(): void { + this.#focusFromMouse = true; + + if (this.#isNavigating) { + this.#endNavigation(); + } + } + + #handleFocus(): void { + if (this.#focusFromMouse) { + this.#focusFromMouse = false; + return; + } + + if (!this.#isNavigating) { + this.#startNavigation(); + } + } + + #handleBlur(): void { + this.#endNavigation(); + } + + #startNavigation(): void { + this.#isNavigating = true; + this.#strategy = createNavigationStrategy(this.#chart); + this.#focusFirstElement(); + } + + #endNavigation(): void { + this.#isNavigating = false; + this.#focusedElement = undefined; + this.#strategy = undefined; + + // Clear shared focus state so the indicator plugin stops drawing. + focusedElementsState.set(this.#chart, []); + + // Dismiss the tooltip + this.#chart.tooltip?.setActiveElements([], { x: 0, y: 0 }); + + this.#chart.update('none'); + } + + /** + * Delegates arrow-key navigation to the active strategy and updates the chart. + */ + #navigate(key: string): void { + if (!this.#focusedElement || !this.#strategy) { + return; + } + + this.#focusedElement = this.#strategy.navigate(key, this.#focusedElement); + this.#updateChartWithFocus(); + } + + #handleActivation(): void { + if (!this.#focusedElement) { + return; + } + + // Trigger click event on the focused element + const element = this.#getActiveElement( + this.#focusedElement.datasetIndex, + this.#focusedElement.index, + ); + + if (element && this.#chart.config.options?.onClick) { + const chartEvent: ChartEvent = { + native: new Event('keyboard-activation'), + type: 'keydown', + x: 0, + y: 0, + }; + this.#chart.config.options.onClick(chartEvent, [element], this.#chart); + } + } + + #focusFirstElement(): void { + const datasets = this.#chart.data.datasets; + + for (let i = 0; i < datasets.length; i++) { + const meta = this.#chart.getDatasetMeta(i); + + if (!meta.hidden && datasets[i] && datasets[i].data.length > 0) { + this.#focusedElement = { datasetIndex: i, index: 0 }; + this.#updateChartWithFocus(); + return; + } + } + } + + /** + * Updates the tooltip and focus indicator state, then redraws the chart. + */ + #updateChartWithFocus(): void { + /* istanbul ignore next -- these checks are handled by the caller but we need the type scoping */ + if (!this.#focusedElement || !this.#strategy) { + return; + } + + const tooltipElements = this.#strategy.getTooltipElements( + this.#chart, + this.#focusedElement, + ); + + if (tooltipElements.length > 0) { + this.#chart.tooltip?.setActiveElements(tooltipElements, { x: 0, y: 0 }); + } + + this.#syncFocusedState(); + this.#announcePosition(); + this.#chart.update('none'); + } + + /** + * Writes the current focused element to the shared state so the focus indicator plugin can draw it. + */ + #syncFocusedState(): void { + /* istanbul ignore next -- these checks are handled by the caller but we need the type scoping */ + if (!this.#focusedElement) { + focusedElementsState.set(this.#chart, []); + return; + } + + const el = this.#getActiveElement( + this.#focusedElement.datasetIndex, + this.#focusedElement.index, + ); + focusedElementsState.set(this.#chart, el ? [el] : []); + } + + /** + * Announces the current focused element's position and value to screen readers. + */ + #announcePosition(): void { + /* istanbul ignore next -- these checks are handled by the caller but we need the type scoping */ + if (!this.#focusedElement || !this.#strategy) { + return; + } + + const description = this.#strategy.describeElement( + this.#chart, + this.#focusedElement, + ); + + // Override with consumer-formatted label when available. + if (this.#getValueLabel) { + description.value = this.#getValueLabel( + this.#focusedElement.datasetIndex, + this.#focusedElement.index, + ); + } + + if (description.totalSeries === 1) { + this.#resources + .getString( + 'chart.focus_element.single_series.description', + description.categoryLabel, + description.value, + description.index, + description.total, + ) + .subscribe((message) => this.#liveAnnouncer.announce(message)); + } else { + this.#resources + .getString( + 'chart.focus_element.multi_series.description', + description.seriesLabel, + description.seriesIndex, + description.totalSeries, + description.categoryLabel, + description.value, + description.index, + description.total, + ) + .subscribe((message) => this.#liveAnnouncer.announce(message)); + } + } + + #getActiveElement( + datasetIndex: number, + index: number, + ): ActiveElement | undefined { + const meta = this.#chart.getDatasetMeta(datasetIndex); + const dataElement = meta?.data[index]; + + if (!dataElement) { + return undefined; + } + + return { datasetIndex, index, element: dataElement }; + } +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keys.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keys.spec.ts new file mode 100644 index 0000000000..2e479f860d --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keys.spec.ts @@ -0,0 +1,39 @@ +import { ChartKeys, isActivationKey, isNavigationKey } from './keys'; + +describe('keys', () => { + describe('isActivationKey', () => { + it('should return true for Space', () => { + expect(isActivationKey(ChartKeys.Activation.Space)).toBeTrue(); + }); + + it('should return true for Enter', () => { + expect(isActivationKey(ChartKeys.Activation.Enter)).toBeTrue(); + }); + + it('should return false for non-activation keys', () => { + expect(isActivationKey('ArrowUp')).toBeFalse(); + }); + }); + + describe('isNavigationKey', () => { + it('should return true for ArrowUp', () => { + expect(isNavigationKey(ChartKeys.Navigation.ArrowUp)).toBeTrue(); + }); + + it('should return true for ArrowDown', () => { + expect(isNavigationKey(ChartKeys.Navigation.ArrowDown)).toBeTrue(); + }); + + it('should return true for ArrowLeft', () => { + expect(isNavigationKey(ChartKeys.Navigation.ArrowLeft)).toBeTrue(); + }); + + it('should return true for ArrowRight', () => { + expect(isNavigationKey(ChartKeys.Navigation.ArrowRight)).toBeTrue(); + }); + + it('should return false for non-navigation keys', () => { + expect(isNavigationKey('Enter')).toBeFalse(); + }); + }); +}); diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keys.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keys.ts new file mode 100644 index 0000000000..4bb087f8e1 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keys.ts @@ -0,0 +1,45 @@ +/** + * Defines the keys used for keyboard navigation within charts. + */ +export const ChartKeys = { + /** Exits navigation and moves focus to the next focusable element. */ + Tab: 'Tab', + + /** Exits navigation and clears the focus indicator. */ + Escape: 'Escape', + + /** Keys for activating a focused item */ + Activation: { + Space: ' ', + Enter: 'Enter', + }, + + /** Keys for navigating between data points and series within the chart. */ + Navigation: { + ArrowUp: 'ArrowUp', + ArrowDown: 'ArrowDown', + ArrowLeft: 'ArrowLeft', + ArrowRight: 'ArrowRight', + }, +} as const; + +// --- Activation keys --- +export type ActivationKey = + (typeof ChartKeys.Activation)[keyof typeof ChartKeys.Activation]; + +const activationKeySet = new Set(Object.values(ChartKeys.Activation)); + +export function isActivationKey(key: string): key is ActivationKey { + return activationKeySet.has(key as ActivationKey); +} + +// --- Navigation keys --- + +export type NavigationKey = + (typeof ChartKeys.Navigation)[keyof typeof ChartKeys.Navigation]; + +const arrowKeySet = new Set(Object.values(ChartKeys.Navigation)); + +export function isNavigationKey(key: string): key is NavigationKey { + return arrowKeySet.has(key as NavigationKey); +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/navigation-strategy.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/navigation-strategy.ts new file mode 100644 index 0000000000..e8268b0355 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/navigation-strategy.ts @@ -0,0 +1,43 @@ +import type { ActiveElement, Chart } from 'chart.js'; + +/** + * Represents the currently focused data point in the chart, identified by its dataset index and data index. + */ +export interface FocusedElement { + datasetIndex: number; + index: number; +} + +/** + * Structured description of a focused chart element for screen reader announcements. + */ +export interface ElementDescription { + /** The series label */ + seriesLabel: string; + /** The human readable series index */ + seriesIndex: number; + /** The total number of series in the chart */ + totalSeries: number; + /** The category label */ + categoryLabel: string; + /** The value of the data point */ + value: string; + /** The human readable data point index */ + index: number; + /** The total number of data points in the series */ + total: number; +} + +/** + * Strategy interface for chart-type-specific keyboard navigation behavior. + */ +export interface NavigationStrategy { + /** Computes the next focused element after an arrow key press. */ + navigate(key: string, current: FocusedElement): FocusedElement; + + /** Returns the active elements that should be shown in the tooltip for the given focused element. */ + getTooltipElements(chart: Chart, focused: FocusedElement): ActiveElement[]; + + /** Returns the structured description parts for a focused element. */ + describeElement(chart: Chart, focused: FocusedElement): ElementDescription; +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/radial-navigation-strategy.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/radial-navigation-strategy.spec.ts new file mode 100644 index 0000000000..b45e6eb5ea --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/radial-navigation-strategy.spec.ts @@ -0,0 +1,204 @@ +import type { ActiveElement, Chart } from 'chart.js'; + +import type { FocusedElement } from './navigation-strategy'; +import { RadialNavigationStrategy } from './radial-navigation-strategy'; + +describe('RadialNavigationStrategy', () => { + describe('navigate', () => { + it('should move to the next segment on ArrowRight', () => { + const chart = createMockChart(); + const strategy = new RadialNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowRight', current); + + expect(result).toEqual({ datasetIndex: 0, index: 1 }); + }); + + it('should move to the next segment on ArrowDown', () => { + const chart = createMockChart(); + const strategy = new RadialNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowDown', current); + + expect(result).toEqual({ datasetIndex: 0, index: 1 }); + }); + + it('should move to the previous segment on ArrowLeft', () => { + const chart = createMockChart(); + const strategy = new RadialNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 2 }; + + const result = strategy.navigate('ArrowLeft', current); + + expect(result).toEqual({ datasetIndex: 0, index: 1 }); + }); + + it('should move to the previous segment on ArrowUp', () => { + const chart = createMockChart(); + const strategy = new RadialNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 2 }; + + const result = strategy.navigate('ArrowUp', current); + + expect(result).toEqual({ datasetIndex: 0, index: 1 }); + }); + + it('should wrap forward from the last segment to the first on ArrowRight', () => { + const chart = createMockChart(); + const strategy = new RadialNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 3 }; + + const result = strategy.navigate('ArrowRight', current); + + expect(result).toEqual({ datasetIndex: 0, index: 0 }); + }); + + it('should wrap backward from the first segment to the last on ArrowLeft', () => { + const chart = createMockChart(); + const strategy = new RadialNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowLeft', current); + + expect(result).toEqual({ datasetIndex: 0, index: 3 }); + }); + + it('should return current when datasets array is empty (no datasets[0])', () => { + const chart = { + config: { type: 'doughnut', options: {} }, + data: { datasets: [], labels: [] }, + getDatasetMeta: jasmine + .createSpy('getDatasetMeta') + .and.returnValue({ data: [] }), + } as unknown as Chart; + const strategy = new RadialNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowRight', current); + + expect(result).toEqual(current); + }); + + it('should return current when the data array is empty', () => { + const chart = createMockChart({ data: [] }); + const strategy = new RadialNavigationStrategy(chart); + const current: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.navigate('ArrowRight', current); + + expect(result).toEqual(current); + }); + }); + + describe('getTooltipElements', () => { + it('should return the focused segment as the single tooltip element', () => { + const chart = createMockChart(); + const strategy = new RadialNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 2 }; + + const result = strategy.getTooltipElements(chart, focused); + + expect(result.length).toBe(1); + expect(result[0].datasetIndex).toBe(0); + expect(result[0].index).toBe(2); + }); + + it('should return empty array when the data element does not exist', () => { + const chart = createMockChart({ data: [] }); + const strategy = new RadialNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.getTooltipElements(chart, focused); + + expect(result.length).toBe(0); + }); + }); + + describe('describeElement', () => { + it('should describe the focused segment with correct values', () => { + const chart = createMockChart({ + data: [10, 20, 30, 40], + labels: ['Q1', 'Q2', 'Q3', 'Q4'], + label: 'Sales', + }); + const strategy = new RadialNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 1 }; + + const result = strategy.describeElement(chart, focused); + + expect(result.seriesLabel).toBe('Sales'); + expect(result.seriesIndex).toBe(1); + expect(result.totalSeries).toBe(1); + expect(result.categoryLabel).toBe('Q2'); + expect(result.value).toBe('20'); + expect(result.index).toBe(2); + expect(result.total).toBe(4); + }); + + it('should return empty strings and zero total when datasetIndex is out of bounds', () => { + const chart = createMockChart({ + data: [10, 20], + labels: ['X', 'Y'], + label: 'Data', + }); + const strategy = new RadialNavigationStrategy(chart); + // datasetIndex 99 is out of bounds — all ?? fallbacks should fire + const focused: FocusedElement = { datasetIndex: 99, index: 0 }; + + const result = strategy.describeElement(chart, focused); + + expect(result.seriesLabel).toBe(''); + expect(result.categoryLabel).toBe('X'); + expect(result.value).toBe(''); + expect(result.total).toBe(0); + }); + + it('should fall back to dataset label when segment label is missing', () => { + const chart = createMockChart({ + data: [5], + labels: [], + label: 'Total', + }); + const strategy = new RadialNavigationStrategy(chart); + const focused: FocusedElement = { datasetIndex: 0, index: 0 }; + + const result = strategy.describeElement(chart, focused); + + expect(result.categoryLabel).toBe('Total'); + }); + }); +}); + +// #region Test Helpers +function createMockDataElement(): ActiveElement['element'] { + return {} as ActiveElement['element']; +} + +function createMockChart( + options: { + data?: unknown[]; + label?: string; + labels?: string[]; + } = {}, +): Chart { + const data = options.data ?? [10, 20, 30, 40]; + const labels = options.labels ?? ['A', 'B', 'C', 'D']; + + const getDatasetMeta = jasmine + .createSpy('getDatasetMeta') + .and.callFake(() => ({ + data: data.map(() => createMockDataElement()), + })); + + return { + config: { type: 'doughnut', options: {} }, + data: { + datasets: [{ data, label: options.label ?? 'Dataset' }], + labels, + }, + getDatasetMeta, + } as unknown as Chart; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/radial-navigation-strategy.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/radial-navigation-strategy.ts new file mode 100644 index 0000000000..d95fe826eb --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/radial-navigation-strategy.ts @@ -0,0 +1,87 @@ +import type { ActiveElement, Chart } from 'chart.js'; + +import { ChartKeys, type NavigationKey } from './keys'; +import type { + ElementDescription, + FocusedElement, + NavigationStrategy, +} from './navigation-strategy'; + +/** + * Navigation strategy for radial charts (doughnut, pie). + * All arrow keys cycle through segments in a single ring: + * - Right/Down advance to the next segment (wrapping). + * - Left/Up move to the previous segment (wrapping). + * + * Tooltip shows only the focused segment. + */ +export class RadialNavigationStrategy implements NavigationStrategy { + readonly #chart: Chart; + + constructor(chart: Chart) { + this.#chart = chart; + } + + public navigate(key: NavigationKey, current: FocusedElement): FocusedElement { + const dataLength = this.#chart.data.datasets[0]?.data.length ?? 0; + + if (dataLength === 0) { + return current; + } + + let newIndex = current.index; + + if ( + key === ChartKeys.Navigation.ArrowRight || + key === ChartKeys.Navigation.ArrowDown + ) { + newIndex = (newIndex + 1) % dataLength; + } else if ( + key === ChartKeys.Navigation.ArrowLeft || + key === ChartKeys.Navigation.ArrowUp + ) { + newIndex = (newIndex - 1 + dataLength) % dataLength; + } + + return { datasetIndex: current.datasetIndex, index: newIndex }; + } + + public getTooltipElements( + chart: Chart, + focused: FocusedElement, + ): ActiveElement[] { + const meta = chart.getDatasetMeta(focused.datasetIndex); + const dataElement = meta?.data[focused.index]; + + if (!dataElement) { + return []; + } + + return [ + { + datasetIndex: focused.datasetIndex, + index: focused.index, + element: dataElement, + }, + ]; + } + + public describeElement( + chart: Chart, + focused: FocusedElement, + ): ElementDescription { + const dataset = chart.data.datasets[focused.datasetIndex]; + const datasetLabel = String(dataset?.label ?? ''); + const segmentLabel = chart.data.labels?.[focused.index] ?? datasetLabel; + + return { + seriesLabel: datasetLabel, + seriesIndex: focused.datasetIndex + 1, + totalSeries: chart.data.datasets.length, + categoryLabel: String(segmentLabel), + value: String(dataset?.data[focused.index] ?? ''), + index: focused.index + 1, + total: dataset?.data.length ?? 0, + }; + } +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/plugin-state/focused-elements-state.ts b/libs/components/charts/src/lib/modules/shared/plugins/plugin-state/focused-elements-state.ts new file mode 100644 index 0000000000..668b9146c6 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/plugin-state/focused-elements-state.ts @@ -0,0 +1,7 @@ +import { ActiveElement, Chart } from 'chart.js'; + +/** + * Shared state between the keyboard navigation plugin and the focus indicator plugin. + * The keyboard nav plugin writes focused elements here; the focus indicator plugin reads them to draw. + */ +export const focusedElementsState = new WeakMap(); diff --git a/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-options.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-options.spec.ts new file mode 100644 index 0000000000..315169032b --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-options.spec.ts @@ -0,0 +1,185 @@ +import { SkyChartStyles } from '../../services/chart-style.service'; + +import { getTooltipPluginOptions } from './tooltip-options'; + +describe('getTooltipPluginOptions', () => { + function setupTest(): { + styles: SkyChartStyles; + options: ReturnType; + } { + const styles = createMockStyles(); + return { styles, options: getTooltipPluginOptions(styles) }; + } + + describe('base options', () => { + it('should enable the tooltip', () => { + const { options } = setupTest(); + expect(options.enabled).toBe(true); + }); + + it('should position the tooltip at "average"', () => { + const { options } = setupTest(); + expect(options.position).toBe('average'); + }); + + it('should display colors', () => { + const { options } = setupTest(); + expect(options.displayColors).toBe(true); + }); + + it('should use point style', () => { + const { options } = setupTest(); + expect(options.usePointStyle).toBe(true); + }); + }); + + describe('interaction', () => { + it('should use "index" interaction mode', () => { + const { options } = setupTest(); + expect(options.mode).toBe('index'); + }); + + it('should not require intersection for tooltip trigger', () => { + const { options } = setupTest(); + expect(options.intersect).toBe(false); + }); + }); + + describe('typography styles', () => { + it('should apply title color and font from styles', () => { + const { styles, options } = setupTest(); + expect(options.titleColor).toBe(styles.tooltip.title.color); + expect(options.titleFont).toEqual({ + family: styles.fontFamily, + size: styles.tooltip.title.fontSize, + weight: styles.tooltip.title.fontWeight, + lineHeight: styles.tooltip.title.lineHeight, + }); + }); + + it('should apply body color and font from styles', () => { + const { styles, options } = setupTest(); + expect(options.bodyColor).toBe(styles.tooltip.body.color); + expect(options.bodyFont).toEqual({ + family: styles.fontFamily, + size: styles.tooltip.body.fontSize, + weight: styles.tooltip.body.fontWeight, + lineHeight: styles.tooltip.body.lineHeight, + }); + }); + + it('should apply footer color and font from styles', () => { + const { styles, options } = setupTest(); + expect(options.footerColor).toBe(styles.tooltip.footer.color); + expect(options.footerFont).toEqual({ + family: styles.fontFamily, + size: styles.tooltip.footer.fontSize, + weight: styles.tooltip.footer.fontWeight, + lineHeight: styles.tooltip.footer.lineHeight, + }); + }); + }); + + describe('sizing and spacing styles', () => { + it('should apply padding from styles', () => { + const { styles, options } = setupTest(); + expect(options.padding).toEqual(styles.tooltip.padding); + }); + + it('should apply corner radius from styles', () => { + const { styles, options } = setupTest(); + expect(options.cornerRadius).toBe(styles.tooltip.cornerRadius); + }); + + it('should apply border width from styles', () => { + const { styles, options } = setupTest(); + expect(options.borderWidth).toBe(styles.tooltip.borderWidth); + }); + + it('should apply caret size and padding from styles', () => { + const { styles, options } = setupTest(); + expect(options.caretSize).toBe(styles.tooltip.caret.size); + expect(options.caretPadding).toBe(styles.tooltip.caret.padding); + }); + + it('should apply box dimensions and padding from styles', () => { + const { styles, options } = setupTest(); + expect(options.boxHeight).toBe(styles.tooltip.box.height); + expect(options.boxWidth).toBe(styles.tooltip.box.width); + expect(options.boxPadding).toBe(styles.tooltip.box.padding); + }); + + it('should set multiKeyBackground to transparent', () => { + const { options } = setupTest(); + expect(options.multiKeyBackground).toBe('transparent'); + }); + + it('should apply title margin bottom from styles', () => { + const { styles, options } = setupTest(); + expect(options.titleMarginBottom).toBe(styles.tooltip.title.marginBottom); + }); + + it('should apply body spacing from styles', () => { + const { styles, options } = setupTest(); + expect(options.bodySpacing).toBe(styles.tooltip.body.bodySpacing); + }); + + it('should apply footer margin top from styles', () => { + const { styles, options } = setupTest(); + expect(options.footerMarginTop).toBe(styles.tooltip.footer.marginTop); + }); + }); + + describe('element colors', () => { + it('should apply background color from styles', () => { + const { styles, options } = setupTest(); + expect(options.backgroundColor).toBe(styles.tooltip.backgroundColor); + }); + + it('should apply border color from styles', () => { + const { styles, options } = setupTest(); + expect(options.borderColor).toBe(styles.tooltip.borderColor); + }); + }); +}); + +// #region Test Helpers +function createMockStyles(): SkyChartStyles { + const styles: Partial = { + fontFamily: 'Test Font, sans-serif', + tooltip: { + backgroundColor: '#ffffff', + borderColor: '#cccccc', + borderWidth: 1, + cornerRadius: 4, + padding: { top: 8, right: 8, bottom: 8, left: 8 }, + shadow: { color: 'rgba(0,0,0,0.15)', blur: 4, offsetX: 1, offsetY: 2 }, + caret: { size: 8, padding: 4 }, + box: { height: 12, width: 12, padding: 4 }, + title: { + fontSize: 15, + fontWeight: 600, + lineHeight: '20px', + color: '#212327', + marginBottom: 5, + }, + body: { + fontSize: 15, + fontWeight: 400, + lineHeight: '20px', + color: '#444444', + bodySpacing: 2, + }, + footer: { + fontSize: 13, + fontWeight: 400, + lineHeight: '18px', + color: '#666666', + marginTop: 5, + }, + }, + }; + + return styles as unknown as SkyChartStyles; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-options.ts b/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-options.ts new file mode 100644 index 0000000000..f5f2e4e4a7 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-options.ts @@ -0,0 +1,98 @@ +import { TooltipOptions } from 'chart.js'; + +import { SkyChartStyles } from '../../services/chart-style.service'; +import { DeepPartial } from '../../types/deep-partial-type'; + +/** + * Get default tooltip options for Chart.JS Tooltips + */ +export function getTooltipPluginOptions( + styles: SkyChartStyles, +): DeepPartial { + // Default tooltip interaction options + // - These defaults allow for a more forgiving UX as the tooltip will appear when the user is near a data point, even when not directly intersecting it. + // - This is especially helpful for touch devices or when dealing with small data points. + const interaction: DeepPartial = { + mode: 'index', + intersect: false, + }; + + const options: DeepPartial = { + enabled: true, + position: 'average', + displayColors: true, + usePointStyle: true, + ...interaction, + ...getTypographyStyles(styles), + ...getSizingAndSpacingStyles(styles), + ...getElementColors(styles), + }; + + return options; +} + +function getTypographyStyles( + styles: SkyChartStyles, +): DeepPartial { + const title: DeepPartial = { + titleColor: styles.tooltip.title.color, + titleFont: { + family: styles.fontFamily, + size: styles.tooltip.title.fontSize, + weight: styles.tooltip.title.fontWeight, + lineHeight: styles.tooltip.title.lineHeight, + }, + }; + + const body: DeepPartial = { + bodyColor: styles.tooltip.body.color, + bodyFont: { + family: styles.fontFamily, + size: styles.tooltip.body.fontSize, + weight: styles.tooltip.body.fontWeight, + lineHeight: styles.tooltip.body.lineHeight, + }, + }; + + const footer: DeepPartial = { + footerColor: styles.tooltip.footer.color, + footerFont: { + family: styles.fontFamily, + size: styles.tooltip.footer.fontSize, + weight: styles.tooltip.footer.fontWeight, + lineHeight: styles.tooltip.footer.lineHeight, + }, + }; + + return { ...title, ...footer, ...body }; +} + +function getSizingAndSpacingStyles( + styles: SkyChartStyles, +): DeepPartial { + return { + // Container + padding: styles.tooltip.padding, + cornerRadius: styles.tooltip.cornerRadius, + borderWidth: styles.tooltip.borderWidth, + // Caret + caretSize: styles.tooltip.caret.size, + caretPadding: styles.tooltip.caret.padding, + // Icon + boxHeight: styles.tooltip.box.height, + boxWidth: styles.tooltip.box.width, + boxPadding: styles.tooltip.box.padding, + multiKeyBackground: 'transparent', // Removes the colored box behind the icon + // Text Spacing + titleMarginBottom: styles.tooltip.title.marginBottom, + bodySpacing: styles.tooltip.body.bodySpacing, + footerMarginTop: styles.tooltip.footer.marginTop, + }; +} + +function getElementColors(styles: SkyChartStyles): DeepPartial { + return { + backgroundColor: styles.tooltip.backgroundColor, + borderColor: styles.tooltip.borderColor, + }; +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-shadow-plugin.spec.ts b/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-shadow-plugin.spec.ts new file mode 100644 index 0000000000..9f9830afe4 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-shadow-plugin.spec.ts @@ -0,0 +1,243 @@ +import { Chart, type ChartType, Plugin, type TooltipModel } from 'chart.js'; + +import { + SkyChartStyleService, + SkyChartStyles, +} from '../../services/chart-style.service'; + +import { createTooltipShadowPlugin } from './tooltip-shadow-plugin'; + +describe('createTooltipShadowPlugin', () => { + function setupTest( + tooltipStyleOverrides: Partial = {}, + ): { + plugin: Plugin; + styleService: SkyChartStyleService; + } { + const styleService = createMockStyleService(tooltipStyleOverrides); + const plugin = createTooltipShadowPlugin(styleService); + return { plugin, styleService }; + } + + describe('plugin identity', () => { + it('should have the correct plugin id', () => { + const { plugin } = setupTest(); + expect(plugin.id).toBe('sky_tooltip_shadow'); + }); + }); + + describe('beforeTooltipDraw', () => { + it('should not draw when tooltip is not present', () => { + const { plugin } = setupTest(); + const { chart, ctx } = createMockChart(null); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(ctx.save).not.toHaveBeenCalled(); + }); + + it('should not draw when tooltip opacity is 0', () => { + const { plugin } = setupTest(); + const { chart, ctx } = createMockChart({ + opacity: 0, + x: 10, + y: 20, + width: 100, + height: 50, + }); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(ctx.save).not.toHaveBeenCalled(); + }); + + it('should save and restore the canvas context when drawing', () => { + const { plugin } = setupTest(); + const { chart, ctx } = createMockChart({ + opacity: 1, + x: 10, + y: 20, + width: 100, + height: 50, + }); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(ctx.save).toHaveBeenCalled(); + expect(ctx.restore).toHaveBeenCalled(); + }); + + it('should set composite operation to "destination-over"', () => { + const { plugin } = setupTest(); + const { chart, ctx } = createMockChart({ + opacity: 1, + x: 10, + y: 20, + width: 100, + height: 50, + }); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(ctx.globalCompositeOperation).toBe('destination-over'); + }); + + it('should apply the tooltip background color to fillStyle', () => { + const backgroundColor = '#f0f0f0'; + const { plugin } = setupTest({ backgroundColor }); + const { chart, ctx } = createMockChart({ + opacity: 1, + x: 0, + y: 0, + width: 100, + height: 50, + }); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(ctx.fillStyle).toBe(backgroundColor); + }); + + it('should apply shadow styles from the style service', () => { + const shadow = { + color: 'rgba(10, 20, 30, 0.6)', + blur: 8, + offsetX: 2, + offsetY: 4, + }; + const { plugin } = setupTest({ shadow }); + const { chart, ctx } = createMockChart({ + opacity: 1, + x: 0, + y: 0, + width: 100, + height: 50, + }); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(ctx.shadowColor).toBe(shadow.color); + expect(ctx.shadowBlur).toBe(shadow.blur); + expect(ctx.shadowOffsetX).toBe(shadow.offsetX); + expect(ctx.shadowOffsetY).toBe(shadow.offsetY); + }); + + it('should call roundRect with tooltip bounds and corner radius', () => { + const cornerRadius = 6; + const { plugin } = setupTest({ cornerRadius }); + const { chart, ctx } = createMockChart({ + opacity: 1, + x: 15, + y: 25, + width: 120, + height: 60, + }); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(ctx.roundRect).toHaveBeenCalledWith(15, 25, 120, 60, cornerRadius); + }); + + it('should call beginPath and fill when drawing the shadow', () => { + const { plugin } = setupTest(); + const { chart, ctx } = createMockChart({ + opacity: 1, + x: 0, + y: 0, + width: 100, + height: 50, + }); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(ctx.beginPath).toHaveBeenCalled(); + expect(ctx.fill).toHaveBeenCalled(); + }); + + it('should read styles from the service on every draw call', () => { + const { plugin, styleService } = setupTest(); + const { chart } = createMockChart({ + opacity: 1, + x: 0, + y: 0, + width: 100, + height: 50, + }); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + plugin.beforeTooltipDraw?.(chart, beforeTooltipDrawArgs, {}); + + expect(styleService.styles).toHaveBeenCalledTimes(2); + }); + }); +}); + +// #region Test Helpers +const beforeTooltipDrawArgs = { + tooltip: {} as TooltipModel, + cancelable: true, +} as const; + +interface MockCtx { + save: jasmine.Spy; + restore: jasmine.Spy; + beginPath: jasmine.Spy; + fill: jasmine.Spy; + roundRect: jasmine.Spy; + globalCompositeOperation: string; + fillStyle: string; + shadowColor: string; + shadowBlur: number; + shadowOffsetX: number; + shadowOffsetY: number; +} + +function createMockChart( + tooltip: { + opacity: number; + x: number; + y: number; + width: number; + height: number; + } | null, +): { chart: Chart; ctx: MockCtx } { + const ctx: MockCtx = { + save: jasmine.createSpy('save'), + restore: jasmine.createSpy('restore'), + beginPath: jasmine.createSpy('beginPath'), + fill: jasmine.createSpy('fill'), + roundRect: jasmine.createSpy('roundRect'), + globalCompositeOperation: '', + fillStyle: '', + shadowColor: '', + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + }; + + return { + chart: { ctx, tooltip } as unknown as Chart, + ctx, + }; +} + +function createMockStyleService( + tooltipOverrides: Partial = {}, +): SkyChartStyleService { + const defaultTooltip: Pick< + SkyChartStyles['tooltip'], + 'backgroundColor' | 'cornerRadius' | 'shadow' + > = { + backgroundColor: '#ffffff', + cornerRadius: 4, + shadow: { color: 'rgba(0,0,0,0.15)', blur: 4, offsetX: 1, offsetY: 2 }, + }; + + return { + styles: jasmine.createSpy('styles').and.returnValue({ + tooltip: { ...defaultTooltip, ...tooltipOverrides }, + }), + } as unknown as SkyChartStyleService; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-shadow-plugin.ts b/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-shadow-plugin.ts new file mode 100644 index 0000000000..af71d01395 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-shadow-plugin.ts @@ -0,0 +1,47 @@ +import { Plugin } from 'chart.js'; + +import { SkyChartStyleService } from '../../services/chart-style.service'; + +/** + * Plugin to add box shadow and accent border to tooltips + * @returns + */ +export function createTooltipShadowPlugin( + styleService: SkyChartStyleService, +): Plugin { + const plugins: Plugin = { + id: 'sky_tooltip_shadow', + beforeTooltipDraw: (chart) => { + const tooltip = chart.tooltip; + if (!tooltip || tooltip.opacity === 0) { + return; + } + + // Get styles at runtime to ensure colors are up to date with current theme + const styles = styleService.styles(); + + const ctx = chart.ctx; + const { x, y, width, height } = tooltip; + const borderRadius = styles.tooltip.cornerRadius; + + ctx.save(); + + // Temporarily disable clipping to allow shadow to extend beyond tooltip bounds + ctx.globalCompositeOperation = 'destination-over'; + + ctx.fillStyle = styles.tooltip.backgroundColor; + ctx.shadowColor = styles.tooltip.shadow.color; + ctx.shadowBlur = styles.tooltip.shadow.blur; + ctx.shadowOffsetX = styles.tooltip.shadow.offsetX; + ctx.shadowOffsetY = styles.tooltip.shadow.offsetY; + + ctx.beginPath(); + ctx.roundRect(x, y, width, height, borderRadius); + ctx.fill(); + + ctx.restore(); + }, + }; + + return plugins; +} diff --git a/libs/components/charts/src/lib/modules/shared/scale-mapping.ts b/libs/components/charts/src/lib/modules/shared/scale-mapping.ts new file mode 100644 index 0000000000..9fac983a55 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/scale-mapping.ts @@ -0,0 +1,201 @@ +import type { CartesianScaleOptions, ScaleOptionsByType } from 'chart.js'; + +import { SkyChartStyles } from './services/chart-style.service'; +import { + type SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from './types/axis-types'; +import type { DeepPartial } from './types/deep-partial-type'; + +/** + * Builds the base configuration for a chart scale. + * @param styles + * @returns + */ +function buildBaseScale( + styles: SkyChartStyles, +): DeepPartial { + return { + grid: { + display: true, + drawTicks: true, + color: styles.axis.grid.color, + tickColor: styles.axis.grid.color, + tickLength: styles.axis.ticks.measureLength, + }, + border: { + display: true, + color: styles.axis.border.color, + }, + ticks: { + color: styles.axis.ticks.color, + font: { + size: styles.axis.ticks.fontSize, + family: styles.fontFamily, + weight: styles.axis.ticks.fontWeight, + }, + major: { enabled: true }, + }, + title: { + display: true, + font: { + size: styles.axis.title.fontSize, + family: styles.fontFamily, + }, + color: styles.axis.title.color, + padding: { + top: styles.axis.title.paddingTop, + bottom: styles.axis.title.paddingBottom, + }, + }, + }; +} + +/** + * Builds the configuration for a category scale based on the provided parameters. + * @param params + * @returns + */ +export function buildCategoryScale(params: { + styles: SkyChartStyles; + stacked: boolean | undefined; + categoryAxis?: SkyChartCategoryAxisConfig; +}): DeepPartial> { + const { styles, stacked, categoryAxis } = params; + const base = buildBaseScale(styles); + + const categoryScale: DeepPartial> = { + type: 'category', + stacked: stacked ?? false, + grid: base.grid, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + }, + title: { + ...base.title, + display: !!categoryAxis?.labelText, + text: categoryAxis?.labelText, + }, + }; + + return categoryScale; +} + +/** + * Builds a linear measure scale configuration. + * @param params + * @returns Linear Scale Options + */ +export function buildLinearMeasureScale(params: { + styles: SkyChartStyles; + stacked: boolean; + measureAxis?: SkyChartMeasureAxisConfig; +}): DeepPartial> { + const { styles, stacked, measureAxis } = params; + const base = buildBaseScale(params.styles); + + return { + type: 'linear', + stacked: stacked ?? false, + grid: base.grid, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + }, + title: { + ...base.title, + display: !!measureAxis?.labelText, + text: measureAxis?.labelText, + }, + ...getScaleMinMaxMapping(measureAxis), + }; +} + +/** + * Builds a logarithmic measure scale configuration. + * @param params + * @returns Logarithmic Scale Options + */ +export function buildLogarithmicMeasureScale(params: { + styles: SkyChartStyles; + stacked: boolean; + measureAxis?: SkyChartMeasureAxisConfig; +}): DeepPartial> { + const { styles, stacked, measureAxis } = params; + const base = buildBaseScale(params.styles); + + return { + type: 'logarithmic', + stacked: stacked ?? false, + grid: { + ...base.grid, + // Improve readability by filtering away Grid Lines that are not powers of 10 + lineWidth: (ctx): number => { + const tick = ctx.tick; + return !tick?.label ? 0 : styles.axis.grid.width; + }, + }, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + // Improve readability by filtering away Ticks that are not powers of 10 + callback: createLogTickFilter, + }, + title: { + ...base.title, + display: !!measureAxis?.labelText, + text: measureAxis?.labelText ?? '', + }, + ...getScaleMinMaxMapping(measureAxis), + }; +} + +/** + * This function maps the min and max values for a chart scale based on the provided configuration. + * It determines whether to allow overflow for the minimum and maximum values and sets the appropriate properties for the chart scale. + * @param config + * @returns + */ +function getScaleMinMaxMapping(config: SkyChartMeasureAxisConfig | undefined): { + min?: number; + max?: number; + suggestedMin?: number; + suggestedMax?: number; +} { + if (!config) { + return {}; + } + + const { min, max, allowMinOverflow, allowMaxOverflow } = config; + + return { + min: allowMinOverflow ? undefined : min, + max: allowMaxOverflow ? undefined : max, + suggestedMin: allowMinOverflow ? min : undefined, + suggestedMax: allowMaxOverflow ? max : undefined, + }; +} + +/** + * Creates a tick filter function for logarithmic axes that only shows ticks at powers of 10. + * @param value The tick value + * @param formatter An optional formatter function to format the tick label + * @returns The formatted tick label if it's a power of 10, otherwise an empty string for no tick + */ +function createLogTickFilter(value: string | number): string { + const noTick = ''; + const numeric = Number(value); + + // Show only powers of 10 + const isPowerOf10 = numeric > 0 && Math.log10(numeric) % 1 === 0; + + if (!isPowerOf10) { + return noTick; + } + + return numeric.toLocaleString(); +} diff --git a/libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.spec.ts b/libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.spec.ts new file mode 100644 index 0000000000..ac1ef78aa6 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.spec.ts @@ -0,0 +1,310 @@ +import { TestBed } from '@angular/core/testing'; +import { SkyAppWindowRef } from '@skyux/core'; + +import { SkyChartCssUtilsService } from './chart-css-utils.service'; + +describe('SkyChartCssUtilsService', () => { + describe('css()', () => { + describe('when body has the value', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef({ + bodyValues: { '--my-color': 'red' }, + }), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should return the body CSS variable value', () => { + expect(service.css('--my-color')).toBe('red'); + }); + }); + + describe('when body has no value but documentElement does', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef({ + docElValues: { '--my-color': 'blue' }, + }), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should fall back to the documentElement value', () => { + expect(service.css('--my-color')).toBe('blue'); + }); + }); + + describe('when neither body nor documentElement has a value', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef(), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should return the cssFallback', () => { + expect(service.css('--unknown', 'fallback-value')).toBe( + 'fallback-value', + ); + }); + + it('should return empty string when no value and no fallback', () => { + expect(service.css('--unknown')).toBe(''); + }); + }); + }); + + describe('cssNumber()', () => { + describe('with unitless or pixel values', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef({ + bodyValues: { '--my-num': '400', '--my-size': '16px' }, + }), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should return a plain number directly (unitless)', () => { + expect(service.cssNumber('--my-num')).toBe(400); + }); + + it('should parse a pixel value directly', () => { + expect(service.cssNumber('--my-size')).toBe(16); + }); + }); + + describe('with rem/other units', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef({ + bodyValues: { '--my-rem': '1rem' }, + measureValues: { width: '16px' }, + }), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should use cssMeasured and return the computed pixel value', () => { + expect(service.cssNumber('--my-rem')).toBe(16); + }); + }); + }); + + describe('cssMeasured()', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef({ + bodyValues: { '--my-var': '24px' }, + measureValues: { width: '24px' }, + }), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should return the computed value of the CSS property', () => { + expect(service.cssMeasured('--my-var', undefined, 'width')).toBe('24px'); + }); + + it('should use width as the default cssProperty', () => { + expect(service.cssMeasured('--my-var')).toBe('24px'); + }); + }); + + describe('remToPx()', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef(), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should convert a rem number to pixels using root font size', () => { + const rootFontSize = Number.parseFloat( + getComputedStyle(document.documentElement).fontSize, + ); + expect(service.remToPx(1.5)).toBe(1.5 * rootFontSize); + }); + + it('should convert a rem string to pixels', () => { + const rootFontSize = Number.parseFloat( + getComputedStyle(document.documentElement).fontSize, + ); + expect(service.remToPx('2rem')).toBe(2 * rootFontSize); + }); + }); + + describe('extractShadowColor()', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef(), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should extract rgba color from shadow value', () => { + expect(service.extractShadowColor('0 2px 4px rgba(0, 0, 0, 0.5)')).toBe( + 'rgba(0, 0, 0, 0.5)', + ); + }); + + it('should extract rgb color from shadow value', () => { + expect(service.extractShadowColor('0 2px 4px rgb(10, 20, 30)')).toBe( + 'rgb(10, 20, 30)', + ); + }); + + it('should extract hex color from shadow value', () => { + expect(service.extractShadowColor('0 2px 4px #abc123')).toBe('#abc'); + }); + + it('should extract 3-digit hex color from shadow value', () => { + expect(service.extractShadowColor('0 2px 4px #abc')).toBe('#abc'); + }); + + it('should return null when no color is found', () => { + expect(service.extractShadowColor('0 2px 4px none')).toBeNull(); + }); + }); + + describe('colorToRgbaWithAlpha()', () => { + let service: SkyChartCssUtilsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + SkyChartCssUtilsService, + { + provide: SkyAppWindowRef, + useValue: createMockWindowRef(), + }, + ], + }); + service = TestBed.inject(SkyChartCssUtilsService); + }); + + it('should convert rgb color to rgba with specified alpha', () => { + expect(service.colorToRgbaWithAlpha('rgb(10, 20, 30)', 0.5)).toBe( + 'rgba(10, 20, 30, 0.5)', + ); + }); + + it('should convert rgba color to rgba with new alpha', () => { + expect(service.colorToRgbaWithAlpha('rgba(10, 20, 30, 0.8)', 0.3)).toBe( + 'rgba(10, 20, 30, 0.3)', + ); + }); + + it('should convert 6-digit hex color to rgba', () => { + expect(service.colorToRgbaWithAlpha('#0a141e', 0.5)).toBe( + 'rgba(10, 20, 30, 0.5)', + ); + }); + + it('should convert 3-digit hex color to rgba', () => { + expect(service.colorToRgbaWithAlpha('#fff', 1)).toBe( + 'rgba(255, 255, 255, 1)', + ); + }); + + it('should return null for unrecognized color formats', () => { + expect(service.colorToRgbaWithAlpha('not-a-color', 0.5)).toBeNull(); + }); + }); +}); + +// #region Test Helpers +function createMockWindowRef( + options: { + bodyValues?: Record; + docElValues?: Record; + measureValues?: Record; + } = {}, +): { nativeWindow: unknown } { + const { bodyValues = {}, docElValues = {}, measureValues = {} } = options; + return { + nativeWindow: { + getComputedStyle: (el: Element) => { + if (el === document.body) { + return { + getPropertyValue: (prop: string) => bodyValues[prop] ?? '', + }; + } + if (el === document.documentElement) { + return { + getPropertyValue: (prop: string) => docElValues[prop] ?? '', + }; + } + return { + getPropertyValue: (prop: string) => measureValues[prop] ?? '', + }; + }, + }, + }; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.ts b/libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.ts new file mode 100644 index 0000000000..455959465a --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.ts @@ -0,0 +1,182 @@ +import { DOCUMENT, Injectable, inject } from '@angular/core'; +import { SkyAppWindowRef } from '@skyux/core'; + +/** + * Utility service for CSS calculations and property resolution. + * @remarks Chart.js requires pixel values + */ +@Injectable({ providedIn: 'root' }) +export class SkyChartCssUtilsService { + readonly #skyAppWindowRef = inject(SkyAppWindowRef); + readonly #document = inject(DOCUMENT); + + /** + * A hidden element used for measuring CSS values that require rendering (e.g. calc(), rem, ect values). + * This element is reused for measurements to avoid unnecessary DOM bloat. + */ + readonly #measureElement: HTMLDivElement; + + constructor() { + this.#measureElement = this.#document.createElement('div'); + this.#measureElement.style.position = 'absolute'; + this.#measureElement.style.visibility = 'hidden'; + this.#measureElement.style.pointerEvents = 'none'; + } + + /** + * Resolve a CSS custom property value with an optional fallback + * @see https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/var + * @param varName The CSS variable name to resolve + * @param cssFallback The fallback value to use if the CSS variable is not found. This should ideally be in the same format as the expected CSS value (e.g. "16px" for width/height values). + * @returns The resolved CSS variable value, or the fallback if not found. Returns empty string if neither is found. + */ + public css(varName: `--${string}`, cssFallback?: string): string { + // Try to get from body first (where theme classes are typically applied) + let value = this.#skyAppWindowRef.nativeWindow + .getComputedStyle(this.#document.body) + .getPropertyValue(varName) + .trim(); + + // Fallback to document element + if (!value) { + value = this.#skyAppWindowRef.nativeWindow + .getComputedStyle(this.#document.documentElement) + .getPropertyValue(varName) + .trim(); + } + + // Final fallback to provided fallback value or empty string + value = value || cssFallback || ''; + + return value; + } + + /** + * Resolves a CSS property that is expected to be a numeric value (e.g. border width, font weight) and returns it as a number. + * @remarks This method will parse the numeric portion of the value, so it can handle values like "1px" or "400". If the value cannot be parsed as a number, it will return NaN. + * @param varName The CSS variable name to resolve + * @param cssFallback The fallback value to use if the CSS variable is not found. This should ideally be in the same format as the expected CSS value (e.g. "16px" for width/height values). + * @param cssProperty The CSS property to apply the value to for measurement (default is 'font-size'). This is only needed if the CSS value uses units (e.g. "1px") and needs to be measured to resolve to a pixel value. + * @returns The numeric value of the CSS property, or NaN if it cannot be parsed as a number + */ + public cssNumber( + varName: `--${string}`, + cssFallback?: string, + cssProperty = 'width', + ): number { + const value = this.css(varName, cssFallback); + const parsed = Number.parseFloat(value); + + // Unit-less or pixel-based numbers can be returned immediately + if (String(parsed) === value || value.endsWith('px')) { + return parsed; + } + + // For other units (e.g. rem, em, etc), we need to measure the computed pixel value + const measured = this.cssMeasured(varName, cssFallback, cssProperty); + return Number.parseFloat(measured); + } + + /** + * Resolves a CSS property that may involve calculations (e.g. using calc(), rem, ect) and returns the computed value as a string (e.g. "16px"). + * @remarks This method creates a temporary element, applies the CSS value to the requested property, and then reads the computed value. + * @param varName The CSS variable name to resolve + * @param cssFallback The fallback value to use if the CSS variable is not found. This should ideally be in the same format as the expected CSS value (e.g. "16px" for width/height values). + * @param cssProperty The CSS property to apply the value to for measurement (default is 'width') + * @returns The measured value as a string (e.g. "16px") + */ + public cssMeasured( + varName: `--${string}`, + cssFallback?: string, + cssProperty = 'width', + ): string { + const cssValue = this.css(varName, cssFallback); + + const el = this.#measureElement; + el.style.setProperty(cssProperty, cssValue); + this.#document.body.appendChild(el); + + try { + return this.#skyAppWindowRef.nativeWindow + .getComputedStyle(el) + .getPropertyValue(cssProperty) + .trim(); + } finally { + el.style.removeProperty(cssProperty); + el.remove(); + } + } + + /** + * Converts a rem value to pixels based on the root font size. + * @param rem The rem value to convert (e.g. "1.5rem" or 1.5) + * @returns The equivalent pixel value as a number (e.g. 24) + */ + public remToPx(rem: string | number): number { + const remNumber = typeof rem === 'number' ? rem : Number.parseFloat(rem); + const rootFontSize = this.#getRootFontSize(); + return remNumber * rootFontSize; + } + + /** + * Extracts the color value from a CSS shadow property. + * @param shadowValue The shadow CSS value + * @returns The extracted color (rgb/rgba or hex), or null if not found + */ + public extractShadowColor(shadowValue: string): string | null { + const rgbaMatch = shadowValue.match( + /rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)/, + ); + if (rgbaMatch) { + return rgbaMatch[0]; + } + + const hexMatch = shadowValue.match(/#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})/); + if (hexMatch) { + return hexMatch[0]; + } + + return null; + } + + /** + * Converts a color value to rgba format with the specified alpha value. + * @param color The color value (rgb, rgba, or hex) + * @param alpha The alpha value to apply (0-1) + * @returns The rgba color string, or null if color format is not recognized + */ + public colorToRgbaWithAlpha(color: string, alpha: number): string | null { + const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (rgbMatch) { + return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`; + } + + const hexMatch = color.match(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/); + if (hexMatch) { + const hex = + hexMatch[1].length === 3 + ? hexMatch[1] + .split('') + .map((char) => char + char) + .join('') + : hexMatch[1]; + const r = Number.parseInt(hex.slice(0, 2), 16); + const g = Number.parseInt(hex.slice(2, 4), 16); + const b = Number.parseInt(hex.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + + return null; + } + + /** + * Get the root font size in pixels + * @returns A number representing the root font-size in pixels + */ + #getRootFontSize(): number { + const element = this.#document.documentElement; + const rootFontSize = Number.parseFloat(getComputedStyle(element).fontSize); + + return rootFontSize; + } +} diff --git a/libs/components/charts/src/lib/modules/shared/services/chart-registry.service.ts b/libs/components/charts/src/lib/modules/shared/services/chart-registry.service.ts new file mode 100644 index 0000000000..a51fc5e433 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/chart-registry.service.ts @@ -0,0 +1,37 @@ +import { Signal } from '@angular/core'; + +import { SkyChartDataPoint } from '../types/chart-data-point'; +import { SkyChartSeries } from '../types/chart-series'; + +/** + * The interface for a chart registry service, which is responsible for managing the series and data points in a chart. + */ +export interface SkyChartRegistry { + readonly series: Signal[]>; + + /** + * Updates or inserts a series in the chart. + * @param series The series to upsert. + */ + upsertSeries(series: SkyChartSeries): void; + + /** + * Removes a series from the chart by its Series Id + * @param seriesId The ID of the series to remove. + */ + removeSeries(seriesId: number): void; + + /** + * Updates or inserts a data point in a series. + * @param seriesId The ID of the series to which the data point belongs. + * @param point The data point to upsert. + */ + upsertPoint(seriesId: number, point: TData): void; + + /** + * Removes a data point from a series by its point ID. + * @param seriesId The ID of the series from which to remove the data point. + * @param pointId The unique identifier of the data point to remove. + */ + removePoint(seriesId: number, pointId: number): void; +} diff --git a/libs/components/charts/src/lib/modules/shared/services/chart-style.service.spec.ts b/libs/components/charts/src/lib/modules/shared/services/chart-style.service.spec.ts new file mode 100644 index 0000000000..2ceb8141e2 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/chart-style.service.spec.ts @@ -0,0 +1,659 @@ +import { RendererFactory2 } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + SkyTheme, + SkyThemeMode, + SkyThemeService, + SkyThemeSettings, +} from '@skyux/theme'; + +import { SkyChartCssUtilsService } from './chart-css-utils.service'; +import { + SkyChartStyleService, + type SkyChartStyles, +} from './chart-style.service'; + +describe('SkyChartStyleService', () => { + function setupTest(options: { theme?: SkyThemeSettings }): { + service: SkyChartStyleService; + themeSvc: SkyThemeService; + } { + const theme = + options.theme ?? + new SkyThemeSettings(SkyTheme.presets.modern, SkyThemeMode.presets.light); + + TestBed.configureTestingModule({ + providers: [SkyChartStyleService, SkyThemeService], + }); + + const themeSvc = TestBed.inject(SkyThemeService); + const renderer = TestBed.inject(RendererFactory2).createRenderer( + null, + null, + ); + themeSvc.init(document.body, renderer, theme); + + return { + service: TestBed.inject(SkyChartStyleService), + themeSvc, + }; + } + + afterEach(() => { + // Clean up theme service after each test to prevent side effects on other tests + const themeSvc = TestBed.inject(SkyThemeService); + themeSvc.setTheme( + new SkyThemeSettings( + SkyTheme.presets.default, + SkyThemeMode.presets.light, + ), + ); + themeSvc.destroy(); + }); + + describe('theme changes', () => { + it('should recompute styles when theme changes', () => { + const { service, themeSvc } = setupTest({}); + const initialStyles = service.styles(); + + themeSvc.setTheme( + new SkyThemeSettings( + SkyTheme.presets.default, + SkyThemeMode.presets.light, + ), + ); + + const updatedStyles = service.styles(); + expect(updatedStyles).not.toBe(initialStyles); + }); + }); + + describe('default theme', () => { + const theme = new SkyThemeSettings( + SkyTheme.presets.default, + SkyThemeMode.presets.light, + ); + + it('should resolve palette colors', () => { + const { service } = setupTest({ theme }); + expect(service.styles().palettes).toEqual(DefaultTheme.palettes); + }); + + it('should resolve height constraints', () => { + const { service } = setupTest({ theme }); + expect(service.styles().height).toEqual(DefaultTheme.height); + }); + + it('should resolve font family and chart padding', () => { + const { service } = setupTest({ theme }); + const styles = service.styles(); + expect(styles.fontFamily).toBe(DefaultTheme.fontFamily); + expect(styles.chartPadding).toBe(DefaultTheme.chartPadding); + }); + + it('should resolve axis border and grid styles', () => { + const { service } = setupTest({ theme }); + const { axis } = service.styles(); + expect(axis.border).toEqual(DefaultTheme.axis.border); + expect(axis.grid).toEqual(DefaultTheme.axis.grid); + }); + + it('should resolve axis tick styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().axis.ticks).toEqual(DefaultTheme.axis.ticks); + }); + + it('should resolve axis title styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().axis.title).toEqual(DefaultTheme.axis.title); + }); + + it('should resolve tooltip base styles', () => { + const { service } = setupTest({ theme }); + const { tooltip } = service.styles(); + expect(tooltip.backgroundColor).toBe( + DefaultTheme.tooltip.backgroundColor, + ); + expect(tooltip.borderColor).toBe(DefaultTheme.tooltip.borderColor); + expect(tooltip.borderWidth).toBe(DefaultTheme.tooltip.borderWidth); + expect(tooltip.cornerRadius).toBe(DefaultTheme.tooltip.cornerRadius); + }); + + it('should resolve tooltip padding', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.padding).toEqual( + DefaultTheme.tooltip.padding, + ); + }); + + it('should resolve tooltip shadow', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.shadow).toEqual( + DefaultTheme.tooltip.shadow, + ); + }); + + it('should resolve tooltip caret and box', () => { + const { service } = setupTest({ theme }); + const { tooltip } = service.styles(); + expect(tooltip.caret).toEqual(DefaultTheme.tooltip.caret); + expect(tooltip.box).toEqual(DefaultTheme.tooltip.box); + }); + + it('should resolve tooltip title styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.title).toEqual( + DefaultTheme.tooltip.title, + ); + }); + + it('should resolve tooltip body styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.body).toEqual(DefaultTheme.tooltip.body); + }); + + it('should resolve tooltip footer styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.footer).toEqual( + DefaultTheme.tooltip.footer, + ); + }); + + it('should resolve indicator styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().indicator).toEqual(DefaultTheme.indicator); + }); + + it('should resolve bar chart styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().charts.bar).toEqual(DefaultTheme.charts.bar); + }); + + it('should resolve line chart styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().charts.line).toEqual(DefaultTheme.charts.line); + }); + + it('should resolve donut chart styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().charts.donut).toEqual(DefaultTheme.charts.donut); + }); + }); + + describe('modern theme', () => { + const theme = new SkyThemeSettings( + SkyTheme.presets.modern, + SkyThemeMode.presets.light, + ); + + it('should resolve palette colors', () => { + const { service } = setupTest({ theme }); + expect(service.styles().palettes).toEqual(ModernTheme.palettes); + }); + + it('should resolve height constraints', () => { + const { service } = setupTest({ theme }); + expect(service.styles().height).toEqual(ModernTheme.height); + }); + + it('should resolve font family and chart padding', () => { + const { service } = setupTest({ theme }); + const styles = service.styles(); + expect(styles.fontFamily).toBe(ModernTheme.fontFamily); + expect(styles.chartPadding).toBe(ModernTheme.chartPadding); + }); + + it('should resolve axis border and grid styles', () => { + const { service } = setupTest({ theme }); + const { axis } = service.styles(); + expect(axis.border).toEqual(ModernTheme.axis.border); + expect(axis.grid).toEqual(ModernTheme.axis.grid); + }); + + it('should resolve axis tick styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().axis.ticks).toEqual(ModernTheme.axis.ticks); + }); + + it('should resolve axis title styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().axis.title).toEqual(ModernTheme.axis.title); + }); + + it('should resolve tooltip base styles', () => { + const { service } = setupTest({ theme }); + const { tooltip } = service.styles(); + expect(tooltip.backgroundColor).toBe(ModernTheme.tooltip.backgroundColor); + expect(tooltip.borderColor).toBe(ModernTheme.tooltip.borderColor); + expect(tooltip.borderWidth).toBe(ModernTheme.tooltip.borderWidth); + expect(tooltip.cornerRadius).toBe(ModernTheme.tooltip.cornerRadius); + }); + + it('should resolve tooltip padding', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.padding).toEqual( + ModernTheme.tooltip.padding, + ); + }); + + it('should resolve tooltip shadow', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.shadow).toEqual( + ModernTheme.tooltip.shadow, + ); + }); + + it('should resolve tooltip caret and box', () => { + const { service } = setupTest({ theme }); + const { tooltip } = service.styles(); + expect(tooltip.caret).toEqual(ModernTheme.tooltip.caret); + expect(tooltip.box).toEqual(ModernTheme.tooltip.box); + }); + + it('should resolve tooltip title styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.title).toEqual(ModernTheme.tooltip.title); + }); + + it('should resolve tooltip body styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.body).toEqual(ModernTheme.tooltip.body); + }); + + it('should resolve tooltip footer styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().tooltip.footer).toEqual( + ModernTheme.tooltip.footer, + ); + }); + + it('should resolve indicator styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().indicator).toEqual(ModernTheme.indicator); + }); + + it('should resolve bar chart styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().charts.bar).toEqual(ModernTheme.charts.bar); + }); + + it('should resolve line chart styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().charts.line).toEqual(ModernTheme.charts.line); + }); + + it('should resolve donut chart styles', () => { + const { service } = setupTest({ theme }); + expect(service.styles().charts.donut).toEqual(ModernTheme.charts.donut); + }); + }); + + describe('tooltip shadow color fallbacks', () => { + it('should use rgba fallback when extractShadowColor returns null', () => { + const { service } = setupTest({}); + const cssUtils = TestBed.inject(SkyChartCssUtilsService); + spyOn(cssUtils, 'extractShadowColor').and.returnValue(null); + spyOn(cssUtils, 'colorToRgbaWithAlpha').and.returnValue(null); + const tooltip = service.styles().tooltip; + expect(tooltip.shadow.color).toBe('rgba(0, 0, 0, 0.15)'); + }); + }); +}); + +// #region Test Data +const DefaultTheme: SkyChartStyles = { + palettes: { + categorical: [ + '#06a39e', + '#6d3c96', + '#5589dd', + '#004252', + '#ce5600', + '#822325', + '#c650c1', + '#077e43', + ], + sequential: [ + '#dcf6f5', + '#abe9e7', + '#60d5d2', + '#07beb8', + '#06a39e', + '#058984', + '#046e6b', + '#035755', + '#02413f', + '#022a28', + ], + positiveDiverging: [ + '#eef3fc', + '#d5e1f7', + '#aac4ee', + '#80a6e6', + '#5589dd', + '#2b6bd5', + '#2256aa', + '#1a4080', + '#112b55', + '#0d2040', + ], + negativeDiverging: [ + '#fae7e8', + '#f5cccd', + '#f1b4b5', + '#ea9596', + '#e36d6f', + '#d93a3d', + '#ae2e31', + '#822325', + '#641b1c', + '#4a1415', + ], + }, + height: { + min: 168.75, + max: 375, + default: 'clamp(11.25rem, 28vh, 25rem)', + }, + fontFamily: 'Blackbaud Sans, Arial, sans-serif', + chartPadding: 0, + axis: { + border: { + color: '#85888d', + width: 1, + }, + grid: { + color: '#d5d6d8', + width: 1, + }, + ticks: { + fontSize: 13, + fontWeight: 400, + lineHeight: '18px', + color: '#252b33', + padding: 5, + measureLength: 12, + categoryLength: 0, + }, + title: { + fontSize: 13, + fontWeight: 400, + lineHeight: '18px', + color: '#686c73', + paddingTop: 5, + paddingBottom: 5, + }, + }, + tooltip: { + backgroundColor: '#ffffff', + borderColor: '#cdcfd2', + borderWidth: 1, + cornerRadius: 3, + padding: { + top: 8, + right: 8, + bottom: 8, + left: 8, + }, + shadow: { + color: 'rgba(33, 35, 39, 0.6)', + blur: 4, + offsetX: 1, + offsetY: 2, + }, + caret: { + size: 8, + padding: 4, + }, + box: { + height: 12, + width: 12, + padding: 4, + }, + title: { + fontSize: 15, + fontWeight: 600, + lineHeight: '20px', + color: '#212327', + marginBottom: 5, + }, + body: { + fontSize: 15, + fontWeight: 400, + lineHeight: '20px', + color: '#212327', + bodySpacing: 0, + }, + footer: { + fontSize: 15, + fontWeight: 400, + lineHeight: '20px', + color: '#212327', + marginTop: 5, + }, + }, + indicator: { + padding: 2, + borderRadius: 3, + hover: { + borderWidth: 1, + borderColor: '#0974A1', + backgroundColor: '#eeeeef', + }, + active: { + borderWidth: 2, + borderColor: '#0974A1', + backgroundColor: '#eeeeef', + }, + focus: { + borderWidth: 2, + borderColor: '#0974A1', + backgroundColor: '#eeeeef', + }, + }, + charts: { + bar: { + borderColor: '#ffffff', + borderWidth: 1, + borderRadius: 2, + vertical: { + maxBarThickness: 112.5, + }, + horizontal: { + minBarThickness: 11.25, + maxBarThickness: 15, + minCategoryGap: 7.5, + }, + }, + line: { + tension: 0.2, + borderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, + pointBorderWidth: 2, + }, + donut: { + borderColor: '#ffffff', + borderWidth: 1, + }, + }, +}; + +const ModernTheme: SkyChartStyles = { + palettes: { + categorical: [ + '#06a39e', + '#6d3c96', + '#5589dd', + '#004252', + '#ce5600', + '#822325', + '#c650c1', + '#077e43', + ], + sequential: [ + '#dcf6f5', + '#abe9e7', + '#60d5d2', + '#07beb8', + '#06a39e', + '#058984', + '#046e6b', + '#035755', + '#02413f', + '#022a28', + ], + positiveDiverging: [ + '#eef3fc', + '#d5e1f7', + '#aac4ee', + '#80a6e6', + '#5589dd', + '#2b6bd5', + '#2256aa', + '#1a4080', + '#112b55', + '#0d2040', + ], + negativeDiverging: [ + '#fae7e8', + '#f5cccd', + '#f1b4b5', + '#ea9596', + '#e36d6f', + '#d93a3d', + '#ae2e31', + '#822325', + '#641b1c', + '#4a1415', + ], + }, + height: { + min: 180, + max: 400, + default: 'clamp(11.25rem, 28vh, 25rem)', + }, + fontFamily: 'BLKB Sans, Helvetica Neue, Arial, sans-serif', + chartPadding: 0, + axis: { + border: { + color: '#85888d', + width: 1, + }, + grid: { + color: '#d5d6d8', + width: 1, + }, + ticks: { + fontSize: 13, + fontWeight: 400, + lineHeight: '20.7692px', + color: '#1e2229', + padding: 4, + measureLength: 12, + categoryLength: 0, + }, + title: { + fontSize: 13, + fontWeight: 400, + lineHeight: '20.7692px', + color: '#666b70', + paddingTop: 4, + paddingBottom: 4, + }, + }, + tooltip: { + backgroundColor: '#ffffff', + borderColor: '#cad2e1', + borderWidth: 1, + cornerRadius: 4, + padding: { + top: 8, + right: 8, + bottom: 8, + left: 8, + }, + shadow: { + color: 'rgba(33, 44, 63, 0.6)', + blur: 4, + offsetX: 1, + offsetY: 2, + }, + caret: { + size: 8, + padding: 4, + }, + box: { + height: 12, + width: 12, + padding: 4, + }, + title: { + fontSize: 15, + fontWeight: 600, + lineHeight: '20px', + color: '#1e2229', + marginBottom: 8, + }, + body: { + fontSize: 15, + fontWeight: 400, + lineHeight: '20px', + color: '#1e2229', + bodySpacing: 0, + }, + footer: { + fontSize: 15, + fontWeight: 400, + lineHeight: '20px', + color: '#1e2229', + marginTop: 8, + }, + }, + indicator: { + padding: 2, + borderRadius: 4, + hover: { + borderWidth: 1, + borderColor: '#2b6bd5', + backgroundColor: '#f4f8fd', + }, + active: { + borderWidth: 2, + borderColor: '#2b6bd5', + backgroundColor: '#eef3fc', + }, + focus: { + borderWidth: 2, + borderColor: '#2b6bd5', + backgroundColor: 'rgba(0, 0, 0, 0)', + }, + }, + charts: { + bar: { + borderColor: '#ffffff', + borderWidth: 1, + borderRadius: 2, + vertical: { + maxBarThickness: 120, + }, + horizontal: { + minBarThickness: 12, + maxBarThickness: 16, + minCategoryGap: 8, + }, + }, + line: { + tension: 0.2, + borderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, + pointBorderWidth: 2, + }, + donut: { + borderColor: '#ffffff', + borderWidth: 1, + }, + }, +}; +// #endregion diff --git a/libs/components/charts/src/lib/modules/shared/services/chart-style.service.ts b/libs/components/charts/src/lib/modules/shared/services/chart-style.service.ts new file mode 100644 index 0000000000..04ba4d6ae1 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/chart-style.service.ts @@ -0,0 +1,599 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { SkyThemeService } from '@skyux/theme'; + +import { SkyChartCssUtilsService } from './chart-css-utils.service'; + +/** + * Service that provides chart styles derived from the current theme. + */ +@Injectable({ providedIn: 'root' }) +export class SkyChartStyleService { + readonly #cssUtils = inject(SkyChartCssUtilsService); + readonly #themeSvc = inject(SkyThemeService, { optional: true }); + + readonly #themeVersion = signal(0); + + /** + * Computed styles for charts that are derived from the current theme. + * The styles are automatically recomputed when the theme changes, ensuring that charts stay up to date with the latest theme settings. + */ + public readonly styles = computed(() => { + // Recompute chart styles when the theme changes + this.#themeVersion(); + + const styles: SkyChartStyles = { + palettes: this.#palettes(), + height: this.#height(), + fontFamily: this.#cssUtils.css( + '--sky-font-family-primary', + 'Blackbaud Sans, Arial, sans-serif', + ), + chartPadding: 0, + axis: this.#axis(), + tooltip: this.#tooltip(), + indicator: this.#indicator(), + charts: { + bar: this.#bar(), + line: this.#line(), + donut: this.#donut(), + }, + }; + + return styles; + }); + + constructor() { + /* istanbul ignore else */ + if (this.#themeSvc) { + this.#themeSvc.settingsChange + .pipe(takeUntilDestroyed()) + .subscribe(() => this.#themeVersion.update((v) => v + 1)); + } + } + + #palettes(): SkyChartStyles['palettes'] { + const categorical = [ + this.#cssUtils.css('--sky-theme-color-viz-category-1'), + this.#cssUtils.css('--sky-theme-color-viz-category-2'), + this.#cssUtils.css('--sky-theme-color-viz-category-3'), + this.#cssUtils.css('--sky-theme-color-viz-category-4'), + this.#cssUtils.css('--sky-theme-color-viz-category-5'), + this.#cssUtils.css('--sky-theme-color-viz-category-6'), + this.#cssUtils.css('--sky-theme-color-viz-category-7'), + this.#cssUtils.css('--sky-theme-color-viz-category-8'), + ]; + + const sequential = [ + this.#cssUtils.css('--sky-theme-color-viz-sequence-1'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-2'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-3'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-4'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-5'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-6'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-7'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-8'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-9'), + this.#cssUtils.css('--sky-theme-color-viz-sequence-10'), + ]; + + const positiveDiverging = [ + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-1'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-2'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-3'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-4'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-5'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-6'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-7'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-8'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-9'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-pos-10'), + ]; + + const negativeDiverging = [ + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-1'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-2'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-3'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-4'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-5'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-6'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-7'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-8'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-9'), + this.#cssUtils.css('--sky-theme-color-viz-diverge-neg-10'), + ]; + + return { + categorical, + sequential, + positiveDiverging, + negativeDiverging, + }; + } + + #height(): SkyChartStyles['height'] { + const min = '11.25rem'; + const max = '25rem'; + + return { + min: this.#cssUtils.remToPx(min), + max: this.#cssUtils.remToPx(max), + default: `clamp(${min}, 28vh, ${max})`, + }; + } + + #axis(): SkyChartStyles['axis'] { + const border: SkyChartStyles['axis']['border'] = { + color: this.#cssUtils.css('--sky-color-viz-axis', '#85888d'), + width: 1, + }; + + const grid: SkyChartStyles['axis']['grid'] = { + // eslint-disable-next-line @cspell/spellchecker + color: this.#cssUtils.css('--sky-color-viz-gridline', '#d5d6d8'), + width: 1, + }; + + const lineHeight = this.#cssUtils.cssMeasured( + '--sky-font-line_height-body-s', + '18px', + 'line-height', + ); + + const ticks: SkyChartStyles['axis']['ticks'] = { + fontSize: this.#cssUtils.cssNumber('--sky-font-size-body-s', '13px'), + fontWeight: this.#cssUtils.cssNumber('--sky-font-style-body-s', '400'), + lineHeight: lineHeight, + color: this.#cssUtils.css('--sky-color-text-default', '#252b33'), + padding: this.#cssUtils.cssNumber('--sky-space-gap-label-s', '5px'), + measureLength: this.#cssUtils.cssNumber( + '--sky-size-chart-tick_length-measure', + '12px', + ), + categoryLength: this.#cssUtils.cssNumber( + '--sky-size-chart-tick_length-category', + '0px', + ), + }; + + const title: SkyChartStyles['axis']['title'] = { + fontSize: this.#cssUtils.cssNumber('--sky-font-size-body-s', '13px'), + fontWeight: this.#cssUtils.cssNumber('--sky-font-style-body-s', '400'), + lineHeight: lineHeight, + color: this.#cssUtils.css('--sky-text-color-deemphasized', '#686C73'), + paddingTop: this.#cssUtils.cssNumber('--sky-space-stacked-xs', '5px'), + paddingBottom: this.#cssUtils.cssNumber('--sky-space-stacked-xs', '5px'), + }; + + return { + border: border, + grid: grid, + ticks: ticks, + title: title, + }; + } + + #tooltip(): SkyChartStyles['tooltip'] { + const shadow = this.#cssUtils.css( + '--sky-elevation-overlay-simple-100', + '1px 2px 4px 0 rgba(33, 35, 39, 0.5)', + ); + const baseShadowColor = + this.#cssUtils.extractShadowColor(shadow) || 'rgba(0, 0, 0, 0.15)'; + const tooltipShadowColor = + this.#cssUtils.colorToRgbaWithAlpha(baseShadowColor, 0.6) || + baseShadowColor; + + const lineHeight = this.#cssUtils.cssMeasured( + '--sky-font-line_height-body-m', + '20px', + 'line-height', + ); + + const tooltip = { + backgroundColor: this.#cssUtils.css( + '--sky-color-background-container-base', + '#ffffff', + ), + borderColor: this.#cssUtils.css( + '--sky-color-border-container-base', + '#cdcfd2', + ), + borderWidth: this.#cssUtils.cssNumber( + '--sky-border-width-container-base', + '1px', + ), + cornerRadius: this.#cssUtils.cssNumber('--sky-border-radius-s', '3px'), + padding: { + top: this.#cssUtils.cssNumber( + '--sky-comp-chart-tooltip-space-inset-top', + '8px', + ), + right: this.#cssUtils.cssNumber( + '--sky-comp-chart-tooltip-space-inset-right', + '8px', + ), + bottom: this.#cssUtils.cssNumber( + '--sky-comp-chart-tooltip-space-inset-bottom', + '8px', + ), + left: this.#cssUtils.cssNumber( + '--sky-comp-chart-tooltip-space-inset-left', + '8px', + ), + }, + shadow: { + color: tooltipShadowColor, + blur: 4, + offsetX: 1, + offsetY: 2, + }, + caret: { + size: 8, + padding: 4, + }, + box: { + height: this.#cssUtils.cssNumber('--sky-size-icon-xs', '12px'), + width: this.#cssUtils.cssNumber('--sky-size-icon-xs', '12px'), + padding: this.#cssUtils.cssNumber('--sky-space-gap-icon-s', '4px'), + }, + title: { + fontSize: this.#cssUtils.cssNumber('--sky-font-size-body-m', '15px'), + fontWeight: this.#cssUtils.cssNumber( + '--sky-font-style-emphasized', + '600', + ), + lineHeight: lineHeight, + color: this.#cssUtils.css('--sky-color-text-default', '#212327'), + marginBottom: this.#cssUtils.cssNumber('--sky-space-stacked-s', '5px'), + }, + body: { + fontSize: this.#cssUtils.cssNumber('--sky-font-size-body-m', '15px'), + fontWeight: this.#cssUtils.cssNumber('--sky-font-style-body-m', '400'), + lineHeight: lineHeight, + color: this.#cssUtils.css('--sky-color-text-default', '#212327'), + bodySpacing: this.#cssUtils.cssNumber('--sky-space-stacked-0', '0'), + }, + footer: { + fontSize: this.#cssUtils.cssNumber('--sky-font-size-body-m', '15px'), + fontWeight: this.#cssUtils.cssNumber('--sky-font-style-body-m', '400'), + lineHeight: lineHeight, + color: this.#cssUtils.css('--sky-color-text-default', '#212327'), + marginTop: this.#cssUtils.cssNumber('--sky-space-stacked-s', '5px'), + }, + }; + + return tooltip; + } + + #indicator(): SkyChartStyles['indicator'] { + return { + padding: 2, + borderRadius: this.#cssUtils.cssNumber('--sky-border-radius-s', '3px'), + hover: this.#hoverIndicator(), + active: this.#activeIndicator(), + focus: this.#focusIndicator(), + }; + } + + #hoverIndicator(): SkyChartStyles['indicator']['hover'] { + return { + borderWidth: this.#cssUtils.cssNumber( + '--sky-border-width-action-hover', + '1px', + ), + borderColor: this.#cssUtils.css( + '--sky-color-border-action-tertiary-hover', + '#0974A1', + ), + backgroundColor: this.#cssUtils.css( + '--sky-color-background-action-tertiary-hover', + '#eeeeef', + ), + }; + } + + #activeIndicator(): SkyChartStyles['indicator']['active'] { + return { + borderWidth: this.#cssUtils.cssNumber( + '--sky-border-width-action-active', + '2px', + ), + borderColor: this.#cssUtils.css( + '--sky-color-border-action-tertiary-active', + '#0974A1', + ), + backgroundColor: this.#cssUtils.css( + '--sky-color-background-action-tertiary-active', + '#eeeeef', + ), + }; + } + + #focusIndicator(): SkyChartStyles['indicator']['focus'] { + return { + borderWidth: this.#cssUtils.cssNumber( + '--sky-border-width-action-focus', + '2px', + ), + borderColor: this.#cssUtils.css( + '--sky-color-border-action-tertiary-focus', + '#0974A1', + ), + backgroundColor: this.#cssUtils.css( + '--sky-color-background-action-tertiary-focus', + '#eeeeef', + ), + }; + } + + #bar(): SkyChartStyles['charts']['bar'] { + return { + borderColor: this.#cssUtils.css( + '--sky-color-background-container-base', + '#ffffff', + ), + borderWidth: 1, + borderRadius: this.#cssUtils.cssNumber('--sky-border-radius-xs', '2px'), + vertical: { + maxBarThickness: this.#cssUtils.remToPx('7.5rem'), + }, + horizontal: { + minBarThickness: this.#cssUtils.remToPx('0.75rem'), + maxBarThickness: this.#cssUtils.remToPx('1rem'), + minCategoryGap: this.#cssUtils.remToPx('0.5rem'), + }, + }; + } + + #line(): SkyChartStyles['charts']['line'] { + // eslint-disable-next-line @cspell/spellchecker + const pointRadius = this.#cssUtils.cssNumber('--sky-size-icon-xxxs', '4px'); + + return { + tension: 0.2, // Slight curve for smooth lines + borderWidth: 2, + pointRadius: pointRadius, + pointHoverRadius: pointRadius + 2, // Slightly larger on hover, + pointBorderWidth: 2, + }; + } + + #donut(): SkyChartStyles['charts']['donut'] { + return { + borderColor: this.#cssUtils.css( + '--sky-color-background-container-base', + '#ffffff', + ), + borderWidth: this.#cssUtils.cssNumber( + '--sky-border-width-default', + '1px', + ), + }; + } +} + +/** Defines the structure of chart styles */ +export interface SkyChartStyles { + /** Color palettes for charts. */ + palettes: { + /** Use the categorical palette to differentiate categories and data. */ + categorical: string[]; + /** Use the sequential palette to indicate different magnitudes within a range. */ + sequential: string[]; + /** Use the positive diverging palette to indicate positive values that diverge from a critical midpoint. */ + positiveDiverging: string[]; + /** Use the negative diverging palette to indicate negative values that diverge from a critical midpoint. */ + negativeDiverging: string[]; + }; + /** Height constraints for charts. */ + height: { + /** The minimum height for charts in pixels. */ + min: number; + /** The maximum height for charts in pixels. */ + max: number; + /** The default CSS height value for charts. */ + default: string; + }; + /** The font family applied to all chart text. */ + fontFamily: string; + /** The inner padding applied around all chart content. */ + chartPadding: number; + /** Styles for chart axes. */ + axis: { + /** Styles for axis border lines. */ + border: { + /** The CSS color of the axis border. */ + color: string; + /** The pixel width of the axis border. */ + width: number; + }; + /** Styles for the chart grid lines. */ + grid: { + /** The CSS color of the grid lines. */ + color: string; + /** The pixel width of the grid lines. */ + width: number; + }; + /** Styles for axis tick labels. */ + ticks: { + /** The font size in pixels for tick labels. */ + fontSize: number; + /** The font weight for tick labels. */ + fontWeight: number; + /** The line height for tick labels. */ + lineHeight: string; + /** The CSS color of tick label text. */ + color: string; + /** The spacing in pixels between tick marks and their labels. */ + padding: number; + /** The pixel length of measure (value) axis tick marks. */ + measureLength: number; + /** The pixel length of category axis tick marks. */ + categoryLength: number; + }; + /** Styles for axis title labels. */ + title: { + /** The font size in pixels for axis titles. */ + fontSize: number; + /** The font weight for axis titles. */ + fontWeight: number; + /** The line height for axis titles. */ + lineHeight: string; + /** The CSS color of axis title text. */ + color: string; + /** The top padding in pixels above the axis title. */ + paddingTop: number; + /** The bottom padding in pixels below the axis title. */ + paddingBottom: number; + }; + }; + /** Styles for chart tooltips. */ + tooltip: { + /** The CSS background color of the tooltip. */ + backgroundColor: string; + /** The CSS border color of the tooltip. */ + borderColor: string; + /** The pixel width of the tooltip border. */ + borderWidth: number; + /** The corner radius in pixels of the tooltip. */ + cornerRadius: number; + /** The inner padding of the tooltip in pixels. */ + padding: { top: number; right: number; bottom: number; left: number }; + /** The drop shadow applied to the tooltip. */ + shadow: { + /** The CSS color of the shadow. */ + color: string; + /** The shadow blur radius in pixels. */ + blur: number; + /** The horizontal shadow offset in pixels. */ + offsetX: number; + /** The vertical shadow offset in pixels. */ + offsetY: number; + }; + /** Styles for the tooltip pointer caret. */ + caret: { + /** The spacing in pixels between the caret and the tooltip edge. */ + padding: number; + /** The size of the caret in pixels. */ + size: number; + }; + /** Styles for the color swatch box shown next to each dataset label. */ + box: { + /** The height of the color swatch in pixels. */ + height: number; + /** The width of the color swatch in pixels. */ + width: number; + /** The spacing in pixels between the color swatch and the label text. */ + padding: number; + }; + /** Styles for the tooltip title section. */ + title: { + /** The font size in pixels for the tooltip title. */ + fontSize: number; + /** The font weight for the tooltip title. */ + fontWeight: number; + /** The line height for the tooltip title. */ + lineHeight: string; + /** The CSS color of the tooltip title text. */ + color: string; + /** The bottom margin in pixels below the title. */ + marginBottom: number; + }; + /** Styles for the tooltip body section. */ + body: { + /** The font size in pixels for tooltip body text. */ + fontSize: number; + /** The font weight for tooltip body text. */ + fontWeight: number; + /** The line height for tooltip body text. */ + lineHeight: string; + /** The CSS color of tooltip body text. */ + color: string; + /** The vertical spacing in pixels between body lines. */ + bodySpacing: number; + }; + /** Styles for the tooltip footer section. */ + footer: { + /** The font size in pixels for the tooltip footer. */ + fontSize: number; + /** The font weight for the tooltip footer. */ + fontWeight: number; + /** The line height for the tooltip footer. */ + lineHeight: string; + /** The CSS color of tooltip footer text. */ + color: string; + /** The top margin in pixels above the footer. */ + marginTop: number; + }; + }; + /** Styles for the focus/hover/active indicator drawn around chart elements. */ + indicator: { + /** The inner spacing in pixels between the indicator border and the element it surrounds. */ + padding: number; + /** The corner radius in pixels of the indicator border box. */ + borderRadius: number; + /** Indicator styles applied on hover. */ + hover: SkyChartIndicatorStateStyles; + /** Indicator styles applied when an element is active (pressed). */ + active: SkyChartIndicatorStateStyles; + /** Indicator styles applied when an element has keyboard focus. */ + focus: SkyChartIndicatorStateStyles; + }; + /** Styles specific to each chart type. */ + charts: { + /** Styles for bar charts. */ + bar: { + /** The CSS color of the bar border. */ + borderColor: string; + /** The pixel width of the bar border. */ + borderWidth: number; + /** The corner radius in pixels of each bar. */ + borderRadius: number; + /** Styles specific to vertical bar charts. */ + vertical: { + /** The maximum bar thickness in pixels for vertical bar charts. */ + maxBarThickness: number; + }; + /** Styles specific to horizontal bar charts. */ + horizontal: { + /** The minimum gap in pixels between category groups. */ + minCategoryGap: number; + /** The minimum bar thickness in pixels for horizontal bar charts. */ + minBarThickness: number; + /** The maximum bar thickness in pixels for horizontal bar charts. */ + maxBarThickness: number; + }; + }; + /** Styles for line charts. */ + line: { + /** The Bezier tension applied to line segments (0 = straight, 1 = maximum curve). */ + tension: number; + /** The pixel width of the line stroke. */ + borderWidth: number; + /** The radius in pixels of data point markers. */ + pointRadius: number; + /** The radius in pixels of data point markers on hover. */ + pointHoverRadius: number; + /** The pixel width of the data point marker border. */ + pointBorderWidth: number; + }; + /** Styles for donut charts. */ + donut: { + /** The CSS color of the slice border. */ + borderColor: string; + /** The pixel width of the slice border. */ + borderWidth: number; + }; + }; +} + +/** Visual styles applied to an indicator in an interaction state (hover, active, or focus).*/ +export interface SkyChartIndicatorStateStyles { + /** The pixel width of the indicator border. */ + borderWidth: number; + /** The CSS color of the indicator border. */ + borderColor: string; + /** The CSS background color of the indicator. */ + backgroundColor: string; +} diff --git a/libs/components/charts/src/lib/modules/shared/services/global-chart-config.service.ts b/libs/components/charts/src/lib/modules/shared/services/global-chart-config.service.ts new file mode 100644 index 0000000000..8a318a93ee --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/global-chart-config.service.ts @@ -0,0 +1,118 @@ +import { Injectable, inject } from '@angular/core'; +import { SkyLiveAnnouncerService } from '@skyux/core'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { ChartConfiguration, ChartOptions, ChartType, Plugin } from 'chart.js'; + +import { createAutoColorPlugin } from '../plugins/auto-color/auto-color-plugin'; +import { createIndicatorPlugin } from '../plugins/indicator/indicator-plugin'; +import { createKeyboardNavPlugin } from '../plugins/keyboard-nav/keyboard-nav-plugin'; +import { getTooltipPluginOptions } from '../plugins/tooltip/tooltip-options'; +import { createTooltipShadowPlugin } from '../plugins/tooltip/tooltip-shadow-plugin'; + +import { SkyChartStyleService } from './chart-style.service'; + +/** + * A service that provides global chart configuration by merging user-provided configurations with default settings and plugins. + * This ensures a consistent look and feel across all charts in the application while allowing for customization on a per-chart basis. + */ +@Injectable({ providedIn: 'root' }) +export class SkyChartGlobalConfigService { + readonly #chartStyleService = inject(SkyChartStyleService); + readonly #liveAnnouncer = inject(SkyLiveAnnouncerService); + readonly #resources = inject(SkyLibResourcesService); + + /** + * Merges the provided chart configuration with global defaults. + * @param config + * @returns The merged chart configuration + */ + public getMergedChartConfiguration( + config: ChartConfiguration, + ): ChartConfiguration { + // Data + const data = config.data ? config.data : { labels: [], datasets: [] }; + + // Merge options + const options = this.#getMergedChartOptions( + config.options as ChartOptions, + ); + + // Merge plugins + const plugins = this.#getMergedPlugins(config.plugins as Plugin[]); + + const merged: ChartConfiguration = { + type: config.type, + data: data, + options: options, + plugins: plugins, + }; + + return merged; + } + + #getMergedChartOptions( + options: ChartOptions, + ): ChartOptions { + const styles = this.#chartStyleService.styles(); + + const baseOptions: ChartOptions = { + // Responsiveness + responsive: true, + maintainAspectRatio: false, + resizeDelay: 150, + + // Layout padding + layout: { padding: styles.chartPadding }, + + // Interaction options - baseline behavior for Hover and Tooltip behavior + interaction: { mode: 'nearest', intersect: true }, + + // Hover options - hovering interactions should be precise + hover: { mode: 'nearest', intersect: true }, + + // Animation options + animation: { duration: 400, easing: 'easeInOutQuart' }, + + // Global plugin options + plugins: { + legend: { display: false }, + tooltip: getTooltipPluginOptions(styles), + }, + }; + + const mergedOptions: ChartOptions = { + ...baseOptions, + ...options, + plugins: { + ...baseOptions.plugins, + ...options?.plugins, + // Deep merge tooltip to ensure global tooltip config is preserved + tooltip: { + ...baseOptions.plugins?.tooltip, + ...options?.plugins?.tooltip, + }, + // Deep merge legend to ensure global legend config is preserved + legend: { + ...baseOptions.plugins?.legend, + ...options?.plugins?.legend, + }, + }, + }; + + return mergedOptions; + } + + #getMergedPlugins( + plugins: Plugin[], + ): Plugin[] { + const globalPlugins: Plugin[] = [ + createAutoColorPlugin(this.#chartStyleService), + createTooltipShadowPlugin(this.#chartStyleService), + createKeyboardNavPlugin(this.#resources, this.#liveAnnouncer), + createIndicatorPlugin(this.#chartStyleService), + ] as Plugin[]; + + return globalPlugins.concat(plugins ?? []); + } +} diff --git a/libs/components/charts/src/lib/modules/shared/sky-charts-resources.module.ts b/libs/components/charts/src/lib/modules/shared/sky-charts-resources.module.ts new file mode 100644 index 0000000000..54ae6ab399 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/sky-charts-resources.module.ts @@ -0,0 +1,69 @@ +/* istanbul ignore file */ +/** + * NOTICE: DO NOT MODIFY THIS FILE! + * The contents of this file were automatically generated by + * the 'ng generate @skyux/i18n:lib-resources-module lib/modules/shared/sky-charts' schematic. + * To update this file, simply rerun the command. + */ +import { NgModule } from '@angular/core'; +import { + SkyI18nModule, + SkyLibResources, + SkyLibResourcesService, +} from '@skyux/i18n'; + +const RESOURCES: Record = { + 'EN-US': { + 'chart.canvas.role_description': { message: 'chart' }, + 'chart.canvas.label.bar': { message: 'Bar chart' }, + 'chart.canvas.label.line': { message: 'Line chart' }, + 'chart.canvas.label.donut': { message: 'Donut chart' }, + 'chart.focus_element.multi_series.description': { + message: '{0}, series {1} of {2}. {3}: {4}. Point {5} of {6}.', + }, + 'chart.focus_element.single_series.description': { + message: '{0}: {1}. Point {2} of {3}.', + }, + 'chart.legend.aria_label': { message: 'Chart legend' }, + 'chart.legend.legend_item.aria_description': { + message: 'Press Space or Enter to toggle inclusion in chart.', + }, + 'chart.legend.legend_item.aria_label': { + message: '{0}, Legend item {1} of {2}', + }, + 'chart.menu.label': { + message: 'Context menu for {0}', + }, + 'chart.menu.view_data_table': { message: 'View data table' }, + 'chart.menu.view_data_table.aria_label': { + message: 'View data table for {0}', + }, + 'chart_data_grid.category_column_name': { message: 'Category' }, + 'chart_data_grid.close_button': { message: 'Close' }, + 'chart.summary.bar_chart': { + message: 'Bar chart with {0} data series.', + }, + 'chart.summary.line_chart': { + message: 'Line chart with {0} data series.', + }, + 'chart.summary.donut_chart': { + message: 'Donut chart with {0} data points.', + }, + 'chart.summary.category_axis': { + message: 'The chart has a category axis displaying {0}.', + }, + 'chart.summary.measure_axis': { + message: 'The chart has a measure axis displaying {0}.', + }, + }, +}; + +SkyLibResourcesService.addResources(RESOURCES); + +/** + * Import into any component library module that needs to use resource strings. + */ +@NgModule({ + exports: [SkyI18nModule], +}) +export class SkyChartsResourcesModule {} diff --git a/libs/components/charts/src/lib/modules/shared/types/axis-types.ts b/libs/components/charts/src/lib/modules/shared/types/axis-types.ts new file mode 100644 index 0000000000..6d89a5e401 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/axis-types.ts @@ -0,0 +1,52 @@ +/** + * The type for axis label text, which can be a single string or a tuple of strings for dual labels. + */ +export type SkyChartAxisLabelText = string | [string, string]; + +/** + * Configuration for chart axes. + * @internal + */ +export interface SkyChartAxisConfig { + labelText?: SkyChartAxisLabelText; +} + +/** + * Configuration for chart category axis settings. + * @internal + */ +export type SkyChartCategoryAxisConfig = SkyChartAxisConfig; + +/** + * Configuration for chart measure axis settings. + * @internal + */ +export interface SkyChartMeasureAxisConfig extends SkyChartAxisConfig { + /** + * The type of scale to use for the measure axis. + * @default 'linear' + */ + scaleType?: 'linear' | 'logarithmic'; + + /** + * The hard minimum value for the axis. The axis will not go below this value regardless of the data. + */ + min?: number; + + /** + * The hard maximum value for the axis. The axis will not exceed this value regardless of the data. + */ + max?: number; + + /** + * When true, `min` acts as a soft lower bound: the axis starts at `min` but may extend below it if the data requires it. + * When false or omitted, `min` is a hard lower bound and the axis will never go below it. + */ + allowMinOverflow?: boolean; + + /** + * When true, `max` acts as a soft upper bound: the axis starts at `max` but may extend above it if the data requires it. + * When false or omitted, `max` is a hard upper bound and the axis will never exceed it. + */ + allowMaxOverflow?: boolean; +} diff --git a/libs/components/charts/src/lib/modules/shared/types/category.ts b/libs/components/charts/src/lib/modules/shared/types/category.ts new file mode 100644 index 0000000000..5096b0acdf --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/category.ts @@ -0,0 +1,4 @@ +/** + * Defines the type for categories in charts. + */ +export type SkyCategory = string | number; diff --git a/libs/components/charts/src/lib/modules/shared/types/chart-data-point-click-args.ts b/libs/components/charts/src/lib/modules/shared/types/chart-data-point-click-args.ts new file mode 100644 index 0000000000..09f784d464 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/chart-data-point-click-args.ts @@ -0,0 +1,15 @@ +import type { SkyCategory } from './category'; + +/** + * Data emitted when a chart's data point is activated. + */ +export interface SkyChartDataPointClickArgs { + /** The series the activated data point belongs to. */ + series: string; + + /** The category */ + category: SkyCategory; + + /** The value of the data point */ + value: TData; +} diff --git a/libs/components/charts/src/lib/modules/shared/types/chart-data-point.ts b/libs/components/charts/src/lib/modules/shared/types/chart-data-point.ts new file mode 100644 index 0000000000..eeb6c39ca6 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/chart-data-point.ts @@ -0,0 +1,15 @@ +import { SkyCategory } from './category'; + +/** + * Defines the structure of a data point in a chart. + */ +export interface SkyChartDataPoint { + /** The internal unique identifier for the data point component instance. */ + id: number; + + /** The label for the datapoint */ + labelText: string; + + /** The category */ + category: SkyCategory; +} diff --git a/libs/components/charts/src/lib/modules/shared/types/chart-heading-level.ts b/libs/components/charts/src/lib/modules/shared/types/chart-heading-level.ts new file mode 100644 index 0000000000..7a4e7c6d65 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/chart-heading-level.ts @@ -0,0 +1,38 @@ +import { numberAttribute } from '@angular/core'; + +/** + * The allowed heading levels for charts, corresponding to the semantic heading levels in HTML. + */ +export type SkyChartHeadingLevel = 2 | 3 | 4 | 5; + +/** + * Type guard to determine if a value is a valid SkyChartHeadingLevel. + * @param value + */ +export function isSkyChartHeadingLevel( + value: unknown, +): value is SkyChartHeadingLevel { + return value === 2 || value === 3 || value === 4 || value === 5; +} + +/** + * The default heading level for charts. + */ +export const DefaultHeadingLevel: SkyChartHeadingLevel = 3; + +/** + * Transforms a value (typically a string) to a SkyChartHeadingLevel. + * + * @param value Value to be transformed. + * @see [Built-in transformations](guide/components/inputs#built-in-transformations) + */ +export function headingLevelInputTransformer( + value: unknown, +): SkyChartHeadingLevel { + const numberValue = numberAttribute(value, DefaultHeadingLevel); + if (isSkyChartHeadingLevel(numberValue)) { + return numberValue; + } + + return DefaultHeadingLevel; +} diff --git a/libs/components/charts/src/lib/modules/shared/types/chart-heading-style.ts b/libs/components/charts/src/lib/modules/shared/types/chart-heading-style.ts new file mode 100644 index 0000000000..9c9985982b --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/chart-heading-style.ts @@ -0,0 +1,38 @@ +import { numberAttribute } from '@angular/core'; + +/** + * The allowed heading styles for charts, corresponding to the font styles defined in the SKY UX design system. + */ +export type SkyChartHeadingStyle = 2 | 3 | 4 | 5; + +/** + * Type guard to determine if a value is a valid SkyChartHeadingStyle. + * @param value + */ +export function isSkyChartHeadingStyle( + value: unknown, +): value is SkyChartHeadingStyle { + return value === 2 || value === 3 || value === 4 || value === 5; +} + +/** + * The default heading style for charts. + */ +export const DefaultHeadingStyle: SkyChartHeadingStyle = 3; + +/** + * Transforms a value (typically a string) to a SkyChartHeadingStyle. + * + * @param value Value to be transformed. + * @see [Built-in transformations](guide/components/inputs#built-in-transformations) + */ +export function headingStyleInputTransformer( + value: unknown, +): SkyChartHeadingStyle { + const numberValue = numberAttribute(value, DefaultHeadingStyle); + if (isSkyChartHeadingStyle(numberValue)) { + return numberValue; + } + + return DefaultHeadingStyle; +} diff --git a/libs/components/charts/src/lib/modules/shared/types/chart-series.ts b/libs/components/charts/src/lib/modules/shared/types/chart-series.ts new file mode 100644 index 0000000000..da2f20e62c --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/chart-series.ts @@ -0,0 +1,21 @@ +import { SkyChartDataPoint } from './chart-data-point'; + +/** + * Represents a data series in a chart. + */ +export interface SkyChartSeries { + /** + * The internal unique identifier for the series. + */ + id: number; + + /** + * The label for the series + */ + labelText: string; + + /** + * The data points for the series + */ + data: TData[]; +} diff --git a/libs/components/charts/src/lib/modules/shared/types/deep-partial-type.ts b/libs/components/charts/src/lib/modules/shared/types/deep-partial-type.ts new file mode 100644 index 0000000000..cdc2676c4f --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/deep-partial-type.ts @@ -0,0 +1,16 @@ +/** + * Recursively makes all properties of a type optional, including nested objects and arrays. + * ChartJS allows for partial configuration objects but their exposed types don't always reflect that. + */ +export type DeepPartial = + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Using function is intentional to preserve function types as-is. + T extends Function + ? T + : T extends Array + ? DeepPartialArray + : T extends object + ? DeepPartialObject + : T | undefined; + +type DeepPartialArray = Array>; +type DeepPartialObject = { [P in keyof T]?: DeepPartial }; diff --git a/libs/components/charts/testing/eslint.config.js b/libs/components/charts/testing/eslint.config.js new file mode 100644 index 0000000000..b4c331fb6a --- /dev/null +++ b/libs/components/charts/testing/eslint.config.js @@ -0,0 +1,5 @@ +const prettier = require('eslint-config-prettier'); +const baseConfig = require('../../../../eslint-base.config'); +const overrides = require('../../../../eslint-overrides.config'); + +module.exports = [...baseConfig, ...overrides, prettier]; diff --git a/libs/components/charts/testing/karma.conf.js b/libs/components/charts/testing/karma.conf.js new file mode 100644 index 0000000000..7870f8e13d --- /dev/null +++ b/libs/components/charts/testing/karma.conf.js @@ -0,0 +1,19 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +const { join } = require('path'); +const getBaseKarmaConfig = require('../../../../karma.conf'); + +module.exports = function (config) { + const baseConfig = getBaseKarmaConfig(); + config.set({ + ...baseConfig, + coverageReporter: { + ...baseConfig.coverageReporter, + dir: join( + __dirname, + '../../../../coverage/libs/components/charts/testing', + ), + }, + }); +}; diff --git a/libs/components/charts/testing/ng-package.json b/libs/components/charts/testing/ng-package.json new file mode 100644 index 0000000000..fbafcc4448 --- /dev/null +++ b/libs/components/charts/testing/ng-package.json @@ -0,0 +1,5 @@ +{ + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/libs/components/charts/testing/project.json b/libs/components/charts/testing/project.json new file mode 100644 index 0000000000..5c2dc6c0a1 --- /dev/null +++ b/libs/components/charts/testing/project.json @@ -0,0 +1,47 @@ +{ + "name": "charts-testing", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/components/charts/testing/src", + "prefix": "sky", + "tags": ["testing"], + "targets": { + "test": { + "executor": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "libs/components/charts/testing/tsconfig.spec.json", + "karmaConfig": "libs/components/charts/testing/karma.conf.js", + "codeCoverage": true, + "codeCoverageExclude": ["**/fixtures/**"], + "styles": [ + "libs/components/theme/src/lib/styles/sky.scss", + "libs/components/theme/src/lib/styles/themes/modern/styles.scss" + ], + "polyfills": [ + "zone.js", + "zone.js/testing", + "libs/components/packages/src/polyfills.js" + ], + "inlineStyleLanguage": "scss", + "stylePreprocessorOptions": { + "includePaths": ["{workspaceRoot}"] + } + }, + "configurations": { + "ci": { + "browsers": "ChromeHeadlessNoSandbox", + "codeCoverage": true, + "progress": false, + "sourceMap": true, + "watch": false + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "lintFilePatterns": ["{projectRoot}/src/**/*.ts"] + } + } + } +} diff --git a/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.filters.ts b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.filters.ts new file mode 100644 index 0000000000..d127a6cca8 --- /dev/null +++ b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.filters.ts @@ -0,0 +1,7 @@ +import { SkyHarnessFilters } from '@skyux/core/testing'; + +/** + * A set of criteria that can be used to filter a list of `SkyChartBarHarness` instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyChartBarHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.spec.ts b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.spec.ts new file mode 100644 index 0000000000..17de8bbfb0 --- /dev/null +++ b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.spec.ts @@ -0,0 +1,42 @@ +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { SkyHelpTestingModule } from '@skyux/core/testing'; + +import { SkyChartBarHarness } from './chart-bar-harness'; +import { BarChartHarnessTestComponent } from './fixtures/chart-bar-harness-test.component'; + +describe('Bar chart test harness', () => { + async function setupTest( + options: { + dataSkyId?: string; + } = {}, + ): Promise<{ + boxHarness: SkyChartBarHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + await TestBed.configureTestingModule({ + imports: [BarChartHarnessTestComponent, SkyHelpTestingModule], + }).compileComponents(); + + const fixture = TestBed.createComponent(BarChartHarnessTestComponent); + const loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + + const barChartHarness: SkyChartBarHarness = options.dataSkyId + ? await loader.getHarness( + SkyChartBarHarness.with({ dataSkyId: options.dataSkyId }), + ) + : await loader.getHarness(SkyChartBarHarness); + + return { boxHarness: barChartHarness, fixture, loader }; + } + + it('should work', async () => { + const { fixture } = await setupTest(); + + fixture.detectChanges(); + + expect(true).toBeFalse(); + }); +}); diff --git a/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.ts b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.ts new file mode 100644 index 0000000000..800853f21a --- /dev/null +++ b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.ts @@ -0,0 +1,24 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyChartBarHarnessFilters } from './chart-bar-harness.filters'; + +/** + * Harness for interacting with a bar chart component in tests. + */ +export class SkyChartBarHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-chart-bar'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyChartBarHarness` that meets certain criteria + */ + public static with( + filters: SkyChartBarHarnessFilters, + ): HarnessPredicate { + return SkyChartBarHarness.getDataSkyIdPredicate(filters); + } +} diff --git a/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.html b/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.html new file mode 100644 index 0000000000..1333ed77b7 --- /dev/null +++ b/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.html @@ -0,0 +1 @@ +TODO diff --git a/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.ts b/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.ts new file mode 100644 index 0000000000..07627166ce --- /dev/null +++ b/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +// #region Test component +@Component({ + selector: 'sky-bar-chart-fixture', + templateUrl: './chart-bar-harness-test.component.html', + imports: [], + providers: [], +}) +export class BarChartHarnessTestComponent {} +// #endregion Test component diff --git a/libs/components/charts/testing/src/public-api.ts b/libs/components/charts/testing/src/public-api.ts new file mode 100644 index 0000000000..a9ac7e041e --- /dev/null +++ b/libs/components/charts/testing/src/public-api.ts @@ -0,0 +1,2 @@ +export { SkyChartBarHarness } from './modules/chart-bar/chart-bar-harness'; +export { SkyChartBarHarnessFilters } from './modules/chart-bar/chart-bar-harness.filters'; diff --git a/libs/components/charts/testing/tsconfig.spec.json b/libs/components/charts/testing/tsconfig.spec.json new file mode 100644 index 0000000000..3432c27c85 --- /dev/null +++ b/libs/components/charts/testing/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": ["jasmine", "node"] + }, + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/libs/components/charts/tsconfig.json b/libs/components/charts/tsconfig.json new file mode 100644 index 0000000000..c63981277b --- /dev/null +++ b/libs/components/charts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.lib.prod.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/components/charts/tsconfig.lib.json b/libs/components/charts/tsconfig.lib.json new file mode 100644 index 0000000000..4952dcda5f --- /dev/null +++ b/libs/components/charts/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true + }, + "exclude": ["src/test.ts", "**/*.spec.ts"], + "include": ["**/*.ts"] +} diff --git a/libs/components/charts/tsconfig.lib.prod.json b/libs/components/charts/tsconfig.lib.prod.json new file mode 100644 index 0000000000..2a2faa884c --- /dev/null +++ b/libs/components/charts/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/components/charts/tsconfig.spec.json b/libs/components/charts/tsconfig.spec.json new file mode 100644 index 0000000000..bb6f4b9ec9 --- /dev/null +++ b/libs/components/charts/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["jasmine", "node"] + }, + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/package-lock.json b/package-lock.json index 6f5abb4863..ae1e2f8ecd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "ajv": "8.17.1", "autonumeric": "4.10.8", "axe-core": "4.10.3", + "chart.js": "4.5.1", "comment-json": "4.2.5", "dom-autoscroller": "2.3.4", "dompurify": "3.2.6", @@ -7480,6 +7481,12 @@ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", "license": "MIT" }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -17969,6 +17976,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", diff --git a/package.json b/package.json index ac13631ead..90fb7cf66b 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "ajv": "8.17.1", "autonumeric": "4.10.8", "axe-core": "4.10.3", + "chart.js": "4.5.1", "comment-json": "4.2.5", "dom-autoscroller": "2.3.4", "dompurify": "3.2.6", diff --git a/tsconfig.base.json b/tsconfig.base.json index 46106c4e79..74b3cdc39d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -58,6 +58,10 @@ "@skyux/avatar/testing": [ "libs/components/avatar/testing/src/public-api.ts" ], + "@skyux/charts": ["libs/components/charts/src/index.ts"], + "@skyux/charts/testing": [ + "libs/components/charts/testing/src/public-api.ts" + ], "@skyux/code-examples": ["libs/components/code-examples/src/index.ts"], "@skyux/code-examples/routes": [ "libs/components/code-examples/routes/src/index.ts"