From 26e78b9ce419a4acdd6a55d3af135b077390b06e Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Wed, 1 Apr 2026 12:35:54 -0400 Subject: [PATCH 01/34] feat(components/charts): add @skyux/charts library --- .github/workflows/validate-pr.yml | 1 + apps/playground/src/app/app.routes.ts | 1 + .../bar-chart-demo/bar-chart-demo-routes.ts | 18 + .../bar-chart-demo.component.html | 614 ++++++++++++++++++ .../bar-chart-demo.component.ts | 343 ++++++++++ .../app/components/charts/charts-routes.ts | 18 + .../donut-chart-demo-routes.ts | 18 + .../donut-chart-demo.component.html | 44 ++ .../donut-chart-demo.component.ts | 58 ++ .../line-chart-demo/line-chart-demo-routes.ts | 18 + .../line-chart-demo.component.html | 293 +++++++++ .../line-chart-demo.component.ts | 339 ++++++++++ .../charts/shared/chart-demo-utils.ts | 90 +++ .../src/app/components/components.module.ts | 4 + libs/components/charts/CHANGELOG.md | 6 + libs/components/charts/README.md | 7 + libs/components/charts/documentation.json | 17 + libs/components/charts/eslint.config.js | 3 + libs/components/charts/karma.conf.js | 16 + libs/components/charts/ng-package.json | 10 + libs/components/charts/package.json | 35 + libs/components/charts/project.json | 80 +++ .../src/assets/locales/resources_en_US.json | 37 ++ libs/components/charts/src/index.ts | 33 + .../axis/chart-category-axis.component.ts | 51 ++ .../axis/chart-measure-axis.component.ts | 72 ++ .../axis/sky-chart-axis-registry.service.ts | 47 ++ .../bar-chart/bar-chart-config.service.ts | 343 ++++++++++ .../bar-chart/bar-chart-registry.service.ts | 85 +++ .../bar-chart-series-datapoint.component.ts | 71 ++ .../bar-chart/bar-chart-series.component.ts | 55 ++ .../lib/modules/bar-chart/bar-chart-types.ts | 20 + .../modules/bar-chart/bar-chart.component.ts | 211 ++++++ .../chart-data-grid-modal-context.ts | 22 + .../chart-data-grid-modal.component.html | 13 + .../chart-data-grid-modal.component.scss | 0 .../chart-data-grid-modal.component.ts | 149 +++++ .../modules/chart-legend/chart-legend-item.ts | 15 + .../chart-legend/chart-legend.component.html | 38 ++ .../chart-legend/chart-legend.component.scss | 90 +++ .../chart-legend/chart-legend.component.ts | 131 ++++ .../modules/chart/chart-header-id-token.ts | 22 + .../lib/modules/chart/chart.component.html | 99 +++ .../lib/modules/chart/chart.component.scss | 48 ++ .../src/lib/modules/chart/chart.component.ts | 157 +++++ .../src/lib/modules/chart/chart.service.ts | 32 + .../src/lib/modules/chartjs.directive.ts | 147 +++++ .../donut-chart/donut-chart-config.service.ts | 151 +++++ .../donut-chart-registry.service.ts | 58 ++ .../donut-chart-series-datapoint.component.ts | 74 +++ .../donut-chart-series.component.ts | 55 ++ .../modules/donut-chart/donut-chart-types.ts | 14 + .../donut-chart/donut-chart.component.ts | 180 +++++ .../line-chart/line-chart-config.service.ts | 322 +++++++++ .../line-chart/line-chart-registry.service.ts | 85 +++ .../line-chart-series-datapoint.component.ts | 74 +++ .../line-chart/line-chart-series.component.ts | 64 ++ .../modules/line-chart/line-chart-types.ts | 15 + .../line-chart/line-chart.component.ts | 196 ++++++ .../src/lib/modules/shared/chart-helpers.ts | 155 +++++ .../plugins/auto-color/auto-color-plugin.ts | 89 +++ .../plugins/indicator/bar-indicator-bounds.ts | 106 +++ .../indicator/donut-indicator-helpers.ts | 63 ++ .../plugins/indicator/indicator-draw.ts | 228 +++++++ .../indicator/indicator-plugin-options.ts | 17 + .../plugins/indicator/indicator-plugin.ts | 217 +++++++ .../plugins/indicator/indicator-types.ts | 14 + .../indicator/line-indicator-bounds.ts | 56 ++ .../cartesian-navigation-strategy.ts | 168 +++++ .../create-navigation-strategy.ts | 20 + .../keyboard-nav-plugin-options.ts | 36 + .../keyboard-nav/keyboard-nav-plugin.ts | 370 +++++++++++ .../shared/plugins/keyboard-nav/keys.ts | 45 ++ .../keyboard-nav/navigation-strategy.ts | 43 ++ .../radial-navigation-strategy.ts | 87 +++ .../plugin-state/focused-elements-state.ts | 7 + .../shared/plugins/tooltip/tooltip-options.ts | 98 +++ .../plugins/tooltip/tooltip-shadow-plugin.ts | 47 ++ .../services/chart-css-utils.service.ts | 160 +++++ .../shared/services/chart-registry.service.ts | 37 ++ .../services/chart-style.service.spec.ts | 535 +++++++++++++++ .../shared/services/chart-style.service.ts | 422 ++++++++++++ .../services/global-chart-config.service.ts | 117 ++++ .../shared/sky-charts-resources.module.ts | 38 ++ .../lib/modules/shared/types/axis-types.ts | 44 ++ .../src/lib/modules/shared/types/category.ts | 4 + .../shared/types/chart-clicked-data-point.ts | 15 + .../modules/shared/types/chart-data-point.ts | 15 + .../shared/types/chart-heading-level.ts | 38 ++ .../shared/types/chart-heading-style.ts | 38 ++ .../lib/modules/shared/types/chart-series.ts | 21 + .../modules/shared/types/deep-partial-type.ts | 16 + .../charts/testing/eslint.config.js | 5 + libs/components/charts/testing/karma.conf.js | 19 + .../components/charts/testing/ng-package.json | 5 + libs/components/charts/testing/project.json | 47 ++ .../bar-chart/bar-chart-harness.filters.ts | 7 + .../bar-chart/bar-chart-harness.spec.ts | 42 ++ .../modules/bar-chart/bar-chart-harness.ts | 24 + .../bar-chart-harness-test.component.html | 1 + .../bar-chart-harness-test.component.ts | 11 + .../charts/testing/src/public-api.ts | 2 + .../charts/testing/tsconfig.spec.json | 8 + libs/components/charts/tsconfig.json | 16 + libs/components/charts/tsconfig.lib.json | 11 + libs/components/charts/tsconfig.lib.prod.json | 9 + libs/components/charts/tsconfig.spec.json | 8 + package-lock.json | 19 + package.json | 1 + tsconfig.base.json | 4 + 110 files changed, 8914 insertions(+) create mode 100644 apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo-routes.ts create mode 100644 apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html create mode 100644 apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts create mode 100644 apps/playground/src/app/components/charts/charts-routes.ts create mode 100644 apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo-routes.ts create mode 100644 apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html create mode 100644 apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts create mode 100644 apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo-routes.ts create mode 100644 apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html create mode 100644 apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts create mode 100644 apps/playground/src/app/components/charts/shared/chart-demo-utils.ts create mode 100644 libs/components/charts/CHANGELOG.md create mode 100644 libs/components/charts/README.md create mode 100644 libs/components/charts/documentation.json create mode 100644 libs/components/charts/eslint.config.js create mode 100644 libs/components/charts/karma.conf.js create mode 100644 libs/components/charts/ng-package.json create mode 100644 libs/components/charts/package.json create mode 100644 libs/components/charts/project.json create mode 100644 libs/components/charts/src/assets/locales/resources_en_US.json create mode 100644 libs/components/charts/src/index.ts create mode 100644 libs/components/charts/src/lib/modules/axis/chart-category-axis.component.ts create mode 100644 libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.ts create mode 100644 libs/components/charts/src/lib/modules/axis/sky-chart-axis-registry.service.ts create mode 100644 libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts create mode 100644 libs/components/charts/src/lib/modules/bar-chart/bar-chart-registry.service.ts create mode 100644 libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts create mode 100644 libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts create mode 100644 libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts create mode 100644 libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts create mode 100644 libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal-context.ts create mode 100644 libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.html create mode 100644 libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.scss create mode 100644 libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.ts create mode 100644 libs/components/charts/src/lib/modules/chart-legend/chart-legend-item.ts create mode 100644 libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.html create mode 100644 libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.scss create mode 100644 libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.ts create mode 100644 libs/components/charts/src/lib/modules/chart/chart-header-id-token.ts create mode 100644 libs/components/charts/src/lib/modules/chart/chart.component.html create mode 100644 libs/components/charts/src/lib/modules/chart/chart.component.scss create mode 100644 libs/components/charts/src/lib/modules/chart/chart.component.ts create mode 100644 libs/components/charts/src/lib/modules/chart/chart.service.ts create mode 100644 libs/components/charts/src/lib/modules/chartjs.directive.ts create mode 100644 libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts create mode 100644 libs/components/charts/src/lib/modules/donut-chart/donut-chart-registry.service.ts create mode 100644 libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts create mode 100644 libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts create mode 100644 libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts create mode 100644 libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts create mode 100644 libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts create mode 100644 libs/components/charts/src/lib/modules/line-chart/line-chart-registry.service.ts create mode 100644 libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts create mode 100644 libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts create mode 100644 libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts create mode 100644 libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts create mode 100644 libs/components/charts/src/lib/modules/shared/chart-helpers.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-helpers.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin-options.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-types.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/create-navigation-strategy.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin-options.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keys.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/navigation-strategy.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/radial-navigation-strategy.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/plugin-state/focused-elements-state.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-options.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-shadow-plugin.ts create mode 100644 libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.ts create mode 100644 libs/components/charts/src/lib/modules/shared/services/chart-registry.service.ts create mode 100644 libs/components/charts/src/lib/modules/shared/services/chart-style.service.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/services/chart-style.service.ts create mode 100644 libs/components/charts/src/lib/modules/shared/services/global-chart-config.service.ts create mode 100644 libs/components/charts/src/lib/modules/shared/sky-charts-resources.module.ts create mode 100644 libs/components/charts/src/lib/modules/shared/types/axis-types.ts create mode 100644 libs/components/charts/src/lib/modules/shared/types/category.ts create mode 100644 libs/components/charts/src/lib/modules/shared/types/chart-clicked-data-point.ts create mode 100644 libs/components/charts/src/lib/modules/shared/types/chart-data-point.ts create mode 100644 libs/components/charts/src/lib/modules/shared/types/chart-heading-level.ts create mode 100644 libs/components/charts/src/lib/modules/shared/types/chart-heading-style.ts create mode 100644 libs/components/charts/src/lib/modules/shared/types/chart-series.ts create mode 100644 libs/components/charts/src/lib/modules/shared/types/deep-partial-type.ts create mode 100644 libs/components/charts/testing/eslint.config.js create mode 100644 libs/components/charts/testing/karma.conf.js create mode 100644 libs/components/charts/testing/ng-package.json create mode 100644 libs/components/charts/testing/project.json create mode 100644 libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.filters.ts create mode 100644 libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.spec.ts create mode 100644 libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts create mode 100644 libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.html create mode 100644 libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.ts create mode 100644 libs/components/charts/testing/src/public-api.ts create mode 100644 libs/components/charts/testing/tsconfig.spec.json create mode 100644 libs/components/charts/tsconfig.json create mode 100644 libs/components/charts/tsconfig.lib.json create mode 100644 libs/components/charts/tsconfig.lib.prod.json create mode 100644 libs/components/charts/tsconfig.spec.json 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/bar-chart-demo/bar-chart-demo-routes.ts b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo-routes.ts new file mode 100644 index 0000000000..6b827e0fc9 --- /dev/null +++ b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo-routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; + +import { BarChartDemoComponent } from './bar-chart-demo.component'; + +const BAR_CHART_ROUTES: Routes = [ + { + path: '', + component: BarChartDemoComponent, + 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/bar-chart-demo/bar-chart-demo.component.html b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html new file mode 100644 index 0000000000..27f8761376 --- /dev/null +++ b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html @@ -0,0 +1,614 @@ + + + + + + + + + + + + + + + + + + @for ( + series of verticalSingleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of verticalMultiSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of verticalStacked; track series.label) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + + + @for (point of verticalLog[0].data; track $index) { + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @for (series of verticalStackedLog; track series.label) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + + @for ( + series of horizontalSingleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of horizontalMultiSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of horizontalStacked; track series.label) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + + @for ( + series of horizontalLogSingleSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of horizontalLogMultiSeries; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for ( + series of horizontalLogStacked; + track series.label + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + +Help content diff --git a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts new file mode 100644 index 0000000000..86edabb6d4 --- /dev/null +++ b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts @@ -0,0 +1,343 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + SkyBarChartComponent, + SkyBarChartSeriesComponent, + SkyBarChartSeriesDatapointComponent, + type SkyBarDatum, + SkyChartCategoryAxisComponent, + type SkyChartClickedDataPoint, + SkyChartComponent, + 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-bar-chart-demo', + templateUrl: './bar-chart-demo.component.html', + styles: [], + imports: [ + SkyPageModule, + SkyBarChartComponent, + SkyBoxModule, + SkyTabsModule, + SkyFluidGridModule, + SkyBarChartComponent, + SkyBarChartSeriesComponent, + SkyBarChartSeriesDatapointComponent, + SkyChartCategoryAxisComponent, + SkyChartMeasureAxisComponent, + SkyChartComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BarChartDemoComponent { + // #region Vertical + public readonly verticalSingleSeries = [ + { + 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 }, + ], + }, + ]; + + public readonly verticalMultiSeries = [ + { + 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 }, + ], + }, + ]; + + public readonly verticalStacked = [ + { + label: 'Dataset 1', + data: ChartDemoUtils.numbers({ + count: 7, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: 'Dataset 2', + data: ChartDemoUtils.numbers({ + count: 7, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: 'Dataset 3', + data: ChartDemoUtils.numbers({ + count: 7, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + ]; + + public readonly verticalLog = [ + { + label: 'Dataset 1', + data: [1, 1.1, 1.9, 2.1, 4.9, 5.1, 9, 11, 90, 110, 900, 1100, 9000].map( + (value, index) => { + return { + category: 'Cat-' + (index + 1), + label: `$${value}`, + value: value, + }; + }, + ), + }, + ]; + + public readonly verticalStackedLog = [ + { + label: 'Dataset 1', + data: ChartDemoUtils.numbers({ + count: 7, + min: 1, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: 'Dataset 2', + data: ChartDemoUtils.numbers({ + count: 7, + min: 1, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: 'Dataset 3', + data: ChartDemoUtils.numbers({ + count: 7, + min: 1, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + ]; + // #endregion + + // #region Horizontal + public readonly horizontalSingleSeries = [ + { + 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 }, + ], + }, + ]; + + public readonly horizontalMultiSeries = [ + { + labelText: 'Budget', + data: [ + { category: 'Revenue', label: '$120,000', value: 120_000 }, + { category: 'Expenses', label: '$85,000', value: 85_000 }, + ], + }, + { + labelText: 'Actuals', + data: [ + { category: 'Revenue', label: '$115,000', value: 115_000 }, + { category: 'Expenses', label: '$78,000', value: 78_000 }, + ], + }, + ]; + + public readonly horizontalStacked = [ + { + label: 'Dataset 1', + data: ChartDemoUtils.numbers({ + count: 7, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: 'Dataset 2', + data: ChartDemoUtils.numbers({ + count: 7, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: 'Dataset 3', + data: ChartDemoUtils.numbers({ + count: 7, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }), + }, + ]; + + public readonly horizontalLog = []; + + public readonly horizontalLogSingleSeries = [ + { + 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 }, + ], + }, + ]; + + public readonly horizontalLogMultiSeries = [ + { + labelText: 'Budget', + data: [ + { category: 'Revenue', label: '$120,000', value: 120_000 }, + { category: 'Expenses', label: '$85,000', value: 85_000 }, + ], + }, + { + labelText: 'Actuals', + data: [ + { category: 'Revenue', label: '$115,000', value: 115_000 }, + { category: 'Expenses', label: '$78,000', value: 78_000 }, + ], + }, + ]; + + public readonly horizontalLogStacked = [ + { + label: 'Dataset 1', + data: ChartDemoUtils.numbers({ count: 7, min: 1, max: 1_000 }).map( + (value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }, + ), + }, + { + label: 'Dataset 2', + data: ChartDemoUtils.numbers({ count: 7, min: 1, max: 1_000 }).map( + (value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }, + ), + }, + { + label: 'Dataset 3', + data: ChartDemoUtils.numbers({ count: 7, min: 1, max: 1_000 }).map( + (value, index) => { + return { + category: ChartDemoUtils.months({ count: 7 })[index], + label: `$${value}`, + value, + }; + }, + ), + }, + ]; + // #endregion + + public onDataPointClicked( + event: SkyChartClickedDataPoint, + ): 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..f79fed883f --- /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: 'bar-chart-demos', + loadChildren: () => import('./bar-chart-demo/bar-chart-demo-routes'), + }, + { + path: 'line-chart-demos', + loadChildren: () => import('./line-chart-demo/line-chart-demo-routes'), + }, + { + path: 'donut-chart-demos', + loadChildren: () => import('./donut-chart-demo/donut-chart-demo-routes'), + }, +]; + +export default CHARTS_ROUTES; diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo-routes.ts b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo-routes.ts new file mode 100644 index 0000000000..009fa13154 --- /dev/null +++ b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo-routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; + +import { DonutChartDemoComponent } from './donut-chart-demo.component'; + +const DONUT_CHART_ROUTES: Routes = [ + { + path: '', + component: DonutChartDemoComponent, + 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/donut-chart-demo/donut-chart-demo.component.html b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html new file mode 100644 index 0000000000..58f549ac3d --- /dev/null +++ b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + @for (item of chart1; track $index) { + + } + + + + + + + + + + diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts new file mode 100644 index 0000000000..f3a954fe5d --- /dev/null +++ b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts @@ -0,0 +1,58 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + type SkyChartClickedDataPoint, + SkyChartComponent, + SkyDonutChartComponent, + SkyDonutChartSeriesComponent, + SkyDonutChartSeriesDatapointComponent, + SkyDonutDatum, +} from '@skyux/charts'; +import { SkyBoxModule, SkyFluidGridModule } from '@skyux/layout'; +import { SkyPageModule } from '@skyux/pages'; + +@Component({ + selector: 'app-donut-chart-demo', + templateUrl: 'donut-chart-demo.component.html', + styles: [], + imports: [ + SkyPageModule, + SkyDonutChartComponent, + SkyBoxModule, + SkyFluidGridModule, + SkyChartComponent, + SkyDonutChartComponent, + SkyDonutChartSeriesComponent, + SkyDonutChartSeriesDatapointComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DonutChartDemoComponent { + protected chart1 = [ + { + name: 'Securities', + value: 5_000_000, + label: '$5,000,000', + }, + { + name: 'Income/Compensation', + value: 2_500_000, + label: '$2,500,000', + }, + { + name: 'Private Co. Valuation', + value: 1_500_000, + label: '$1,500,000', + }, + { + name: 'Real Estate', + value: 1_000_000, + label: '$1,000,000', + }, + ]; + + public onDataPointClicked( + event: SkyChartClickedDataPoint, + ): void { + console.log(JSON.stringify(event, null, 2)); + } +} diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo-routes.ts b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo-routes.ts new file mode 100644 index 0000000000..bfbfd7c2c7 --- /dev/null +++ b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo-routes.ts @@ -0,0 +1,18 @@ +import { Routes } from '@angular/router'; + +import { LineChartDemoComponent } from './line-chart-demo.component'; + +const LINE_CHART_ROUTES: Routes = [ + { + path: '', + component: LineChartDemoComponent, + 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/line-chart-demo/line-chart-demo.component.html b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html new file mode 100644 index 0000000000..1256af20d3 --- /dev/null +++ b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html @@ -0,0 +1,293 @@ + + + + + + + + + + + + + + + + + + @for (series of singleSeries; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of multiSeries; track series.label) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of stacked; track series.label) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + + + + + + + @for (series of logSingleSeries; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of logMultiSeries; track series.label) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + + + + @for (series of logStacked; track series.label) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts new file mode 100644 index 0000000000..424f30fc8a --- /dev/null +++ b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts @@ -0,0 +1,339 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + SkyChartCategoryAxisComponent, + SkyChartClickedDataPoint, + SkyChartComponent, + SkyChartMeasureAxisComponent, + SkyLineChartComponent, + SkyLineChartSeriesComponent, + SkyLineChartSeriesDatapointComponent, + SkyLineDatum, +} 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-bar-chart-demo', + templateUrl: 'line-chart-demo.component.html', + imports: [ + SkyPageModule, + SkyTabsModule, + SkyLineChartComponent, + SkyBoxModule, + SkyFluidGridModule, + SkyChartComponent, + SkyLineChartComponent, + SkyLineChartSeriesComponent, + SkyLineChartSeriesDatapointComponent, + SkyChartCategoryAxisComponent, + SkyChartMeasureAxisComponent, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LineChartDemoComponent { + // #region Linear + public readonly 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 }, + ], + }, + ]; + + public readonly multiSeries = [ + { + label: '2022', + data: ChartDemoUtils.numbers({ + count: 12, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2023', + data: ChartDemoUtils.numbers({ + count: 12, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2024', + data: ChartDemoUtils.numbers({ + count: 12, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2025', + data: ChartDemoUtils.numbers({ + count: 12, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + ]; + + public readonly stacked = [ + { + label: '2022', + data: ChartDemoUtils.numbers({ + count: 12, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2023', + data: ChartDemoUtils.numbers({ + count: 12, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2024', + data: ChartDemoUtils.numbers({ + count: 12, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2025', + data: ChartDemoUtils.numbers({ + count: 12, + min: 0, + max: 100, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + ]; + // #endregion + + // #region Logarithmic Scale + public readonly logSingleSeries = [ + { + 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 }, + ], + }, + ]; + + public readonly logMultiSeries = [ + { + label: '2022', + data: ChartDemoUtils.numbers({ + count: 12, + min: 1, + max: 1_000, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2023', + data: ChartDemoUtils.numbers({ + count: 12, + min: 1, + max: 1_000, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2024', + data: ChartDemoUtils.numbers({ + count: 12, + min: 1, + max: 1_000, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2025', + data: ChartDemoUtils.numbers({ + count: 12, + min: 1, + max: 1_000, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + ]; + + public readonly logStacked = [ + { + label: '2022', + data: ChartDemoUtils.numbers({ + count: 12, + min: 1, + max: 1_000, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2023', + data: ChartDemoUtils.numbers({ + count: 12, + min: 1, + max: 1_000, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2024', + data: ChartDemoUtils.numbers({ + count: 12, + min: 1, + max: 1_000, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + { + label: '2025', + data: ChartDemoUtils.numbers({ + count: 12, + min: 1, + max: 1_000, + decimals: 0, + }).map((value, index) => { + return { + category: ChartDemoUtils.months({ count: 12 })[index], + label: `$${value}`, + value, + }; + }), + }, + ]; + // #endregion + + public onDataPointClicked( + event: SkyChartClickedDataPoint, + ): void { + console.log(JSON.stringify(event, null, 2)); + } +} 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..bd22071cc7 --- /dev/null +++ b/apps/playground/src/app/components/charts/shared/chart-demo-utils.ts @@ -0,0 +1,90 @@ +/** + * 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 points(config: { + min?: number; + max?: number; + from?: number[]; + count?: number; + decimals?: number; + continuity?: number; + }): { x: number; y: number }[] { + const xs = this.numbers(config); + const ys = this.numbers(config); + return xs.map((x, i) => ({ x, y: ys[i] })); + } + + 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; + } +} 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..0c865d3fd5 --- /dev/null +++ b/libs/components/charts/documentation.json @@ -0,0 +1,17 @@ +{ + "$schema": "../manifest-generator/documentation-schema.json", + "groups": { + "charts": { + "development": { + "docsIds": [], + "primaryDocsId": "" + }, + "testing": { + "docsIds": [] + }, + "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..e0b64def95 --- /dev/null +++ b/libs/components/charts/package.json @@ -0,0 +1,35 @@ +{ + "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/core": "0.0.0-PLACEHOLDER", + "@skyux/icon": "0.0.0-PLACEHOLDER", + "@skyux/i18n": "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..034268050f --- /dev/null +++ b/libs/components/charts/project.json @@ -0,0 +1,80 @@ +{ + "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" + ], + "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..0b774fe332 --- /dev/null +++ b/libs/components/charts/src/assets/locales/resources_en_US.json @@ -0,0 +1,37 @@ +{ + "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.list_label": { + "_description": "The label for the chart legend", + "message": "Chart legend" + }, + "chart.menu.view_data_table": { + "_description": "The label for the 'View data table' chart menu item", + "message": "View data table" + }, + "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" + } +} diff --git a/libs/components/charts/src/index.ts b/libs/components/charts/src/index.ts new file mode 100644 index 0000000000..0db25546db --- /dev/null +++ b/libs/components/charts/src/index.ts @@ -0,0 +1,33 @@ +export { SkyChartComponent } from './lib/modules/chart/chart.component'; +export { SkyChartClickedDataPoint } from './lib/modules/shared/types/chart-clicked-data-point'; + +// Axis +export { SkyChartCategoryAxisComponent } from './lib/modules/axis/chart-category-axis.component'; +export { SkyChartMeasureAxisComponent } from './lib/modules/axis/chart-measure-axis.component'; + +// Bar Chart +export { SkyBarChartComponent } from './lib/modules/bar-chart/bar-chart.component'; +export { SkyBarChartSeriesComponent } from './lib/modules/bar-chart/bar-chart-series.component'; +export { SkyBarChartSeriesDatapointComponent } from './lib/modules/bar-chart/bar-chart-series-datapoint.component'; +export { + SkyBarDatum, + SkyBarChartPoint, +} from './lib/modules/bar-chart/bar-chart-types'; + +// Line Chart +export { SkyLineChartComponent } from './lib/modules/line-chart/line-chart.component'; +export { SkyLineChartSeriesComponent } from './lib/modules/line-chart/line-chart-series.component'; +export { SkyLineChartSeriesDatapointComponent } from './lib/modules/line-chart/line-chart-series-datapoint.component'; +export { + SkyLineDatum, + SkyLineChartPoint, +} from './lib/modules/line-chart/line-chart-types'; + +// Donut Chart +export { SkyDonutChartComponent } from './lib/modules/donut-chart/donut-chart.component'; +export { SkyDonutChartSeriesComponent } from './lib/modules/donut-chart/donut-chart-series.component'; +export { SkyDonutChartSeriesDatapointComponent } from './lib/modules/donut-chart/donut-chart-series-datapoint.component'; +export { + SkyDonutDatum, + SkyDonutChartSlice, +} from './lib/modules/donut-chart/donut-chart-types'; 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..f0d7724fbc --- /dev/null +++ b/libs/components/charts/src/lib/modules/axis/chart-category-axis.component.ts @@ -0,0 +1,51 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyChartCategoryAxisConfig, SkyChartAxisLabelText } from '../shared/types/axis-types'; + +import { SKY_CHART_AXIS_REGISTRY } from './sky-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 + * @internal + */ + public 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.ts b/libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.ts new file mode 100644 index 0000000000..36b22f29ff --- /dev/null +++ b/libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.ts @@ -0,0 +1,72 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyChartMeasureAxisConfig, SkyChartAxisLabelText } from '../shared/types/axis-types'; + +import { SKY_CHART_AXIS_REGISTRY } from './sky-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 suggested lower bound for the measure axis. + * The chart may still go below this value if the data requires it. + */ + public readonly suggestedMin = input(); + + /** + * The suggested upper bound for the measure axis. + * The chart may still exceed this value if the data requires it. + */ + public readonly suggestedMax = input(); + + /** + * The axis object + * @internal + */ + public readonly axis = computed(() => { + return { + labelText: this.labelText(), + scaleType: this.scaleType(), + suggestedMin: this.suggestedMin(), + suggestedMax: this.suggestedMax() + }; + }); + + constructor() { + effect(() => { + const axis = this.axis(); + this.#registry.upsertMeasureAxis(axis); + }); + } + + public ngOnDestroy(): void { + this.#registry.removeMeasureAxis(); + } +} diff --git a/libs/components/charts/src/lib/modules/axis/sky-chart-axis-registry.service.ts b/libs/components/charts/src/lib/modules/axis/sky-chart-axis-registry.service.ts new file mode 100644 index 0000000000..fbbeefe3bc --- /dev/null +++ b/libs/components/charts/src/lib/modules/axis/sky-chart-axis-registry.service.ts @@ -0,0 +1,47 @@ +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/bar-chart/bar-chart-config.service.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts new file mode 100644 index 0000000000..de49099351 --- /dev/null +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts @@ -0,0 +1,343 @@ +import { Injectable, inject } from '@angular/core'; + +import { + ChartConfiguration, + ChartDataset, + ChartOptions, + ChartTypeRegistry, + ScaleOptionsByType, +} from 'chart.js'; + +import { createLogTickFilter, parseCategories } from '../shared/chart-helpers'; +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 { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; +import { DeepPartial } from '../shared/types/deep-partial-type'; + +import { + SkyBarChartOrientation, + SkyBarChartPoint, + SkyBarDatum, +} from './bar-chart-types'; + +/** + * Configuration service for the Bar Chart component. + */ +@Injectable({ providedIn: 'root' }) +export class SkyBarChartConfigService { + 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 bar chart options + */ + public buildConfig(options: SkyBarChartOptions): 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); + } + + 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: { dataPointsClickable: options.dataPointsClickable }, + 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: { + categoryPercentage: 0.7, + // barPercentage: 0.7, + }, + }, + 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.dataPointsClickable || + !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; + } + + #createScales( + styles: SkyChartStyles, + options: SkyBarChartOptions, + ): 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 }; + } + } + + #getBaseScale(styles: SkyChartStyles): PartialBarScale { + const base: PartialBarScale = { + 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, + }, + }, + }; + + return base; + } + + #createCategoryScale( + styles: SkyChartStyles, + options: SkyBarChartOptions, + ): PartialBarScale { + const base = this.#getBaseScale(styles); + + const categoryScale: PartialBarScale = { + type: 'category', + stacked: options.stacked ?? false, + grid: { + display: false, + lineWidth: 0, + drawTicks: false, + tickLength: 0, + }, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + }, + title: { + ...base.title, + display: !!options.categoryAxis?.labelText, + text: options.categoryAxis?.labelText ?? '', + }, + }; + + return categoryScale; + } + + #createMeasureScale( + styles: SkyChartStyles, + options: SkyBarChartOptions, + ): PartialBarScale { + if (options.measureAxis?.scaleType === 'logarithmic') { + return this.#createLogarithmicMeasureScale(styles, options); + } else { + return this.#createLinearMeasureScale(styles, options); + } + } + + #createLinearMeasureScale( + styles: SkyChartStyles, + options: SkyBarChartOptions, + ): PartialBarScale { + const base = this.#getBaseScale(styles); + + const valueScale: PartialBarScale = { + type: 'linear', + stacked: options.stacked ?? false, + beginAtZero: true, + suggestedMin: options.measureAxis?.suggestedMin, + suggestedMax: options.measureAxis?.suggestedMax, + grid: base.grid, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + }, + title: { + ...base.title, + display: !!options.measureAxis?.labelText, + text: options.measureAxis?.labelText, + }, + }; + + return valueScale; + } + + #createLogarithmicMeasureScale( + styles: SkyChartStyles, + options: SkyBarChartOptions, + ): PartialBarScale { + const base = this.#getBaseScale(styles); + + const valueScale: PartialBarScale = { + type: 'logarithmic', + stacked: options.stacked ?? false, + suggestedMin: options.measureAxis?.suggestedMin, + suggestedMax: options.measureAxis?.suggestedMax, + grid: { + ...base.grid, + lineWidth: (ctx) => { + const tick = ctx.tick; + return !tick?.label ? 0 : styles.axis.grid.width; + }, + }, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + callback: createLogTickFilter, + }, + title: { + ...base.title, + display: !!options.measureAxis?.labelText, + text: options.measureAxis?.labelText, + }, + }; + + return valueScale; + } +} + +// #region Types +/** Configuration for the bar chart component. */ +export interface SkyBarChartOptions { + /** Orientation of the chart. */ + orientation?: SkyBarChartOrientation; + + /** 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 */ + dataPointsClickable: boolean; + + callbacks?: { + onDataPointClick: (event: SkyChartClickedDataPoint) => void; + }; +} + +type PartialBarScale = DeepPartial< + ScaleOptionsByType +>; +// #endregion diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-registry.service.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-registry.service.ts new file mode 100644 index 0000000000..3932c5a089 --- /dev/null +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-registry.service.ts @@ -0,0 +1,85 @@ +import { Injectable, signal } from '@angular/core'; + +import { SkyChartAxisRegistry } from '../axis/sky-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 { SkyBarChartPoint } from './bar-chart-types'; + +@Injectable() +export class SkyBarChartRegistry + 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: SkyBarChartPoint): 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/bar-chart/bar-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts new file mode 100644 index 0000000000..0ff3f514e0 --- /dev/null +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts @@ -0,0 +1,71 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyCategory } from '../shared/types/category'; + +import { SkyBarChartRegistry } from './bar-chart-registry.service'; +import { SkyBarChartSeriesComponent } from './bar-chart-series.component'; +import { SkyBarChartPoint, SkyBarDatum } from './bar-chart-types'; + +let nextId = 0; + +/** + * Represents a single data point within a chart series. + */ +@Component({ + selector: 'sky-bar-chart-series-datapoint', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyBarChartSeriesDatapointComponent implements OnDestroy { + readonly #registry = inject(SkyBarChartRegistry); + readonly #series = inject(SkyBarChartSeriesComponent); + + /** + * 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. + * Accepts a single number, a floating-bar range `[min, max]`, or `null` for a gap. + */ + public readonly value = input.required(); + + /** + * A unique ID for this data point component instance. + */ + public 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.#series.id, datapoint); + }); + } + + public ngOnDestroy(): void { + this.#registry.removePoint(this.#series.id, this.id); + } +} diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts new file mode 100644 index 0000000000..a31dc55908 --- /dev/null +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts @@ -0,0 +1,55 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyBarChartRegistry } from './bar-chart-registry.service'; +import { SkyBarChartPoint } from './bar-chart-types'; + +let nextId = 0; + +/** + * Represents a named data series in a chart. + */ +@Component({ + selector: 'sky-bar-chart-series', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyBarChartSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyBarChartRegistry); + + /** + * 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. + */ + public readonly id = nextId++; + + 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/bar-chart/bar-chart-types.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts new file mode 100644 index 0000000000..c5989e0eeb --- /dev/null +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts @@ -0,0 +1,20 @@ +import { SkyChartDataPoint } from '../shared/types/chart-data-point'; + +/** + * The orientation of the bar chart + */ +export type SkyBarChartOrientation = 'vertical' | 'horizontal'; + +/** + * A bar chart data point, which can be a single numeric value or a range (tuple of two numbers). + * A `null` value represents a gap in the data. + */ +export type SkyBarDatum = number | [number, number] | null; + +/** + * A single data point within a bar chart series. + */ +export interface SkyBarChartPoint extends SkyChartDataPoint { + /** Numeric value or floating range */ + value: SkyBarDatum; +} diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts new file mode 100644 index 0000000000..38fc4f425c --- /dev/null +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts @@ -0,0 +1,211 @@ +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + computed, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; + +import { SKY_CHART_AXIS_REGISTRY } from '../axis/sky-chart-axis-registry.service'; +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; +import { SkyChartService } from '../chart/chart.service'; +import { SkyChartJsDirective } from '../chartjs.directive'; +import { getLegendItems } from '../shared/chart-helpers'; +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; +import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { + SkyBarChartConfigService, + SkyBarChartOptions, +} from './bar-chart-config.service'; +import { SkyBarChartRegistry } from './bar-chart-registry.service'; +import { + SkyBarChartOrientation, + SkyBarChartPoint, + SkyBarDatum, +} from './bar-chart-types'; + +/** + * Displays a bar chart visualization. + */ +@Component({ + selector: 'sky-bar-chart', + 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: [ + SkyBarChartRegistry, + { provide: SKY_CHART_AXIS_REGISTRY, useExisting: SkyBarChartRegistry }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyBarChartComponent { + // #region Dependency Injection + readonly #chartService = inject(SkyChartService); + readonly #chartRegistry = inject(SkyBarChartRegistry); + readonly #chartConfigService = inject(SkyBarChartConfigService); + // #endregion + + // #region Inputs + public readonly orientation = input('vertical'); + public readonly stacked = input(false, { transform: booleanAttribute }); + public readonly dataPointsClickable = input(false, { + transform: booleanAttribute, + }); + // #endregion + + // #region Outputs + public readonly dataPointClicked = + output>(); + // #endregion + + // #region View Children + protected readonly chartDirective = viewChild(SkyChartJsDirective); + // #endregion + + protected readonly arialLabel = this.#chartService.headingText; + readonly #chart = computed(() => this.chartDirective()?.chart()); + readonly #chartUpdated = signal(0); + readonly #refreshLegendItems = signal(0); + + readonly #chartOptions = computed(() => { + const dataPointsClickable = this.dataPointsClickable(); + 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({ + dataPointsClickable: dataPointsClickable, + orientation: orientation, + stacked: stacked, + categoryAxis: categoryAxis, + measureAxis: measureAxis, + series: series, + }); + + return options; + }); + + 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 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: { + dataPointsClickable: boolean; + orientation: SkyBarChartOrientation; + stacked: boolean; + categoryAxis: Readonly | undefined; + measureAxis: Readonly | undefined; + series: SkyChartSeries[]; + }): SkyBarChartOptions { + const { + dataPointsClickable, + orientation, + stacked, + categoryAxis, + measureAxis, + series, + } = context; + + return { + orientation: orientation, + stacked: stacked, + series: series, + categoryAxis: categoryAxis ? categoryAxis : undefined, + measureAxis: measureAxis ? measureAxis : undefined, + dataPointsClickable: dataPointsClickable, + callbacks: { + onDataPointClick: (dataPoint) => this.dataPointClicked.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); + } + // #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.ts b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.ts new file mode 100644 index 0000000000..cc6b32d3cb --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.ts @@ -0,0 +1,149 @@ +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 { 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's implementation 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 (string | number)[], + 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): string { + return `series_${seriesIndex}`; + } +} + +interface ChartDataGridRow { + /** The category for this row. */ + category: string | number; + + /** + * Key: the series' label + * Value: the data point's label + */ + [key: string]: string | number; +} 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..d3c2bdf730 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.html @@ -0,0 +1,38 @@ +@if (hasLegendItems()) { +
    + @for ( + item of legendItems(); + track item.labelText + '-' + item.datasetIndex + '-' + item.index + ) { +
  • + +
  • + } +
+} 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..69ede0c55e --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.scss @@ -0,0 +1,90 @@ +@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; + list-style: none; + margin: 0; + padding: 0; +} + +.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.ts b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.ts new file mode 100644 index 0000000000..a1347a99cf --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.ts @@ -0,0 +1,131 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + computed, + effect, + input, + output, + signal, + viewChildren, +} from '@angular/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], + 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); + } + }); + } + + protected onLegendFocusIn(event: FocusEvent): void { + const host = event.currentTarget as HTMLElement | null; + const related = event.relatedTarget as Node | null; + const enteredFromOutside = !related || !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 toggleLegendItem(item: SkyChartLegendItem, index?: number): void { + // Guard against toggling the last visible item off, which would leave the chart without any visible data. + if (item.isVisible && this.#isLastVisible()) { + 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/chart-header-id-token.ts b/libs/components/charts/src/lib/modules/chart/chart-header-id-token.ts new file mode 100644 index 0000000000..ec8cb99e65 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart-header-id-token.ts @@ -0,0 +1,22 @@ +import { InjectionToken, Provider, inject } from '@angular/core'; +import { SkyIdService } from '@skyux/core'; + +/** + * Injection token for the chart header ID, used to associate the chart header with its content for accessibility purposes. + */ +export const SKY_CHART_HEADER_ID = new InjectionToken( + 'SKY_CHART_HEADER_ID', +); + +/** + * Factory function to provide a unique ID for the chart header. + */ +export function provideSkyChartHeaderId(): Provider { + return { + provide: SKY_CHART_HEADER_ID, + useFactory(): string { + const idService = inject(SkyIdService); + return idService.generateId(); + }, + }; +} 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..17f1734bcc --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart.component.html @@ -0,0 +1,99 @@ +
+
+ +
+ @switch (headingLevel()) { + @case (2) { + +

{{ headingText() }}

+ } + @case (4) { + +

{{ headingText() }}

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

{{ headingText() }}

+ } + } + + @if ((helpPopoverContent() || helpKey()) && !headingHidden()) { + + + + } +
+ + + @if (subtitleText()) { +
+ {{ 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..801133fa48 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart.component.scss @@ -0,0 +1,48 @@ +@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; +} + +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..e8eca0524f --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart.component.ts @@ -0,0 +1,157 @@ +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 { + SKY_CHART_HEADER_ID, + provideSkyChartHeaderId, +} from './chart-header-id-token'; +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-bar-chart`) inside it. + */ +@Component({ + selector: 'sky-chart', + templateUrl: 'chart.component.html', + styleUrl: 'chart.component.scss', + imports: [ + SkyChartsResourcesModule, + SkyDropdownModule, + SkyHelpInlineModule, + SkyChartLegendComponent, + ], + providers: [provideSkyChartHeaderId(), SkyChartService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyChartComponent { + // #region Dependency Injection + protected readonly headerId = inject(SKY_CHART_HEADER_ID); + 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 includeHeaderMargin = computed(() => { + const headingVisible = !!this.headingText() && !this.headingHidden(); + const subtitleVisible = !!this.subtitleText() && !this.subtitleHidden(); + return headingVisible || subtitleVisible; + }); + protected readonly headingClass = computed( + () => `sky-font-heading-${this.headingStyle()}`, + ); + protected readonly legendItems = this.#chartService.legendItems; + protected readonly showLegend = computed(() => this.legendItems().length > 1); + + constructor() { + effect(() => this.#chartService.headingText.set(this.headingText())); + } + + 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..60d5370954 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart/chart.service.ts @@ -0,0 +1,32 @@ +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 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.directive.ts b/libs/components/charts/src/lib/modules/chartjs.directive.ts new file mode 100644 index 0000000000..d6cfbaed8d --- /dev/null +++ b/libs/components/charts/src/lib/modules/chartjs.directive.ts @@ -0,0 +1,147 @@ +import { + AfterViewInit, + Directive, + ElementRef, + NgZone, + OnDestroy, + effect, + inject, + input, + output, + signal, + untracked, +} from '@angular/core'; + +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: 'img', + '[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); + // #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 + + 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/donut-chart/donut-chart-config.service.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts new file mode 100644 index 0000000000..9c5f3b452b --- /dev/null +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts @@ -0,0 +1,151 @@ +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 { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; + +/** + * Configuration service for the Donut Chart component. + */ +@Injectable({ providedIn: 'root' }) +export class SkyDonutChartConfigService { + 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: SkyDonutChartOptions, + ): 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: { dataPointsClickable: options.dataPointsClickable }, + 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 + 10, + }, + 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.dataPointsClickable || + !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; + } +} + +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 SkyDonutChartOptions { + /** The data series for the chart. */ + series: SkyChartSeries; + + /** Are the data points clickable */ + dataPointsClickable: boolean; + + callbacks?: { + onDataPointClick: (event: SkyChartClickedDataPoint) => void; + }; +} +// #endregion diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-registry.service.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-registry.service.ts new file mode 100644 index 0000000000..b34f095734 --- /dev/null +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-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 { SkyDonutChartSlice } from './donut-chart-types'; + +@Injectable() +export class SkyDonutChartRegistry + 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: SkyDonutChartSlice): 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/donut-chart/donut-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts new file mode 100644 index 0000000000..7e49d3e9cf --- /dev/null +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts @@ -0,0 +1,74 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyCategory } from '../shared/types/category'; + +import { SkyDonutChartRegistry } from './donut-chart-registry.service'; +import { SkyDonutChartSeriesComponent } from './donut-chart-series.component'; +import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; + +let nextId = 0; + +/** + * Represents a single data point within a donut chart series. + */ +@Component({ + selector: 'sky-donut-chart-series-datapoint', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyDonutChartSeriesDatapointComponent implements OnDestroy { + readonly #registry = inject(SkyDonutChartRegistry); + readonly #series = inject(SkyDonutChartSeriesComponent); + + /** + * 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. + */ + public readonly id = nextId++; + + /** + * The data point object + * @internal + */ + public 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.#series.id, datapoint); + }); + } + + public ngOnDestroy(): void { + this.#registry.removePoint(this.#series.id, this.id); + } +} diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts new file mode 100644 index 0000000000..6589a61e07 --- /dev/null +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts @@ -0,0 +1,55 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyDonutChartRegistry } from './donut-chart-registry.service'; +import { SkyDonutChartSlice } from './donut-chart-types'; + +let nextId = 0; + +/** + * Represents a named data series in a chart. + */ +@Component({ + selector: 'sky-donut-chart-series', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyDonutChartSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyDonutChartRegistry); + + /** + * 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. + */ + public readonly id = nextId++; + + 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/donut-chart/donut-chart-types.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts new file mode 100644 index 0000000000..7dc0d7b705 --- /dev/null +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts @@ -0,0 +1,14 @@ +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 SkyDonutDatum = number; + +/** + * A single data point within a donut chart series. + */ +export interface SkyDonutChartSlice extends SkyChartDataPoint { + /** Numeric value */ + value: SkyDonutDatum; +} diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts new file mode 100644 index 0000000000..7450b132a7 --- /dev/null +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts @@ -0,0 +1,180 @@ +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + computed, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; + +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; +import { SkyChartService } from '../chart/chart.service'; +import { SkyChartJsDirective } from '../chartjs.directive'; +import { getLegendItems } from '../shared/chart-helpers'; +import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { + SkyDonutChartConfigService, + SkyDonutChartOptions, +} from './donut-chart-config.service'; +import { SkyDonutChartRegistry } from './donut-chart-registry.service'; +import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; + +/** + * Displays a donut chart visualization. + */ +@Component({ + selector: 'sky-donut-chart', + 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: [SkyDonutChartRegistry], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyDonutChartComponent { + // #region Dependency Injection + readonly #chartService = inject(SkyChartService); + readonly #chartRegistry = inject(SkyDonutChartRegistry); + readonly #chartConfigService = inject(SkyDonutChartConfigService); + // #endregion + + // #region Inputs + public readonly dataPointsClickable = input(false, { + transform: booleanAttribute, + }); + // #endregion + + // #region Outputs + public readonly dataPointClicked = + output>(); + // #endregion + + // #region View Children + protected readonly chartDirective = viewChild(SkyChartJsDirective); + // #endregion + + protected readonly arialLabel = this.#chartService.headingText; + readonly #chart = computed(() => this.chartDirective()?.chart()); + readonly #chartUpdated = signal(0); + readonly #refreshLegendItems = signal(0); + + readonly #chartOptions = computed(() => { + const dataPointsClickable = this.dataPointsClickable(); + const series = this.#chartRegistry.series(); + const options = this.#parseOptions({ + dataPointsClickable: dataPointsClickable, + series: series, + }); + + return options; + }); + + 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 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: { + dataPointsClickable: boolean; + series: SkyChartSeries[]; + }): SkyDonutChartOptions { + const { dataPointsClickable, series } = context; + + // Donut charts only supports a single series + if (series.length > 1) { + throw new Error('Donut charts only support a single series.'); + } + + return { + series: series[0], + dataPointsClickable: dataPointsClickable, + callbacks: { + onDataPointClick: (dataPoint) => this.dataPointClicked.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); + } + // #endregion +} diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts new file mode 100644 index 0000000000..a8a96c9cef --- /dev/null +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts @@ -0,0 +1,322 @@ +import { Injectable, inject } from '@angular/core'; + +import { + ChartConfiguration, + ChartDataset, + ChartOptions, + ChartTypeRegistry, + ScaleOptionsByType, +} from 'chart.js'; + +import { createLogTickFilter, parseCategories } from '../shared/chart-helpers'; +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 { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; +import { DeepPartial } from '../shared/types/deep-partial-type'; + +import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; + +/** + * Configuration service for the Line Chart component. + */ +@Injectable({ providedIn: 'root' }) +export class SkyLineChartConfigService { + 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: SkyLineChartOptions): 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); + } + + 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: { dataPointsClickable: options.dataPointsClickable }, + 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.dataPointsClickable || + !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; + } + + #createScales( + styles: SkyChartStyles, + config: SkyLineChartOptions, + ): ChartOptions<'line'>['scales'] { + const categoryScale = this.#createCategoryScale(styles, config); + const measureScale = this.#createMeasureScale(styles, config); + + return { x: categoryScale, y: measureScale }; + } + + #getBaseScale(styles: SkyChartStyles): PartialLineScale { + const base: PartialLineScale = { + 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, + }, + }, + }; + + return base; + } + + #createCategoryScale( + styles: SkyChartStyles, + config: SkyLineChartOptions, + ): PartialLineScale { + const base = this.#getBaseScale(styles); + + const categoryScale: PartialLineScale = { + type: 'category', + stacked: config.stacked ?? false, + grid: base.grid, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + }, + title: { + ...base.title, + display: !!config.categoryAxis?.labelText, + text: config.categoryAxis?.labelText, + }, + }; + + return categoryScale; + } + + #createMeasureScale( + styles: SkyChartStyles, + config: SkyLineChartOptions, + ): PartialLineScale { + if (config.measureAxis?.scaleType === 'logarithmic') { + return this.#createLogarithmicMeasureScale(styles, config); + } else { + return this.#createLinearMeasureScale(styles, config); + } + } + + #createLinearMeasureScale( + styles: SkyChartStyles, + config: SkyLineChartOptions, + ): PartialLineScale { + const base = this.#getBaseScale(styles); + + const valueScale: PartialLineScale = { + type: 'linear', + stacked: config.stacked ?? false, + suggestedMin: config.measureAxis?.suggestedMin, + suggestedMax: config.measureAxis?.suggestedMax, + grid: base.grid, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + }, + title: { + ...base.title, + display: !!config.measureAxis?.labelText, + text: config.measureAxis?.labelText, + }, + }; + + return valueScale; + } + + #createLogarithmicMeasureScale( + styles: SkyChartStyles, + config: SkyLineChartOptions, + ): PartialLineScale { + const base = this.#getBaseScale(styles); + + const valueScale: PartialLineScale = { + type: 'logarithmic', + stacked: config.stacked ?? false, + suggestedMin: config.measureAxis?.suggestedMin, + suggestedMax: config.measureAxis?.suggestedMax, + grid: { + ...base.grid, + lineWidth: (ctx) => { + const tick = ctx.tick; + return !tick?.label ? 0 : styles.axis.grid.width; + }, + }, + border: base.border, + ticks: { + ...base.ticks, + padding: styles.axis.ticks.padding, + callback: createLogTickFilter, + }, + title: { + ...base.title, + display: !!config.measureAxis?.labelText, + text: config.measureAxis?.labelText, + }, + }; + + return valueScale; + } +} + +// #region Types +/** Configuration for the line chart component. */ +export interface SkyLineChartOptions { + /** 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 */ + dataPointsClickable: boolean; + + callbacks?: { + onDataPointClick: (event: SkyChartClickedDataPoint) => void; + }; +} + +type PartialLineScale = DeepPartial< + ScaleOptionsByType +>; +// #endregion diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-registry.service.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-registry.service.ts new file mode 100644 index 0000000000..f5a9bac390 --- /dev/null +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-registry.service.ts @@ -0,0 +1,85 @@ +import { Injectable, signal } from '@angular/core'; + +import { SkyChartAxisRegistry } from '../axis/sky-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 { SkyLineChartPoint } from './line-chart-types'; + +@Injectable() +export class SkyLineChartRegistry + 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: SkyLineChartPoint): 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/line-chart/line-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts new file mode 100644 index 0000000000..cfbb428777 --- /dev/null +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts @@ -0,0 +1,74 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyCategory } from '../shared/types/category'; + +import { SkyLineChartRegistry } from './line-chart-registry.service'; +import { SkyLineChartSeriesComponent } from './line-chart-series.component'; +import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; + +let nextId = 0; + +/** + * Represents a single data point within a line chart series. + */ +@Component({ + selector: 'sky-line-chart-series-datapoint', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyLineChartSeriesDatapointComponent implements OnDestroy { + readonly #registry = inject(SkyLineChartRegistry); + readonly #series = inject(SkyLineChartSeriesComponent); + + /** + * 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. + */ + public readonly id = nextId++; + + /** + * The data point object + * @internal + */ + public 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.#series.id, datapoint); + }); + } + + public ngOnDestroy(): void { + this.#registry.removePoint(this.#series.id, this.id); + } +} diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts new file mode 100644 index 0000000000..9858341f97 --- /dev/null +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts @@ -0,0 +1,64 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + computed, + contentChildren, + effect, + inject, + input, +} from '@angular/core'; + +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { SkyLineChartRegistry } from './line-chart-registry.service'; +import { SkyLineChartSeriesDatapointComponent } from './line-chart-series-datapoint.component'; +import { SkyLineChartPoint } from './line-chart-types'; + +let nextId = 0; + +/** + * Represents a named data series in a chart. + */ +@Component({ + selector: 'sky-line-chart-series', + template: '', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyLineChartSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyLineChartRegistry); + + /** + * The display label for this series. Shown in the chart legend and tooltips. + */ + public readonly labelText = input.required(); + + /** + * The data points that belong to this series. + */ + protected readonly datapoints = contentChildren( + SkyLineChartSeriesDatapointComponent, + ); + + /** + * A unique ID for this series component instance. + */ + public readonly id = nextId++; + + 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/line-chart/line-chart-types.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts new file mode 100644 index 0000000000..b2bc65f17a --- /dev/null +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts @@ -0,0 +1,15 @@ +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. + * A `null` value represents a gap in the data. + */ +export type SkyLineDatum = number | null; + +/** + * A single data point within a line chart series. + */ +export interface SkyLineChartPoint extends SkyChartDataPoint { + /** Numeric value */ + value: SkyLineDatum; +} diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts new file mode 100644 index 0000000000..bebccac192 --- /dev/null +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts @@ -0,0 +1,196 @@ +import { + ChangeDetectionStrategy, + Component, + booleanAttribute, + computed, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; + +import { SKY_CHART_AXIS_REGISTRY } from '../axis/sky-chart-axis-registry.service'; +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; +import { SkyChartService } from '../chart/chart.service'; +import { SkyChartJsDirective } from '../chartjs.directive'; +import { getLegendItems } from '../shared/chart-helpers'; +import { + SkyChartCategoryAxisConfig, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; +import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import { SkyChartSeries } from '../shared/types/chart-series'; + +import { + SkyLineChartConfigService, + SkyLineChartOptions, +} from './line-chart-config.service'; +import { SkyLineChartRegistry } from './line-chart-registry.service'; +import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; + +/** + * Displays a line chart visualization. + */ +@Component({ + selector: 'sky-line-chart', + 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: [ + SkyLineChartRegistry, + { provide: SKY_CHART_AXIS_REGISTRY, useExisting: SkyLineChartRegistry }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SkyLineChartComponent { + // #region Dependency Injection + readonly #chartService = inject(SkyChartService); + readonly #chartRegistry = inject(SkyLineChartRegistry); + readonly #chartConfigService = inject(SkyLineChartConfigService); + // #endregion + + // #region Inputs + public readonly dataPointsClickable = input(false, { + transform: booleanAttribute, + }); + public readonly stacked = input(false, { transform: booleanAttribute }); + // #endregion + + // #region Outputs + public readonly dataPointClicked = + output>(); + // #endregion + + // #region View Children + protected readonly chartDirective = viewChild(SkyChartJsDirective); + // #endregion + + protected readonly arialLabel = this.#chartService.headingText; + readonly #chart = computed(() => this.chartDirective()?.chart()); + readonly #chartUpdated = signal(0); + readonly #refreshLegendItems = signal(0); + + readonly #chartOptions = computed(() => { + const dataPointsClickable = this.dataPointsClickable(); + const stacked = this.stacked(); + + const categoryAxis = this.#chartRegistry.categoryAxis(); + const measureAxis = this.#chartRegistry.measureAxis(); + const series = this.#chartRegistry.series(); + + const options = this.#parseOptions({ + dataPointsClickable: dataPointsClickable, + stacked: stacked, + categoryAxis: categoryAxis, + measureAxis: measureAxis, + series: series, + }); + + return options; + }); + + 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 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: { + dataPointsClickable: boolean; + stacked: boolean; + categoryAxis: Readonly | undefined; + measureAxis: Readonly | undefined; + series: SkyChartSeries[]; + }): SkyLineChartOptions { + const { dataPointsClickable, stacked, categoryAxis, measureAxis, series } = + context; + + return { + stacked: stacked, + series: series, + categoryAxis: categoryAxis ? categoryAxis : undefined, + measureAxis: measureAxis ? measureAxis : undefined, + dataPointsClickable: dataPointsClickable, + callbacks: { + onDataPointClick: (dataPoint) => this.dataPointClicked.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); + } + // #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..c5e7c5b3b9 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts @@ -0,0 +1,155 @@ +import { Chart, ChartConfiguration, ChartDataset, ChartType } from 'chart.js'; + +import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; + +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; + }); +} + +/** + * 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 + */ +export 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/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..38097ad111 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.ts @@ -0,0 +1,89 @@ +import { Chart, ChartDataset, ChartType, Plugin } from 'chart.js'; + +import { isDatasetType, isDonutChart } from '../../chart-helpers'; +import { + SkyChartStyleService, + SkyChartStyles, +} 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(); + + // For donut charts, always apply colors in 'data' mode + if (isDonutChart(chart)) { + applyDataMode(chart, styles); + } else { + applyDatasetMode(chart, styles); + } + }, + }; + + return plugin; +} + +/** + * Applies colors in 'dataset' mode - each `dataset` (series) gets a unique color. + * @param chart The chart instance + * @param styles The chart styles to use + */ +function applyDatasetMode(chart: Chart, styles: SkyChartStyles): void { + const datasets = chart.data.datasets; + const colors = styles.series; + + 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 styles The chart styles to use + */ +function applyDataMode(chart: Chart, styles: SkyChartStyles): void { + const datasets = chart.data.datasets; + const colors = styles.series; + + 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.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.ts new file mode 100644 index 0000000000..61881cf149 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.ts @@ -0,0 +1,106 @@ +import { ActiveElement, BarElement, ChartArea } from 'chart.js'; + +import { IndicatorBounds, IndicatorStyles } from './indicator-types'; + +export function getBarIndicatorBounds( + chartArea: ChartArea, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): IndicatorBounds { + const bars = activeElements.map((el) => getBarGeometry(el)); + + if (activeElements.length === 1) { + return getSingleBarBounds(bars[0], chartArea, styles); + } else { + throw new Error( + 'Multiple active points is not supported for bar indicators', + ); + } +} + +/** 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; +} + +function getBarGeometry(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 getSingleBarBounds( + 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, + }; +} diff --git a/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-helpers.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-helpers.ts new file mode 100644 index 0000000000..0a1f980eb0 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-helpers.ts @@ -0,0 +1,63 @@ +import type { ActiveElement, ArcElement } from 'chart.js'; + +/** + * Returns the resolved geometry of the first active donut slice, or `null` + * if there are no active elements. + */ +export function getDonutActiveElement( + activeElements: ActiveElement[], +): DonutSliceGeometry | null { + if (!activeElements.length) return null; + + // Donut charts should have a single dataset + if (activeElements.length === 1) { + const activeElement = activeElements[0]; + return getArcGeometry(activeElement); + } else { + throw new Error( + 'Multiple active points is not supported for donut indicators', + ); + } +} + +function getArcGeometry(activeElement: ActiveElement): DonutSliceGeometry { + 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, + offset: arc.options.offset, + }; +} + +/** + * Translates the canvas context to account for a slice's hover offset so that + * the indicator arc follows the slice when it is pushed outward. + */ +export function applyDonutSliceOffset( + ctx: CanvasRenderingContext2D, + el: DonutSliceGeometry, +): void { + const offset = el.offset; + if (offset === 0) return; + const midAngle = (el.startAngle + el.endAngle) / 2; + ctx.translate(Math.cos(midAngle) * offset, Math.sin(midAngle) * offset); +} + +export interface DonutSliceGeometry { + x: number; + y: number; + startAngle: number; + endAngle: number; + innerRadius: number; + outerRadius: number; + offset: number; +} 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..7ef393742e --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.ts @@ -0,0 +1,228 @@ +import type { ActiveElement, Chart, ChartType } from 'chart.js'; + +import { getChartType, getDatasetType } from '../../chart-helpers'; + +import { getBarIndicatorBounds } from './bar-indicator-bounds'; +import { + applyDonutSliceOffset, + getDonutActiveElement, +} from './donut-indicator-helpers'; +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. + */ +export function drawIndicatorFill( + chart: Chart, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): void { + const { ctx } = chart; + const { padding, borderRadius, backgroundColor } = styles; + const chartType = getChartType(chart); + + if (chartType === 'doughnut') { + const el = getDonutActiveElement(activeElements); + if (!el) return; + + ctx.save(); + applyDonutSliceOffset(ctx, el); + ctx.fillStyle = backgroundColor; + ctx.beginPath(); + // prettier-ignore + ctx.arc(el.x, el.y, el.outerRadius + padding, el.startAngle, el.endAngle); + // prettier-ignore + ctx.arc(el.x, el.y, el.innerRadius - padding, el.endAngle, el.startAngle, true); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + return; + } else { + // prettier-ignore + const bounds = getCartesianIndicatorBounds(chart, activeElements, styles); + if (!bounds) return; + + const radii = getIndicatorCornerRadii(chart, borderRadius); + + ctx.save(); + ctx.fillStyle = backgroundColor; + ctx.beginPath(); + // prettier-ignore + ctx.roundRect(bounds.x, bounds.y, bounds.width, bounds.height, radii); + 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. + */ +export function drawIndicatorStroke( + chart: Chart, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): void { + const { ctx } = chart; + const { padding, borderRadius, borderColor, borderWidth } = styles; + const chartType = getChartType(chart); + + if (chartType === 'doughnut') { + const el = getDonutActiveElement(activeElements); + if (!el) return; + + ctx.save(); + applyDonutSliceOffset(ctx, el); + ctx.strokeStyle = borderColor; + ctx.lineWidth = borderWidth; + // Trace the full slice outline: outer arc → inner arc (anticlockwise) → close + ctx.beginPath(); + // prettier-ignore + ctx.arc(el.x, el.y, el.outerRadius + padding, el.startAngle, el.endAngle); + // prettier-ignore + ctx.arc(el.x, el.y, el.innerRadius - padding, el.endAngle, el.startAngle, true); + ctx.closePath(); + ctx.stroke(); + ctx.restore(); + return; + } else { + // prettier-ignore + const bounds = getCartesianIndicatorBounds(chart, activeElements, styles); + if (!bounds) return; + + const radii = getIndicatorCornerRadii(chart, borderRadius); + + ctx.save(); + ctx.strokeStyle = borderColor; + ctx.lineWidth = borderWidth; + ctx.beginPath(); + // prettier-ignore + ctx.roundRect(bounds.x, bounds.y, bounds.width, bounds.height, radii); + ctx.stroke(); + ctx.restore(); + } +} + +// #region Private + +/** + * 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]; +} +/** + * Groups active elements by their dataset's resolved type, computes + * bounds for each group using the type-specific strategy, then merges + * them into a single enclosing rectangle. + */ +function getCartesianIndicatorBounds( + chart: Chart, + activeElements: ActiveElement[], + styles: IndicatorStyles, +): IndicatorBounds | null { + const groups = groupByDatasetType(chart, activeElements); + const allBounds: IndicatorBounds[] = []; + + for (const [type, elements] of groups) { + allBounds.push(getBoundsForType(chart, elements, type, styles)); + } + + if (!allBounds.length) return null; + + return mergeBounds(allBounds); +} + +/** + * Partitions active elements into groups keyed by their dataset's + * resolved ChartType. Preserves insertion order. + */ +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. + */ +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. + */ +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..a359a1b434 --- /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 */ + dataPointsClickable?: 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.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.ts new file mode 100644 index 0000000000..367d30f151 --- /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.dataPointsClickable; + 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.dataPointsClickable) { + 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.dataPointsClickable) { + 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..f0f479dcc0 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-types.ts @@ -0,0 +1,14 @@ +export interface IndicatorStyles { + padding: number; + borderRadius: number; + borderColor: string; + borderWidth: number; + backgroundColor: string; +} + +export interface IndicatorBounds { + x: number; + y: number; + width: number; + height: number; +} 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..1032c4b2c1 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.ts @@ -0,0 +1,56 @@ +import { ActiveElement, PointElement } from 'chart.js'; + +import { IndicatorBounds, IndicatorStyles } from './indicator-types'; + +export function getLineIndicatorBounds( + activeElements: ActiveElement[], + styles: IndicatorStyles, +): IndicatorBounds { + const points = activeElements.map((el) => getPointGeometry(el)); + + if (activeElements.length === 1) { + return getSinglePointBounds(points[0], styles); + } else { + throw new Error( + 'Multiple active points is not supported for line indicators', + ); + } +} + +/** The geometry of a point element */ +interface LinePointGeometry { + /** The x center */ + x: number; + /** The y center */ + y: number; + /** The points radius */ + radius: number; +} + +function getPointGeometry(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 getSinglePointBounds( + 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, + }; +} 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..9a769d153d --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.ts @@ -0,0 +1,168 @@ +import type { ActiveElement, Chart } from 'chart.js'; + +import type { + ElementDescription, + FocusedElement, + NavigationStrategy, +} from './navigation-strategy'; +import type { NavigationKey } from './keys'; + +/** + * 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 | null { + const meta = this.#chart.getDatasetMeta(datasetIndex); + const dataElement = meta?.data[index]; + + if (!dataElement) { + return null; + } + + 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.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.ts b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.ts new file mode 100644 index 0000000000..55be8ee0b0 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.ts @@ -0,0 +1,370 @@ +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 | null = null; + #strategy: NavigationStrategy | null = null; + #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 = null; + this.#strategy = null; + + // 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 = {} as ChartEvent; + 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 { + 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 { + 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 { + 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 | null { + const meta = this.#chart.getDatasetMeta(datasetIndex); + const dataElement = meta?.data[index]; + + if (!dataElement) { + return null; + } + + return { datasetIndex, index, element: dataElement }; + } +} 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..0703d37f6a --- /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); +} \ No newline at end of file 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.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.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.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/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..e06cc4cdbe --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.ts @@ -0,0 +1,160 @@ +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(); + } + } + + /** + * 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; + } +} 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..c4f028a322 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/chart-style.service.spec.ts @@ -0,0 +1,535 @@ +import { RendererFactory2 } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + SkyTheme, + SkyThemeMode, + SkyThemeService, + SkyThemeSettings, +} from '@skyux/theme'; + +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 series colors', () => { + const { service } = setupTest({ theme }); + expect(service.styles().series).toEqual(DefaultTheme.series); + }); + + 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 series colors', () => { + const { service } = setupTest({ theme }); + expect(service.styles().series).toEqual(ModernTheme.series); + }); + + 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); + }); + }); +}); + +// #region Test Data +const DefaultTheme: SkyChartStyles = { + series: [ + '#06a39e', + '#6d3c96', + '#5589dd', + '#004252', + '#ce5600', + '#822325', + '#c650c1', + '#077e43', + ], + 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, + }, + line: { + tension: 0.2, + borderWidth: 2, + pointRadius: 4, + pointHoverRadius: 6, + pointBorderWidth: 2, + }, + donut: { + borderColor: '#ffffff', + borderWidth: 1, + }, + }, +}; + +const ModernTheme: SkyChartStyles = { + series: [ + '#06a39e', + '#6d3c96', + '#5589dd', + '#004252', + '#ce5600', + '#822325', + '#c650c1', + '#077e43', + ], + 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, + }, + 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..b72be427bf --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/chart-style.service.ts @@ -0,0 +1,422 @@ +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 = { + series: this.#series(), + 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)); + } + } + + #series(): string[] { + const colors = [ + 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'), + ]; + + return colors; + } + + #axis(): SkyChartStyles['axis'] { + const border: SkyChartStyles['axis']['border'] = { + color: this.#cssUtils.css('--sky-color-viz-axis', '#85888d'), + width: 1, + }; + + const grid: SkyChartStyles['axis']['grid'] = { + 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, 44, 63, 0.5) + '1px 2px 4px 0 rgba(33, 35, 39, 0.5)', + ); + // prettier-ignore + const baseShadowColor = this.#cssUtils.extractShadowColor(shadow) || 'rgba(0, 0, 0, 0.15)'; + // prettier-ignore + 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, // TODO: Confirm if there is a CSS Property we should use. Also 1 + Radius can feel cramped. Might want to increase to 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'), + }; + } + + #line(): SkyChartStyles['charts']['line'] { + // eslint-disable-next-line @cspell/spellchecker -- this icon size is valid in our design system + 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 { + series: string[]; + fontFamily: string; + chartPadding: number; + axis: { + border: { + color: string; + width: number; + }; + grid: { + color: string; + width: number; + }; + ticks: { + fontSize: number; + fontWeight: number; + lineHeight: string; + color: string; + padding: number; + measureLength: number; + categoryLength: number; + }; + title: { + fontSize: number; + fontWeight: number; + lineHeight: string; + color: string; + paddingTop: number; + paddingBottom: number; + }; + }; + tooltip: { + backgroundColor: string; + borderColor: string; + borderWidth: number; + cornerRadius: number; + padding: { top: number; right: number; bottom: number; left: number }; + shadow: { + color: string; + blur: number; + offsetX: number; + offsetY: number; + }; + caret: { + padding: number; + size: number; + }; + box: { + height: number; + width: number; + padding: number; + }; + title: { + fontSize: number; + fontWeight: number; + lineHeight: string; + color: string; + marginBottom: number; + }; + body: { + fontSize: number; + fontWeight: number; + lineHeight: string; + color: string; + bodySpacing: number; + }; + footer: { + fontSize: number; + fontWeight: number; + lineHeight: string; + color: string; + marginTop: number; + }; + }; + indicator: { + padding: number; + borderRadius: number; + hover: { + borderWidth: number; + borderColor: string; + backgroundColor: string; + }; + active: { + borderWidth: number; + borderColor: string; + backgroundColor: string; + }; + focus: { + borderWidth: number; + borderColor: string; + backgroundColor: string; + }; + }; + charts: { + bar: { + borderColor: string; + borderWidth: number; + borderRadius: number; + }; + line: { + tension: number; + borderWidth: number; + pointRadius: number; + pointHoverRadius: number; + pointBorderWidth: number; + }; + donut: { + borderColor: string; + borderWidth: number; + }; + }; +} 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..5cdc184f8c --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/services/global-chart-config.service.ts @@ -0,0 +1,117 @@ +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, + + // 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..23e6a2346a --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/sky-charts-resources.module.ts @@ -0,0 +1,38 @@ +/* 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.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.list_label': { message: 'Chart legend' }, + 'chart.menu.view_data_table': { message: 'View data table' }, + 'chart_data_grid.category_column_name': { message: 'Category' }, + 'chart_data_grid.close_button': { message: 'Close' }, + }, +}; + +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..fe3ae958c3 --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/axis-types.ts @@ -0,0 +1,44 @@ +/** + * 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 suggested minimum value for the axis. + * If not specified, the chart will automatically determine the minimum based on the data. + */ + suggestedMin?: number; + + /** + * The suggested maximum value for the axis. + * If not specified, the chart will automatically determine the minimum based on the data. + */ + suggestedMax?: number; +} 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-clicked-data-point.ts b/libs/components/charts/src/lib/modules/shared/types/chart-clicked-data-point.ts new file mode 100644 index 0000000000..88b2c68c4c --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/types/chart-clicked-data-point.ts @@ -0,0 +1,15 @@ +import type { SkyCategory } from './category'; + +/** + * Data emitted when a chart's data point is activated. + */ +export interface SkyChartClickedDataPoint { + /** 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/bar-chart/bar-chart-harness.filters.ts b/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.filters.ts new file mode 100644 index 0000000000..fd79e57337 --- /dev/null +++ b/libs/components/charts/testing/src/modules/bar-chart/bar-chart-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 `SkyBarChartHarness` instances. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface, @typescript-eslint/no-empty-object-type +export interface SkyBarChartHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.spec.ts b/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.spec.ts new file mode 100644 index 0000000000..144d1b5dcc --- /dev/null +++ b/libs/components/charts/testing/src/modules/bar-chart/bar-chart-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 { SkyBarChartHarness } from './bar-chart-harness'; +import { BarChartHarnessTestComponent } from './fixtures/bar-chart-harness-test.component'; + +describe('Bar chart test harness', () => { + async function setupTest( + options: { + dataSkyId?: string; + } = {}, + ): Promise<{ + boxHarness: SkyBarChartHarness; + fixture: ComponentFixture; + loader: HarnessLoader; + }> { + await TestBed.configureTestingModule({ + imports: [BarChartHarnessTestComponent, SkyHelpTestingModule], + }).compileComponents(); + + const fixture = TestBed.createComponent(BarChartHarnessTestComponent); + const loader = TestbedHarnessEnvironment.documentRootLoader(fixture); + + const barChartHarness: SkyBarChartHarness = options.dataSkyId + ? await loader.getHarness( + SkyBarChartHarness.with({ dataSkyId: options.dataSkyId }), + ) + : await loader.getHarness(SkyBarChartHarness); + + 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/bar-chart/bar-chart-harness.ts b/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts new file mode 100644 index 0000000000..9cbdfb1f3a --- /dev/null +++ b/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts @@ -0,0 +1,24 @@ +import { HarnessPredicate } from '@angular/cdk/testing'; +import { SkyComponentHarness } from '@skyux/core/testing'; + +import { SkyBarChartHarnessFilters } from './bar-chart-harness.filters'; + +/** + * Harness for interacting with a bar chart component in tests. + */ +export class SkyBarChartHarness extends SkyComponentHarness { + /** + * @internal + */ + public static hostSelector = 'sky-bar-chart'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a + * `SkyBarChartHarness` that meets certain criteria + */ + public static with( + filters: SkyBarChartHarnessFilters, + ): HarnessPredicate { + return SkyBarChartHarness.getDataSkyIdPredicate(filters); + } +} diff --git a/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.html b/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.html new file mode 100644 index 0000000000..1333ed77b7 --- /dev/null +++ b/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.html @@ -0,0 +1 @@ +TODO diff --git a/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.ts b/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.ts new file mode 100644 index 0000000000..31f29f4dea --- /dev/null +++ b/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +// #region Test component +@Component({ + selector: 'sky-bar-chart-fixture', + templateUrl: './bar-chart-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..f1270d9d2b --- /dev/null +++ b/libs/components/charts/testing/src/public-api.ts @@ -0,0 +1,2 @@ +export { SkyBarChartHarness } from './modules/bar-chart/bar-chart-harness'; +export { SkyBarChartHarnessFilters } from './modules/bar-chart/bar-chart-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" From 59c76b556984bb681344a99c8fc86e68914fa8d3 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Thu, 2 Apr 2026 22:51:33 -0400 Subject: [PATCH 02/34] feat(charts): update measure axis with min/max/preferredMin/preferredMax --- .../bar-chart-demo.component.html | 2 +- .../line-chart-demo.component.html | 4 +- .../axis/chart-measure-axis.component.ts | 47 +++++++++++++++---- .../bar-chart/bar-chart-config.service.ts | 13 +++-- .../line-chart/line-chart-config.service.ts | 12 +++-- .../lib/modules/shared/types/axis-types.ts | 22 +++++---- 6 files changed, 71 insertions(+), 29 deletions(-) diff --git a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html index 27f8761376..f91fe9882a 100644 --- a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html @@ -186,7 +186,7 @@ diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html index 1256af20d3..033efabf84 100644 --- a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html @@ -173,8 +173,8 @@ @for (series of logSingleSeries; track series.labelText) { 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 index 36b22f29ff..3fefa1f0c3 100644 --- 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 @@ -7,8 +7,12 @@ import { inject, input, } from '@angular/core'; +import { SkyLogService } from '@skyux/core'; -import { SkyChartMeasureAxisConfig, SkyChartAxisLabelText } from '../shared/types/axis-types'; +import { + SkyChartAxisLabelText, + SkyChartMeasureAxisConfig, +} from '../shared/types/axis-types'; import { SKY_CHART_AXIS_REGISTRY } from './sky-chart-axis-registry.service'; @@ -21,6 +25,7 @@ import { SKY_CHART_AXIS_REGISTRY } from './sky-chart-axis-registry.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkyChartMeasureAxisComponent implements OnDestroy { + readonly #logger = inject(SkyLogService); readonly #registry = inject(SKY_CHART_AXIS_REGISTRY); /** @@ -35,16 +40,24 @@ export class SkyChartMeasureAxisComponent implements OnDestroy { public readonly scaleType = input<'linear' | 'logarithmic'>('linear'); /** - * The suggested lower bound for the measure axis. - * The chart may still go below this value if the data requires it. + * The lower bound for the measure axis. The chart will not go below this value. + */ + public readonly min = input(); + + /** + * The upper bound for the measure axis. The chart will not exceed this value. + */ + public readonly max = input(); + + /** + * The preferred lower bound for the measure axis. The chart may still go below this value if the data requires it. */ - public readonly suggestedMin = input(); + public readonly preferredMin = input(); /** - * The suggested upper bound for the measure axis. - * The chart may still exceed this value if the data requires it. + * The preferred upper bound for the measure axis. The chart may still exceed this value if the data requires it. */ - public readonly suggestedMax = input(); + public readonly preferredMax = input(); /** * The axis object @@ -54,14 +67,30 @@ export class SkyChartMeasureAxisComponent implements OnDestroy { return { labelText: this.labelText(), scaleType: this.scaleType(), - suggestedMin: this.suggestedMin(), - suggestedMax: this.suggestedMax() + min: this.min(), + max: this.max(), + preferredMin: this.preferredMin(), + preferredMax: this.preferredMax(), }; }); constructor() { effect(() => { const axis = this.axis(); + const { min, max, preferredMin, preferredMax } = axis; + + if (min !== undefined && preferredMin !== undefined) { + this.#logger.warn( + 'Both `min` and `preferredMin` are set on the measure axis. The `preferredMin` value will be ignored because `min` sets a hard lower bound.', + ); + } + + if (max !== undefined && preferredMax !== undefined) { + this.#logger.warn( + 'Both `max` and `preferredMax` are set on the measure axis. The `preferredMax` value will be ignored because `max` sets a hard upper bound.', + ); + } + this.#registry.upsertMeasureAxis(axis); }); } diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts index de49099351..e7e0043b51 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts @@ -257,9 +257,10 @@ export class SkyBarChartConfigService { const valueScale: PartialBarScale = { type: 'linear', stacked: options.stacked ?? false, - beginAtZero: true, - suggestedMin: options.measureAxis?.suggestedMin, - suggestedMax: options.measureAxis?.suggestedMax, + min: options.measureAxis?.min, + max: options.measureAxis?.max, + suggestedMin: options.measureAxis?.preferredMin, + suggestedMax: options.measureAxis?.preferredMax, grid: base.grid, border: base.border, ticks: { @@ -285,8 +286,10 @@ export class SkyBarChartConfigService { const valueScale: PartialBarScale = { type: 'logarithmic', stacked: options.stacked ?? false, - suggestedMin: options.measureAxis?.suggestedMin, - suggestedMax: options.measureAxis?.suggestedMax, + min: options.measureAxis?.min, + max: options.measureAxis?.max, + suggestedMin: options.measureAxis?.preferredMin, + suggestedMax: options.measureAxis?.preferredMax, grid: { ...base.grid, lineWidth: (ctx) => { diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts index a8a96c9cef..6246b3a56e 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts @@ -240,8 +240,10 @@ export class SkyLineChartConfigService { const valueScale: PartialLineScale = { type: 'linear', stacked: config.stacked ?? false, - suggestedMin: config.measureAxis?.suggestedMin, - suggestedMax: config.measureAxis?.suggestedMax, + min: config.measureAxis?.min, + max: config.measureAxis?.max, + suggestedMin: config.measureAxis?.preferredMin, + suggestedMax: config.measureAxis?.preferredMax, grid: base.grid, border: base.border, ticks: { @@ -267,8 +269,10 @@ export class SkyLineChartConfigService { const valueScale: PartialLineScale = { type: 'logarithmic', stacked: config.stacked ?? false, - suggestedMin: config.measureAxis?.suggestedMin, - suggestedMax: config.measureAxis?.suggestedMax, + min: config.measureAxis?.min, + max: config.measureAxis?.max, + suggestedMin: config.measureAxis?.preferredMin, + suggestedMax: config.measureAxis?.preferredMax, grid: { ...base.grid, lineWidth: (ctx) => { 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 index fe3ae958c3..80658e48e5 100644 --- a/libs/components/charts/src/lib/modules/shared/types/axis-types.ts +++ b/libs/components/charts/src/lib/modules/shared/types/axis-types.ts @@ -11,8 +11,6 @@ export interface SkyChartAxisConfig { labelText?: SkyChartAxisLabelText; } - - /** * Configuration for chart category axis settings. * @internal @@ -31,14 +29,22 @@ export interface SkyChartMeasureAxisConfig extends SkyChartAxisConfig { scaleType?: 'linear' | 'logarithmic'; /** - * The suggested minimum value for the axis. - * If not specified, the chart will automatically determine the minimum based on the data. + * 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; + + /** + * The preferred minimum value for the axis. The axis may still go below this value if the data requires it. */ - suggestedMin?: number; + preferredMin?: number; /** - * The suggested maximum value for the axis. - * If not specified, the chart will automatically determine the minimum based on the data. + * The preferred maximum value for the axis. The axis may still exceed this value if the data requires it. */ - suggestedMax?: number; + preferredMax?: number; } From ee3f88fff72ddc5ef82da5581c43ecfecd559213 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Thu, 2 Apr 2026 22:55:57 -0400 Subject: [PATCH 03/34] feat(components/charts): remove null values from bar and line chart data point types --- .../src/lib/modules/bar-chart/bar-chart-config.service.ts | 1 + .../charts/src/lib/modules/bar-chart/bar-chart-types.ts | 3 +-- .../src/lib/modules/line-chart/line-chart-config.service.ts | 1 + .../charts/src/lib/modules/line-chart/line-chart-types.ts | 3 +-- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts index e7e0043b51..7b494568c9 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts @@ -59,6 +59,7 @@ export class SkyBarChartConfigService { dataByCategory.set(p.category, p.value); } + // Backfill null for categories missing from this series const data = categories.map((category) => { return dataByCategory.get(category) ?? null; }); diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts index c5989e0eeb..92f98c5418 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts @@ -7,9 +7,8 @@ export type SkyBarChartOrientation = 'vertical' | 'horizontal'; /** * A bar chart data point, which can be a single numeric value or a range (tuple of two numbers). - * A `null` value represents a gap in the data. */ -export type SkyBarDatum = number | [number, number] | null; +export type SkyBarDatum = number | [number, number]; /** * A single data point within a bar chart series. diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts index 6246b3a56e..9113a06b4c 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts @@ -52,6 +52,7 @@ export class SkyLineChartConfigService { dataByCategory.set(p.category, p.value); } + // Backfill null for categories missing from this series const data = categories.map((category) => { return dataByCategory.get(category) ?? null; }); diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts index b2bc65f17a..aa56e82045 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts @@ -2,9 +2,8 @@ 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. - * A `null` value represents a gap in the data. */ -export type SkyLineDatum = number | null; +export type SkyLineDatum = number; /** * A single data point within a line chart series. From b4dbc6b42fc817096c05462a4f39915f6c05ce1a Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 6 Apr 2026 15:09:18 -0400 Subject: [PATCH 04/34] bar chart: remove floating bar ([min, max] range) support --- .../bar-chart/bar-chart-series-datapoint.component.ts | 1 - .../charts/src/lib/modules/bar-chart/bar-chart-types.ts | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts index 0ff3f514e0..a2ead0034d 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts @@ -40,7 +40,6 @@ export class SkyBarChartSeriesDatapointComponent implements OnDestroy { /** * The numeric value for this data point. - * Accepts a single number, a floating-bar range `[min, max]`, or `null` for a gap. */ public readonly value = input.required(); diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts index 92f98c5418..06f7d36383 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts @@ -6,14 +6,14 @@ import { SkyChartDataPoint } from '../shared/types/chart-data-point'; export type SkyBarChartOrientation = 'vertical' | 'horizontal'; /** - * A bar chart data point, which can be a single numeric value or a range (tuple of two numbers). + * A bar chart data point, which can be a single numeric value. */ -export type SkyBarDatum = number | [number, number]; +export type SkyBarDatum = number; /** * A single data point within a bar chart series. */ export interface SkyBarChartPoint extends SkyChartDataPoint { - /** Numeric value or floating range */ + /** The bar value */ value: SkyBarDatum; } From c7065fdffb0029de73935573ca85d27626971085 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Wed, 8 Apr 2026 18:12:42 -0400 Subject: [PATCH 05/34] feat(playground/charts): enhance chart demos with new Data density and Responsiveness demos --- .../bar-chart-demo.component.html | 727 +++++++++++++--- .../bar-chart-demo.component.ts | 419 +++------ .../donut-chart-demo.component.html | 360 +++++++- .../donut-chart-demo.component.ts | 27 +- .../line-chart-demo.component.html | 807 +++++++++++++++++- .../line-chart-demo.component.ts | 402 +++------ .../charts/shared/chart-demo-utils.ts | 105 ++- 7 files changed, 2081 insertions(+), 766 deletions(-) diff --git a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html index f91fe9882a..3399c40186 100644 --- a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html @@ -2,16 +2,28 @@ +
+ + + + +
+ - + @@ -39,7 +51,7 @@ /> @for ( - series of verticalSingleSeries; + series of linear.singleSeries; track series.labelText ) { @@ -62,8 +74,8 @@ @@ -74,8 +86,8 @@ [headingStyle]="3" > @@ -86,7 +98,7 @@ /> @for ( - series of verticalMultiSeries; + series of linear.multiSeries; track series.labelText ) { @@ -109,8 +121,8 @@ @@ -123,8 +135,8 @@ [subtitleText]="'Spending over a month'" > @@ -134,8 +146,8 @@ scaleType="linear" /> - @for (series of verticalStacked; track series.label) { - + @for (series of linear.stacked; track series.labelText) { + @for (point of series.data; track $index) { - + @@ -190,7 +202,7 @@ /> - @for (point of verticalLog[0].data; track $index) { + @for (point of log.singleSeries[0].data; track $index) { @@ -220,8 +232,8 @@ [headingStyle]="3" > @@ -231,33 +243,17 @@ scaleType="logarithmic" /> - - - - - - - - - - - + @for (series of log.multiSeries; track series.labelText) { + + @for (point of series.data; track $index) { + + } + + } @@ -268,8 +264,8 @@ @@ -280,8 +276,8 @@ [headingStyle]="3" > @@ -291,8 +287,8 @@ scaleType="logarithmic" /> - @for (series of verticalStackedLog; track series.label) { - + @for (series of log.stacked; track series.labelText) { + @for (point of series.data; track $index) { - + + - + - + @for ( - series of horizontalSingleSeries; + series of density.single1x3; track series.labelText ) { @@ -364,24 +361,27 @@ - + - + @@ -392,7 +392,7 @@ /> @for ( - series of horizontalMultiSeries; + series of density.single1x6; track series.labelText ) { @@ -411,35 +411,91 @@ - + - + - + + + + @for ( + series of density.single1x9; + track series.labelText + ) { + + @for (point of series.data; track $index) { + + } + + } + + + + + + + + + + + + + - @for (series of horizontalStacked; track series.label) { - + @for ( + series of density.single1x12; + track series.labelText + ) { + @for (point of series.data; track $index) { - - - - + - + - + @for ( - series of horizontalLogSingleSeries; + series of density.multi3x8; track series.labelText ) { @@ -511,35 +563,37 @@ - + - + @for ( - series of horizontalLogMultiSeries; + series of density.multi6x8; track series.labelText ) { @@ -558,38 +612,485 @@ - + - + + + + + + @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 horizontalLogStacked; - track series.label + series of linear.singleSeries; + track series.labelText ) { - + @for (point of series.data; track $index) { ( + 'vertical', + ); - public readonly verticalMultiSeries = [ - { - 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 }, - ], - }, - ]; + 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, + }), + }; - public readonly verticalStacked = [ - { - label: 'Dataset 1', - data: ChartDemoUtils.numbers({ - count: 7, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: 'Dataset 2', - data: ChartDemoUtils.numbers({ - count: 7, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: 'Dataset 3', - data: ChartDemoUtils.numbers({ - count: 7, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - ]; - - public readonly verticalLog = [ - { - label: 'Dataset 1', - data: [1, 1.1, 1.9, 2.1, 4.9, 5.1, 9, 11, 90, 110, 900, 1100, 9000].map( - (value, index) => { - return { + 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: value, - }; - }, - ), - }, - ]; - - public readonly verticalStackedLog = [ - { - label: 'Dataset 1', - data: ChartDemoUtils.numbers({ - count: 7, - min: 1, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: 'Dataset 2', - data: ChartDemoUtils.numbers({ - count: 7, - min: 1, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: 'Dataset 3', - data: ChartDemoUtils.numbers({ - count: 7, - min: 1, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - ]; - // #endregion - - // #region Horizontal - public readonly horizontalSingleSeries = [ - { - 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 }, - ], - }, - ]; - - public readonly horizontalMultiSeries = [ - { - labelText: 'Budget', - data: [ - { category: 'Revenue', label: '$120,000', value: 120_000 }, - { category: 'Expenses', label: '$85,000', value: 85_000 }, - ], - }, - { - labelText: 'Actuals', - data: [ - { category: 'Revenue', label: '$115,000', value: 115_000 }, - { category: 'Expenses', label: '$78,000', value: 78_000 }, - ], - }, - ]; - - public readonly horizontalStacked = [ - { - label: 'Dataset 1', - data: ChartDemoUtils.numbers({ - count: 7, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: 'Dataset 2', - data: ChartDemoUtils.numbers({ - count: 7, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: 'Dataset 3', - data: ChartDemoUtils.numbers({ - count: 7, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }), - }, - ]; - - public readonly horizontalLog = []; - - public readonly horizontalLogSingleSeries = [ - { - 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 }, - ], - }, - ]; - - public readonly horizontalLogMultiSeries = [ - { - labelText: 'Budget', - data: [ - { category: 'Revenue', label: '$120,000', value: 120_000 }, - { category: 'Expenses', label: '$85,000', value: 85_000 }, - ], - }, - { - labelText: 'Actuals', - data: [ - { category: 'Revenue', label: '$115,000', value: 115_000 }, - { category: 'Expenses', label: '$78,000', value: 78_000 }, - ], - }, - ]; - - public readonly horizontalLogStacked = [ - { - label: 'Dataset 1', - data: ChartDemoUtils.numbers({ count: 7, min: 1, max: 1_000 }).map( - (value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }, - ), - }, - { - label: 'Dataset 2', - data: ChartDemoUtils.numbers({ count: 7, min: 1, max: 1_000 }).map( - (value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, value, - }; - }, - ), - }, - { - label: 'Dataset 3', - data: ChartDemoUtils.numbers({ count: 7, min: 1, max: 1_000 }).map( - (value, index) => { - return { - category: ChartDemoUtils.months({ count: 7 })[index], - label: `$${value}`, - value, - }; - }, - ), - }, - ]; - // #endregion + }), + ), + }, + ], + 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 onDataPointClicked( event: SkyChartClickedDataPoint, diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html index 58f549ac3d..777cafb990 100644 --- a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html @@ -1,44 +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) { - - } - - - - - - - - + + + + + @for (item of chart1; track $index) { + + } + + + + + + + + + +
diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts index f3a954fe5d..12c25e0414 100644 --- a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts +++ b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts @@ -9,6 +9,12 @@ import { } 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-donut-chart-demo', @@ -16,6 +22,7 @@ import { SkyPageModule } from '@skyux/pages'; styles: [], imports: [ SkyPageModule, + SkyTabsModule, SkyDonutChartComponent, SkyBoxModule, SkyFluidGridModule, @@ -27,29 +34,39 @@ import { SkyPageModule } from '@skyux/pages'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class DonutChartDemoComponent { - protected chart1 = [ + protected chart1: DemoSeriesData[] = [ { - name: 'Securities', + category: 'Securities', value: 5_000_000, label: '$5,000,000', }, { - name: 'Income/Compensation', + category: 'Income/Compensation', value: 2_500_000, label: '$2,500,000', }, { - name: 'Private Co. Valuation', + category: 'Private Co. Valuation', value: 1_500_000, label: '$1,500,000', }, { - name: 'Real Estate', + 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 onDataPointClicked( event: SkyChartClickedDataPoint, ): void { diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html index 033efabf84..18d4500ca4 100644 --- a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html @@ -37,7 +37,10 @@ scaleType="linear" /> - @for (series of singleSeries; track series.labelText) { + @for ( + series of linear.singleSeries; + track series.labelText + ) { @for (point of series.data; track $index) { - @for (series of multiSeries; track series.label) { - + @for ( + series of linear.multiSeries; + track series.labelText + ) { + @for (point of series.data; track $index) { - @for (series of stacked; track series.label) { - + @for (series of linear.stacked; track series.labelText) { + @for (point of series.data; track $index) { - @for (series of logSingleSeries; track series.labelText) { + @for ( + series of log.singleSeries; + track series.labelText + ) { @for (point of series.data; track $index) { - @for (series of logMultiSeries; track series.label) { - + @for (series of log.multiSeries; track series.labelText) { + @for (point of series.data; track $index) { - @for (series of logStacked; track series.label) { - + @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) { { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2023', - data: ChartDemoUtils.numbers({ - count: 12, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2024', - data: ChartDemoUtils.numbers({ - count: 12, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2025', - data: ChartDemoUtils.numbers({ - count: 12, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - ]; + 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, + }), + }; - public readonly stacked = [ - { - label: '2022', - data: ChartDemoUtils.numbers({ - count: 12, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2023', - data: ChartDemoUtils.numbers({ - count: 12, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2024', - data: ChartDemoUtils.numbers({ - count: 12, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2025', - data: ChartDemoUtils.numbers({ - count: 12, - min: 0, - max: 100, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - ]; - // #endregion - - // #region Logarithmic Scale - public readonly logSingleSeries = [ - { - 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 }, - ], - }, - ]; - - public readonly logMultiSeries = [ - { - label: '2022', - data: ChartDemoUtils.numbers({ - count: 12, - min: 1, - max: 1_000, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2023', - data: ChartDemoUtils.numbers({ - count: 12, - min: 1, - max: 1_000, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2024', - data: ChartDemoUtils.numbers({ - count: 12, - min: 1, - max: 1_000, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2025', - data: ChartDemoUtils.numbers({ - count: 12, - min: 1, - max: 1_000, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - ]; - - public readonly logStacked = [ - { - label: '2022', - data: ChartDemoUtils.numbers({ - count: 12, - min: 1, - max: 1_000, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2023', - data: ChartDemoUtils.numbers({ - count: 12, - min: 1, - max: 1_000, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2024', - data: ChartDemoUtils.numbers({ - count: 12, - min: 1, - max: 1_000, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - { - label: '2025', - data: ChartDemoUtils.numbers({ - count: 12, - min: 1, - max: 1_000, - decimals: 0, - }).map((value, index) => { - return { - category: ChartDemoUtils.months({ count: 12 })[index], - label: `$${value}`, - value, - }; - }), - }, - ]; - // #endregion + 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 onDataPointClicked( event: SkyChartClickedDataPoint, 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 index bd22071cc7..c525c71d6f 100644 --- a/apps/playground/src/app/components/charts/shared/chart-demo-utils.ts +++ b/apps/playground/src/app/components/charts/shared/chart-demo-utils.ts @@ -60,19 +60,6 @@ export class ChartDemoUtils { return data; } - public static points(config: { - min?: number; - max?: number; - from?: number[]; - count?: number; - decimals?: number; - continuity?: number; - }): { x: number; y: number }[] { - const xs = this.numbers(config); - const ys = this.numbers(config); - return xs.map((x, i) => ({ x, y: ys[i] })); - } - public static months(config: { count?: number; section?: number }): string[] { const cfg = config ?? {}; const count = cfg.count ?? 12; @@ -87,4 +74,96 @@ export class ChartDemoUtils { 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; } From 7b2580fe90034e335e57873d56d7d534e4bb7a8f Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Thu, 9 Apr 2026 14:36:51 -0400 Subject: [PATCH 06/34] add initial work for Data Density and Responsiveness handling --- .../bar-chart/bar-chart-config.service.ts | 141 +++++++++++++++++- .../modules/bar-chart/bar-chart.component.ts | 16 +- .../donut-chart/donut-chart-config.service.ts | 9 ++ .../donut-chart/donut-chart.component.ts | 15 +- .../line-chart/line-chart-config.service.ts | 9 ++ .../line-chart/line-chart.component.ts | 15 +- .../services/chart-css-utils.service.ts | 22 +++ .../shared/services/chart-style.service.ts | 36 +++++ .../services/global-chart-config.service.ts | 1 + 9 files changed, 256 insertions(+), 8 deletions(-) diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts index 7b494568c9..d587801fbd 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { + BarControllerDatasetOptions, ChartConfiguration, ChartDataset, ChartOptions, @@ -40,7 +41,7 @@ export class SkyBarChartConfigService { /** * 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 bar chart options + * @param options The bar chart options */ public buildConfig(options: SkyBarChartOptions): ChartConfiguration<'bar'> { const styles = this.#chartStyleService.styles(); @@ -106,10 +107,7 @@ export class SkyBarChartConfigService { axis: options.orientation === 'vertical' ? 'x' : 'y', }, datasets: { - bar: { - categoryPercentage: 0.7, - // barPercentage: 0.7, - }, + bar: this.#getBarDatasetOptions(options, categories.length, styles), }, elements: { bar: { @@ -154,6 +152,138 @@ export class SkyBarChartConfigService { 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: SkyBarChartOptions): 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: SkyBarChartOptions, + 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: SkyBarChartOptions, @@ -313,6 +443,7 @@ export class SkyBarChartConfigService { return valueScale; } + // #endregion } // #region Types diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts index 38fc4f425c..e3dc1d9733 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts @@ -41,7 +41,7 @@ import { selector: 'sky-bar-chart', template: ` @if (chartConfiguration(); as config) { -
+
(); // #endregion // #region Outputs @@ -90,6 +96,14 @@ export class SkyBarChartComponent { 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 dataPointsClickable = this.dataPointsClickable(); const orientation = this.orientation(); diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts index 9c5f3b452b..d7dc35d49a 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts @@ -117,6 +117,15 @@ export class SkyDonutChartConfigService { 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 { diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts index 7450b132a7..b25c061bfe 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts @@ -32,7 +32,7 @@ import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; selector: 'sky-donut-chart', template: ` @if (chartConfiguration(); as config) { -
+
(); // #endregion // #region Outputs @@ -72,6 +78,13 @@ export class SkyDonutChartComponent { // #endregion protected readonly arialLabel = this.#chartService.headingText; + + /** The height of the chart */ + protected readonly chartHeight = computed(() => { + const explicitHeight = this.height(); + return explicitHeight ?? this.#chartConfigService.getChartHeight(); + }); + readonly #chart = computed(() => this.chartDirective()?.chart()); readonly #chartUpdated = signal(0); readonly #refreshLegendItems = signal(0); diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts index 9113a06b4c..d3dbe66911 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts @@ -147,6 +147,15 @@ export class SkyLineChartConfigService { 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: SkyLineChartOptions, diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts index bebccac192..e5ea90d9d5 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts @@ -37,7 +37,7 @@ import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; selector: 'sky-line-chart', template: ` @if (chartConfiguration(); as config) { -
+
(); // #endregion // #region Outputs @@ -81,6 +87,13 @@ export class SkyLineChartComponent { // #endregion protected readonly arialLabel = this.#chartService.headingText; + + /** The height of the chart */ + protected readonly chartHeight = computed(() => { + const explicitHeight = this.height(); + return explicitHeight ?? this.#chartConfigService.getChartHeight(); + }); + readonly #chart = computed(() => this.chartDirective()?.chart()); readonly #chartUpdated = signal(0); readonly #refreshLegendItems = signal(0); 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 index e06cc4cdbe..455959465a 100644 --- 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 @@ -107,6 +107,17 @@ export class SkyChartCssUtilsService { } } + /** + * 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 @@ -157,4 +168,15 @@ export class SkyChartCssUtilsService { 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-style.service.ts b/libs/components/charts/src/lib/modules/shared/services/chart-style.service.ts index b72be427bf..e6f417849e 100644 --- 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 @@ -24,6 +24,7 @@ export class SkyChartStyleService { const styles: SkyChartStyles = { series: this.#series(), + height: this.#height(), fontFamily: this.#cssUtils.css( '--sky-font-family-primary', 'Blackbaud Sans, Arial, sans-serif', @@ -66,6 +67,17 @@ export class SkyChartStyleService { return colors; } + #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'), @@ -277,6 +289,14 @@ export class SkyChartStyleService { ), 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'), + }, }; } @@ -310,6 +330,14 @@ export class SkyChartStyleService { /** Defines the structure of chart styles */ export interface SkyChartStyles { series: string[]; + height: { + /** The minimum height for charts in pixels. */ + min: number; + /** The default height for charts in pixels */ + max: number; + /** The default CSS height value for charts. */ + default: string; + }; fontFamily: string; chartPadding: number; axis: { @@ -406,6 +434,14 @@ export interface SkyChartStyles { borderColor: string; borderWidth: number; borderRadius: number; + vertical: { + maxBarThickness: number; + }; + horizontal: { + minCategoryGap: number; + minBarThickness: number; + maxBarThickness: number; + }; }; line: { tension: number; 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 index 5cdc184f8c..8a318a93ee 100644 --- 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 @@ -60,6 +60,7 @@ export class SkyChartGlobalConfigService { // Responsiveness responsive: true, maintainAspectRatio: false, + resizeDelay: 150, // Layout padding layout: { padding: styles.chartPadding }, From c0bc80dc125e59e4d1acf878b098db47dd0d353c Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Fri, 10 Apr 2026 14:45:41 -0400 Subject: [PATCH 07/34] refactor: simplify indicator drawing and add documentation --- .../donut-chart/donut-chart-config.service.ts | 2 +- .../plugins/indicator/bar-indicator-bounds.ts | 61 ++++--- .../indicator/donut-indicator-bounds.ts | 57 +++++++ .../indicator/donut-indicator-helpers.ts | 63 ------- .../plugins/indicator/indicator-draw.ts | 155 +++++++++--------- .../plugins/indicator/indicator-types.ts | 31 ++++ .../indicator/line-indicator-bounds.ts | 40 +++-- 7 files changed, 212 insertions(+), 197 deletions(-) create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-bounds.ts delete mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-helpers.ts diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts index d7dc35d49a..bd9f3f807c 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts @@ -71,7 +71,7 @@ export class SkyDonutChartConfigService { const chartOptions: ChartOptions<'doughnut'> = { layout: { // Add some extra layout padding for the offset indicators - padding: styles.chartPadding + 10, + padding: styles.chartPadding + 15, }, interaction: { mode: 'nearest', 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 index 61881cf149..66c98de847 100644 --- 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 @@ -2,43 +2,22 @@ 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) => getBarGeometry(el)); - - if (activeElements.length === 1) { - return getSingleBarBounds(bars[0], chartArea, styles); - } else { - throw new Error( - 'Multiple active points is not supported for bar indicators', - ); - } + const bars = activeElements.map((el) => getGeometry(el)); + return getBounds(bars[0], chartArea, styles); } -/** 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; -} - -function getBarGeometry(activeElement: ActiveElement): BarGeometry { +function getGeometry(activeElement: ActiveElement): BarGeometry { const bar = activeElement.element as BarElement; const props = bar.getProps( ['x', 'y', 'width', 'height', 'base', 'horizontal'], @@ -55,7 +34,7 @@ function getBarGeometry(activeElement: ActiveElement): BarGeometry { }; } -function getSingleBarBounds( +function getBounds( bar: BarGeometry, chartArea: ChartArea, styles: IndicatorStyles, @@ -104,3 +83,23 @@ function getSingleBarBounds( 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.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/donut-indicator-helpers.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-helpers.ts deleted file mode 100644 index 0a1f980eb0..0000000000 --- a/libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-helpers.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ActiveElement, ArcElement } from 'chart.js'; - -/** - * Returns the resolved geometry of the first active donut slice, or `null` - * if there are no active elements. - */ -export function getDonutActiveElement( - activeElements: ActiveElement[], -): DonutSliceGeometry | null { - if (!activeElements.length) return null; - - // Donut charts should have a single dataset - if (activeElements.length === 1) { - const activeElement = activeElements[0]; - return getArcGeometry(activeElement); - } else { - throw new Error( - 'Multiple active points is not supported for donut indicators', - ); - } -} - -function getArcGeometry(activeElement: ActiveElement): DonutSliceGeometry { - 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, - offset: arc.options.offset, - }; -} - -/** - * Translates the canvas context to account for a slice's hover offset so that - * the indicator arc follows the slice when it is pushed outward. - */ -export function applyDonutSliceOffset( - ctx: CanvasRenderingContext2D, - el: DonutSliceGeometry, -): void { - const offset = el.offset; - if (offset === 0) return; - const midAngle = (el.startAngle + el.endAngle) / 2; - ctx.translate(Math.cos(midAngle) * offset, Math.sin(midAngle) * offset); -} - -export interface DonutSliceGeometry { - x: number; - y: number; - startAngle: number; - endAngle: number; - innerRadius: number; - outerRadius: number; - offset: number; -} 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 index 7ef393742e..6b32ec85d4 100644 --- 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 @@ -3,110 +3,91 @@ import type { ActiveElement, Chart, ChartType } from 'chart.js'; import { getChartType, getDatasetType } from '../../chart-helpers'; import { getBarIndicatorBounds } from './bar-indicator-bounds'; -import { - applyDonutSliceOffset, - getDonutActiveElement, -} from './donut-indicator-helpers'; +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 { - const { ctx } = chart; - const { padding, borderRadius, backgroundColor } = styles; - const chartType = getChartType(chart); - - if (chartType === 'doughnut') { - const el = getDonutActiveElement(activeElements); - if (!el) return; - - ctx.save(); - applyDonutSliceOffset(ctx, el); - ctx.fillStyle = backgroundColor; - ctx.beginPath(); - // prettier-ignore - ctx.arc(el.x, el.y, el.outerRadius + padding, el.startAngle, el.endAngle); - // prettier-ignore - ctx.arc(el.x, el.y, el.innerRadius - padding, el.endAngle, el.startAngle, true); - ctx.closePath(); - ctx.fill(); - ctx.restore(); + if (activeElements.length === 0) { return; - } else { - // prettier-ignore - const bounds = getCartesianIndicatorBounds(chart, activeElements, styles); - if (!bounds) return; - - const radii = getIndicatorCornerRadii(chart, borderRadius); - - ctx.save(); - ctx.fillStyle = backgroundColor; - ctx.beginPath(); - // prettier-ignore - ctx.roundRect(bounds.x, bounds.y, bounds.width, bounds.height, radii); - ctx.fill(); - ctx.restore(); } + + 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 { - const { ctx } = chart; - const { padding, borderRadius, borderColor, borderWidth } = styles; - const chartType = getChartType(chart); - - if (chartType === 'doughnut') { - const el = getDonutActiveElement(activeElements); - if (!el) return; - - ctx.save(); - applyDonutSliceOffset(ctx, el); - ctx.strokeStyle = borderColor; - ctx.lineWidth = borderWidth; - // Trace the full slice outline: outer arc → inner arc (anticlockwise) → close + 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(); - // prettier-ignore - ctx.arc(el.x, el.y, el.outerRadius + padding, el.startAngle, el.endAngle); - // prettier-ignore - ctx.arc(el.x, el.y, el.innerRadius - padding, el.endAngle, el.startAngle, true); + ctx.arc(x, y, outerRadius, startAngle, endAngle); + ctx.arc(x, y, outerRadius, endAngle, startAngle, true); ctx.closePath(); - ctx.stroke(); - ctx.restore(); - return; } else { - // prettier-ignore const bounds = getCartesianIndicatorBounds(chart, activeElements, styles); - if (!bounds) return; + const radii = getIndicatorCornerRadii(chart, styles.borderRadius); - const radii = getIndicatorCornerRadii(chart, borderRadius); - - ctx.save(); - ctx.strokeStyle = borderColor; - ctx.lineWidth = borderWidth; ctx.beginPath(); - // prettier-ignore ctx.roundRect(bounds.x, bounds.y, bounds.width, bounds.height, radii); - ctx.stroke(); - ctx.restore(); } } -// #region Private - +// #region Cartesian Charts /** * Returns per-corner border radii so that only the non-axis end of the indicator box is rounded. * @@ -138,16 +119,19 @@ function getIndicatorCornerRadii( return [borderRadius, borderRadius, borderRadius, borderRadius]; } + /** - * Groups active elements by their dataset's resolved type, computes - * bounds for each group using the type-specific strategy, then merges - * them into a single enclosing rectangle. + * 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 | null { +): IndicatorBounds { const groups = groupByDatasetType(chart, activeElements); const allBounds: IndicatorBounds[] = []; @@ -155,14 +139,14 @@ function getCartesianIndicatorBounds( allBounds.push(getBoundsForType(chart, elements, type, styles)); } - if (!allBounds.length) return null; - return mergeBounds(allBounds); } /** - * Partitions active elements into groups keyed by their dataset's - * resolved ChartType. Preserves insertion order. + * 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, @@ -187,6 +171,11 @@ function groupByDatasetType( /** * 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, @@ -194,16 +183,20 @@ function getBoundsForType( type: ChartType, styles: IndicatorStyles, ): IndicatorBounds { - if (type === 'bar') + if (type === 'bar') { return getBarIndicatorBounds(chart.chartArea, elements, styles); - if (type === 'line') return getLineIndicatorBounds(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. + * 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; 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 index f0f479dcc0..d6b2294cbb 100644 --- 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 @@ -1,14 +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.ts b/libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.ts index 1032c4b2c1..82d4ebebae 100644 --- 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 @@ -2,32 +2,20 @@ 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) => getPointGeometry(el)); - - if (activeElements.length === 1) { - return getSinglePointBounds(points[0], styles); - } else { - throw new Error( - 'Multiple active points is not supported for line indicators', - ); - } -} - -/** The geometry of a point element */ -interface LinePointGeometry { - /** The x center */ - x: number; - /** The y center */ - y: number; - /** The points radius */ - radius: number; + const points = activeElements.map((el) => getGeometry(el)); + return getBounds(points[0], styles); } -function getPointGeometry(activeElement: ActiveElement): LinePointGeometry { +function getGeometry(activeElement: ActiveElement): LinePointGeometry { const point = activeElement.element as PointElement; const props = point.getProps(['x', 'y'], true); @@ -38,7 +26,7 @@ function getPointGeometry(activeElement: ActiveElement): LinePointGeometry { }; } -function getSinglePointBounds( +function getBounds( point: LinePointGeometry, styles: IndicatorStyles, ): IndicatorBounds { @@ -54,3 +42,13 @@ function getSinglePointBounds( 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; +} From 957ad04ca1d3926e1e410dbb260b6079f84dcb98 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Fri, 10 Apr 2026 14:49:10 -0400 Subject: [PATCH 08/34] remove temporary comments --- .../shared/services/chart-style.service.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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 index e6f417849e..365673925d 100644 --- 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 @@ -85,6 +85,7 @@ export class SkyChartStyleService { }; const grid: SkyChartStyles['axis']['grid'] = { + // eslint-disable-next-line @cspell/spellchecker color: this.#cssUtils.css('--sky-color-viz-gridline', '#d5d6d8'), width: 1, }; @@ -130,13 +131,14 @@ export class SkyChartStyleService { #tooltip(): SkyChartStyles['tooltip'] { const shadow = this.#cssUtils.css( - '--sky-elevation-overlay-simple-100', // 1px 2px 4px 0 rgba(33, 44, 63, 0.5) + '--sky-elevation-overlay-simple-100', '1px 2px 4px 0 rgba(33, 35, 39, 0.5)', ); - // prettier-ignore - const baseShadowColor = this.#cssUtils.extractShadowColor(shadow) || 'rgba(0, 0, 0, 0.15)'; - // prettier-ignore - const tooltipShadowColor = this.#cssUtils.colorToRgbaWithAlpha(baseShadowColor, 0.6) || baseShadowColor; + 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', @@ -222,7 +224,7 @@ export class SkyChartStyleService { #indicator(): SkyChartStyles['indicator'] { return { - padding: 2, // TODO: Confirm if there is a CSS Property we should use. Also 1 + Radius can feel cramped. Might want to increase to 2. + padding: 2, borderRadius: this.#cssUtils.cssNumber('--sky-border-radius-s', '3px'), hover: this.#hoverIndicator(), active: this.#activeIndicator(), @@ -301,7 +303,7 @@ export class SkyChartStyleService { } #line(): SkyChartStyles['charts']['line'] { - // eslint-disable-next-line @cspell/spellchecker -- this icon size is valid in our design system + // eslint-disable-next-line @cspell/spellchecker const pointRadius = this.#cssUtils.cssNumber('--sky-size-icon-xxxs', '4px'); return { From 403261c55e98e1af43178d6494bbddf6c7d97171 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Fri, 10 Apr 2026 15:54:37 -0400 Subject: [PATCH 09/34] add comments to chart-style service --- .../plugins/auto-color/auto-color-plugin.ts | 24 ++- .../shared/services/chart-style.service.ts | 181 ++++++++++++++++-- 2 files changed, 171 insertions(+), 34 deletions(-) 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 index 38097ad111..ce3f00e468 100644 --- 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 @@ -1,10 +1,7 @@ import { Chart, ChartDataset, ChartType, Plugin } from 'chart.js'; import { isDatasetType, isDonutChart } from '../../chart-helpers'; -import { - SkyChartStyleService, - SkyChartStyles, -} from '../../services/chart-style.service'; +import { SkyChartStyleService } from '../../services/chart-style.service'; /** * Creates a ChartJS plugin that automatically applies SKY UX color palette to chart datasets. @@ -14,17 +11,20 @@ import { export function createAutoColorPlugin( styleService: SkyChartStyleService, ): Plugin { - const plugin: 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, styles); + applyDataMode(chart, colors); } else { - applyDatasetMode(chart, styles); + applyDatasetMode(chart, colors); } }, }; @@ -35,11 +35,10 @@ export function createAutoColorPlugin( /** * Applies colors in 'dataset' mode - each `dataset` (series) gets a unique color. * @param chart The chart instance - * @param styles The chart styles to use + * @param colors The color palette to apply to the datasets */ -function applyDatasetMode(chart: Chart, styles: SkyChartStyles): void { +function applyDatasetMode(chart: Chart, colors: string[]): void { const datasets = chart.data.datasets; - const colors = styles.series; datasets.forEach((dataset, datasetIndex) => { const color = colors[datasetIndex % colors.length]; @@ -50,11 +49,10 @@ function applyDatasetMode(chart: Chart, styles: SkyChartStyles): void { /** * Applies colors in 'data' mode - each `dataset.data` (series datapoint) gets a unique color. * @param chart The chart instance - * @param styles The chart styles to use + * @param colors The color palette to apply to the datasets */ -function applyDataMode(chart: Chart, styles: SkyChartStyles): void { +function applyDataMode(chart: Chart, colors: string[]): void { const datasets = chart.data.datasets; - const colors = styles.series; datasets.forEach((dataset) => { const backgroundColors: string[] = []; 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 index 365673925d..04ba4d6ae1 100644 --- 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 @@ -23,7 +23,7 @@ export class SkyChartStyleService { this.#themeVersion(); const styles: SkyChartStyles = { - series: this.#series(), + palettes: this.#palettes(), height: this.#height(), fontFamily: this.#cssUtils.css( '--sky-font-family-primary', @@ -52,8 +52,8 @@ export class SkyChartStyleService { } } - #series(): string[] { - const colors = [ + #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'), @@ -64,7 +64,51 @@ export class SkyChartStyleService { this.#cssUtils.css('--sky-theme-color-viz-category-8'), ]; - return colors; + 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'] { @@ -331,130 +375,225 @@ export class SkyChartStyleService { /** Defines the structure of chart styles */ export interface SkyChartStyles { - series: string[]; + /** 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 default height for charts in pixels */ + /** 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; - hover: { - borderWidth: number; - borderColor: string; - backgroundColor: string; - }; - active: { - borderWidth: number; - borderColor: string; - backgroundColor: string; - }; - focus: { - borderWidth: number; - borderColor: string; - backgroundColor: string; - }; + /** 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; +} From addc12e1226259e273fb44369296abbf34e101af Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 13 Apr 2026 09:57:11 -0400 Subject: [PATCH 10/34] fix: cleanup public api index.ts by removing things that no longer need to be exposed --- libs/components/charts/src/index.ts | 12 +++--------- .../bar-chart-series-datapoint.component.ts | 6 +++--- .../src/lib/modules/bar-chart/bar-chart-types.ts | 1 + .../donut-chart-series-datapoint.component.ts | 14 +++++--------- .../lib/modules/donut-chart/donut-chart-types.ts | 1 + .../line-chart-series-datapoint.component.ts | 14 +++++--------- .../src/lib/modules/line-chart/line-chart-types.ts | 1 + 7 files changed, 19 insertions(+), 30 deletions(-) diff --git a/libs/components/charts/src/index.ts b/libs/components/charts/src/index.ts index 0db25546db..43535c0d42 100644 --- a/libs/components/charts/src/index.ts +++ b/libs/components/charts/src/index.ts @@ -10,24 +10,18 @@ export { SkyBarChartComponent } from './lib/modules/bar-chart/bar-chart.componen export { SkyBarChartSeriesComponent } from './lib/modules/bar-chart/bar-chart-series.component'; export { SkyBarChartSeriesDatapointComponent } from './lib/modules/bar-chart/bar-chart-series-datapoint.component'; export { + SkyBarChartOrientation, SkyBarDatum, - SkyBarChartPoint, } from './lib/modules/bar-chart/bar-chart-types'; // Line Chart export { SkyLineChartComponent } from './lib/modules/line-chart/line-chart.component'; export { SkyLineChartSeriesComponent } from './lib/modules/line-chart/line-chart-series.component'; export { SkyLineChartSeriesDatapointComponent } from './lib/modules/line-chart/line-chart-series-datapoint.component'; -export { - SkyLineDatum, - SkyLineChartPoint, -} from './lib/modules/line-chart/line-chart-types'; +export { SkyLineDatum } from './lib/modules/line-chart/line-chart-types'; // Donut Chart export { SkyDonutChartComponent } from './lib/modules/donut-chart/donut-chart.component'; export { SkyDonutChartSeriesComponent } from './lib/modules/donut-chart/donut-chart-series.component'; export { SkyDonutChartSeriesDatapointComponent } from './lib/modules/donut-chart/donut-chart-series-datapoint.component'; -export { - SkyDonutDatum, - SkyDonutChartSlice, -} from './lib/modules/donut-chart/donut-chart-types'; +export { SkyDonutDatum } from './lib/modules/donut-chart/donut-chart-types'; diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts index a2ead0034d..da293d86d0 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts @@ -46,11 +46,11 @@ export class SkyBarChartSeriesDatapointComponent implements OnDestroy { /** * A unique ID for this data point component instance. */ - public readonly id = nextId++; + readonly #id = nextId++; readonly #datapoint = computed(() => { return { - id: this.id, + id: this.#id, category: this.category(), labelText: this.labelText(), value: this.value(), @@ -65,6 +65,6 @@ export class SkyBarChartSeriesDatapointComponent implements OnDestroy { } public ngOnDestroy(): void { - this.#registry.removePoint(this.#series.id, this.id); + this.#registry.removePoint(this.#series.id, this.#id); } } diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts index 06f7d36383..60ebee1117 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts @@ -12,6 +12,7 @@ export type SkyBarDatum = number; /** * A single data point within a bar chart series. + * @internal */ export interface SkyBarChartPoint extends SkyChartDataPoint { /** The bar value */ diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts index 7e49d3e9cf..b119f2267f 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts @@ -46,15 +46,11 @@ export class SkyDonutChartSeriesDatapointComponent implements OnDestroy { /** * A unique ID for this data point component instance. */ - public readonly id = nextId++; + readonly #id = nextId++; - /** - * The data point object - * @internal - */ - public readonly datapoint = computed(() => { + readonly #datapoint = computed(() => { return { - id: this.id, + id: this.#id, category: this.category(), labelText: this.labelText(), value: this.value(), @@ -63,12 +59,12 @@ export class SkyDonutChartSeriesDatapointComponent implements OnDestroy { constructor() { effect(() => { - const datapoint = this.datapoint(); + const datapoint = this.#datapoint(); this.#registry.upsertPoint(this.#series.id, datapoint); }); } public ngOnDestroy(): void { - this.#registry.removePoint(this.#series.id, this.id); + this.#registry.removePoint(this.#series.id, this.#id); } } diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts index 7dc0d7b705..9cbecf1d28 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts @@ -7,6 +7,7 @@ export type SkyDonutDatum = number; /** * A single data point within a donut chart series. + * @internal */ export interface SkyDonutChartSlice extends SkyChartDataPoint { /** Numeric value */ diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts index cfbb428777..d625227fae 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts @@ -46,15 +46,11 @@ export class SkyLineChartSeriesDatapointComponent implements OnDestroy { /** * A unique ID for this data point component instance. */ - public readonly id = nextId++; + readonly #id = nextId++; - /** - * The data point object - * @internal - */ - public readonly datapoint = computed(() => { + readonly #datapoint = computed(() => { return { - id: this.id, + id: this.#id, category: this.category(), labelText: this.labelText(), value: this.value(), @@ -63,12 +59,12 @@ export class SkyLineChartSeriesDatapointComponent implements OnDestroy { constructor() { effect(() => { - const datapoint = this.datapoint(); + const datapoint = this.#datapoint(); this.#registry.upsertPoint(this.#series.id, datapoint); }); } public ngOnDestroy(): void { - this.#registry.removePoint(this.#series.id, this.id); + this.#registry.removePoint(this.#series.id, this.#id); } } diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts index aa56e82045..8e04fb41c0 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts @@ -7,6 +7,7 @@ export type SkyLineDatum = number; /** * A single data point within a line chart series. + * @internal */ export interface SkyLineChartPoint extends SkyChartDataPoint { /** Numeric value */ From 7f4a710ff182b5bf4d946d5c5dee56ffe243ee30 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 13 Apr 2026 14:34:01 -0400 Subject: [PATCH 11/34] Update documentation.json with Chart, Axis, and Bar Chart components --- libs/components/charts/documentation.json | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/libs/components/charts/documentation.json b/libs/components/charts/documentation.json index 0c865d3fd5..937e4167f4 100644 --- a/libs/components/charts/documentation.json +++ b/libs/components/charts/documentation.json @@ -3,8 +3,15 @@ "groups": { "charts": { "development": { - "docsIds": [], - "primaryDocsId": "" + "docsIds": [ + "SkyBarChartComponent", + "SkyBarChartSeriesComponent", + "SkyBarChartSeriesDatapointComponent", + "SkyChartComponent", + "SkyChartCategoryAxisComponent", + "SkyChartMeasureAxisComponent" + ], + "primaryDocsId": "SkyChartComponent" }, "testing": { "docsIds": [] From 01ee3730ffe82856c0044c36bb288d350d94459d Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 11:07:53 -0400 Subject: [PATCH 12/34] refactor: Use numberAttribute for min/max/preferredMin/preferredMax --- .../src/lib/modules/axis/chart-measure-axis.component.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 3fefa1f0c3..2f7c19994d 100644 --- 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 @@ -6,6 +6,7 @@ import { effect, inject, input, + numberAttribute, } from '@angular/core'; import { SkyLogService } from '@skyux/core'; @@ -42,22 +43,22 @@ export class SkyChartMeasureAxisComponent implements OnDestroy { /** * The lower bound for the measure axis. The chart will not go below this value. */ - public readonly min = input(); + public readonly min = input({ transform: numberAttribute }); /** * The upper bound for the measure axis. The chart will not exceed this value. */ - public readonly max = input(); + public readonly max = input({ transform: numberAttribute }); /** * The preferred lower bound for the measure axis. The chart may still go below this value if the data requires it. */ - public readonly preferredMin = input(); + public readonly preferredMin = input({ transform: numberAttribute }); /** * The preferred upper bound for the measure axis. The chart may still exceed this value if the data requires it. */ - public readonly preferredMax = input(); + public readonly preferredMax = input({ transform: numberAttribute }); /** * The axis object From d42c355bb3a90d1213499bfef822f5ae9fc4a88a Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 11:32:19 -0400 Subject: [PATCH 13/34] refactor: update numberAttribute usage --- .../modules/axis/chart-measure-axis.component.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 index 2f7c19994d..be920fc018 100644 --- 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 @@ -43,22 +43,30 @@ export class SkyChartMeasureAxisComponent implements OnDestroy { /** * The lower bound for the measure axis. The chart will not go below this value. */ - public readonly min = input({ transform: numberAttribute }); + 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({ transform: numberAttribute }); + public readonly max = input(undefined, { + transform: numberAttribute, + }); /** * The preferred lower bound for the measure axis. The chart may still go below this value if the data requires it. */ - public readonly preferredMin = input({ transform: numberAttribute }); + public readonly preferredMin = input(undefined, { + transform: numberAttribute, + }); /** * The preferred upper bound for the measure axis. The chart may still exceed this value if the data requires it. */ - public readonly preferredMax = input({ transform: numberAttribute }); + public readonly preferredMax = input(undefined, { + transform: numberAttribute, + }); /** * The axis object From a33d58caa6a7794799acb19a5a29e3c6da6a289c Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 11:32:41 -0400 Subject: [PATCH 14/34] Mark `series.id` as internal --- .../src/lib/modules/bar-chart/bar-chart-series.component.ts | 1 + .../src/lib/modules/donut-chart/donut-chart-series.component.ts | 1 + .../src/lib/modules/line-chart/line-chart-series.component.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts index a31dc55908..6b8231f091 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts @@ -33,6 +33,7 @@ export class SkyBarChartSeriesComponent implements OnDestroy { /** * A unique ID for this series component instance. + * @internal */ public readonly id = nextId++; diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts index 6589a61e07..b96cee68ba 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts @@ -33,6 +33,7 @@ export class SkyDonutChartSeriesComponent implements OnDestroy { /** * A unique ID for this series component instance. + * @internal */ public readonly id = nextId++; diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts index 9858341f97..65777d4dc5 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts @@ -42,6 +42,7 @@ export class SkyLineChartSeriesComponent implements OnDestroy { /** * A unique ID for this series component instance. + * @internal */ public readonly id = nextId++; From 8194c178556971208746124919e049eb870fa012 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 13:13:27 -0400 Subject: [PATCH 15/34] refactor: rename chart component selectors to be prefixed with `sky-chart-` --- .../bar-chart-demo.component.html | 88 +++++++++---------- .../donut-chart-demo.component.html | 36 ++++---- .../line-chart-demo.component.html | 88 +++++++++---------- .../modules/bar-chart/bar-chart.component.ts | 2 +- .../src/lib/modules/chart/chart.component.ts | 2 +- .../donut-chart/donut-chart.component.ts | 2 +- .../line-chart/line-chart.component.ts | 2 +- .../modules/bar-chart/bar-chart-harness.ts | 2 +- 8 files changed, 111 insertions(+), 111 deletions(-) diff --git a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html index 3399c40186..102c336b5f 100644 --- a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html @@ -38,7 +38,7 @@ [subtitleText]="'Spending over a month'" [subtitleHidden]="true" > - } - + @@ -85,7 +85,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -134,7 +134,7 @@ [headingStyle]="3" [subtitleText]="'Spending over a month'" > - } - + @@ -188,7 +188,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -231,7 +231,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -275,7 +275,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -329,7 +329,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -379,7 +379,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -429,7 +429,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -479,7 +479,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -532,7 +532,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -581,7 +581,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -630,7 +630,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -679,7 +679,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -731,7 +731,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -781,7 +781,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -831,7 +831,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -881,7 +881,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -933,7 +933,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -979,7 +979,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -1025,7 +1025,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -1074,7 +1074,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html index 777cafb990..8c01c18261 100644 --- a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html @@ -23,7 +23,7 @@ [subtitleText]="'Spending over a month'" [subtitleHidden]="false" > - @@ -36,7 +36,7 @@ /> } - + @@ -62,7 +62,7 @@ [headingLevel]="3" [headingStyle]="3" > - @@ -75,7 +75,7 @@ /> } - + @@ -95,7 +95,7 @@ [headingLevel]="3" [headingStyle]="3" > - @@ -108,7 +108,7 @@ /> } - + @@ -128,7 +128,7 @@ [headingLevel]="3" [headingStyle]="3" > - @@ -141,7 +141,7 @@ /> } - + @@ -161,7 +161,7 @@ [headingLevel]="3" [headingStyle]="3" > - @@ -174,7 +174,7 @@ /> } - + @@ -200,7 +200,7 @@ [headingLevel]="3" [headingStyle]="3" > - @@ -213,7 +213,7 @@ /> } - + @@ -233,7 +233,7 @@ [headingLevel]="3" [headingStyle]="3" > - @@ -246,7 +246,7 @@ /> } - + @@ -266,7 +266,7 @@ [headingLevel]="3" [headingStyle]="3" > - @@ -279,7 +279,7 @@ /> } - + @@ -302,7 +302,7 @@ [headingLevel]="3" [headingStyle]="3" > - @@ -315,7 +315,7 @@ /> } - + diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html index 18d4500ca4..60d9656529 100644 --- a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html @@ -26,7 +26,7 @@ [subtitleText]="'Revenue over time'" [subtitleHidden]="false" > - } - + @@ -72,7 +72,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -118,7 +118,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -170,7 +170,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -221,7 +221,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -267,7 +267,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -320,7 +320,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -369,7 +369,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -418,7 +418,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -467,7 +467,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -519,7 +519,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -568,7 +568,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -617,7 +617,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -666,7 +666,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -715,7 +715,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -761,7 +761,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -807,7 +807,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -853,7 +853,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -904,7 +904,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -949,7 +949,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -994,7 +994,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + @@ -1042,7 +1042,7 @@ [headingLevel]="3" [headingStyle]="3" > - } - + diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts index e3dc1d9733..03eb427af4 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts @@ -38,7 +38,7 @@ import { * Displays a bar chart visualization. */ @Component({ - selector: 'sky-bar-chart', + selector: 'sky-chart-bar', template: ` @if (chartConfiguration(); as config) {
diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.ts b/libs/components/charts/src/lib/modules/chart/chart.component.ts index e8eca0524f..a591929de6 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.ts +++ b/libs/components/charts/src/lib/modules/chart/chart.component.ts @@ -36,7 +36,7 @@ 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-bar-chart`) inside it. + * Use this as the outer container and place a chart type component (e.g. `sky-chart-bar`) inside it. */ @Component({ selector: 'sky-chart', diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts index b25c061bfe..f93ebf0226 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts @@ -29,7 +29,7 @@ import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; * Displays a donut chart visualization. */ @Component({ - selector: 'sky-donut-chart', + selector: 'sky-chart-donut', template: ` @if (chartConfiguration(); as config) {
diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts index e5ea90d9d5..c23a17641e 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts @@ -34,7 +34,7 @@ import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; * Displays a line chart visualization. */ @Component({ - selector: 'sky-line-chart', + selector: 'sky-chart-line', template: ` @if (chartConfiguration(); as config) {
diff --git a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts b/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts index 9cbdfb1f3a..a047ea74e4 100644 --- a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts +++ b/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts @@ -10,7 +10,7 @@ export class SkyBarChartHarness extends SkyComponentHarness { /** * @internal */ - public static hostSelector = 'sky-bar-chart'; + public static hostSelector = 'sky-chart-bar'; /** * Gets a `HarnessPredicate` that can be used to search for a From 9c5c540a623246f94ab95b1c16f76122468f702e Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 13:25:11 -0400 Subject: [PATCH 16/34] refactor: Rename SkyChartClickedDataPoint to SkyChartDataPointClickArgs --- .../charts/bar-chart-demo/bar-chart-demo.component.ts | 4 ++-- .../charts/donut-chart-demo/donut-chart-demo.component.ts | 4 ++-- .../charts/line-chart-demo/line-chart-demo.component.ts | 4 ++-- libs/components/charts/src/index.ts | 2 +- .../src/lib/modules/bar-chart/bar-chart-config.service.ts | 4 ++-- .../charts/src/lib/modules/bar-chart/bar-chart.component.ts | 4 ++-- .../lib/modules/donut-chart/donut-chart-config.service.ts | 6 ++++-- .../src/lib/modules/donut-chart/donut-chart.component.ts | 4 ++-- .../src/lib/modules/line-chart/line-chart-config.service.ts | 4 ++-- .../src/lib/modules/line-chart/line-chart.component.ts | 4 ++-- ...clicked-data-point.ts => chart-data-point-click-args.ts} | 2 +- 11 files changed, 22 insertions(+), 20 deletions(-) rename libs/components/charts/src/lib/modules/shared/types/{chart-clicked-data-point.ts => chart-data-point-click-args.ts} (84%) diff --git a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts index f0f1d6df0b..4605828e08 100644 --- a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts +++ b/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts @@ -6,8 +6,8 @@ import { SkyBarChartSeriesDatapointComponent, type SkyBarDatum, SkyChartCategoryAxisComponent, - type SkyChartClickedDataPoint, SkyChartComponent, + type SkyChartDataPointClickArgs, SkyChartMeasureAxisComponent, } from '@skyux/charts'; import { SkyRadioModule } from '@skyux/forms'; @@ -167,7 +167,7 @@ export class BarChartDemoComponent { }; public onDataPointClicked( - event: SkyChartClickedDataPoint, + event: SkyChartDataPointClickArgs, ): void { console.log(JSON.stringify(event, null, 2)); } diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts index 12c25e0414..b3c827b7b4 100644 --- a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts +++ b/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { - type SkyChartClickedDataPoint, SkyChartComponent, + type SkyChartDataPointClickArgs, SkyDonutChartComponent, SkyDonutChartSeriesComponent, SkyDonutChartSeriesDatapointComponent, @@ -68,7 +68,7 @@ export class DonutChartDemoComponent { // #endregion public onDataPointClicked( - event: SkyChartClickedDataPoint, + event: SkyChartDataPointClickArgs, ): void { console.log(JSON.stringify(event, null, 2)); } diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts index 59857bbf5f..f5305ab7d8 100644 --- a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts +++ b/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts @@ -1,8 +1,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { SkyChartCategoryAxisComponent, - SkyChartClickedDataPoint, SkyChartComponent, + SkyChartDataPointClickArgs, SkyChartMeasureAxisComponent, SkyLineChartComponent, SkyLineChartSeriesComponent, @@ -148,7 +148,7 @@ export class LineChartDemoComponent { }; public onDataPointClicked( - event: SkyChartClickedDataPoint, + event: SkyChartDataPointClickArgs, ): void { console.log(JSON.stringify(event, null, 2)); } diff --git a/libs/components/charts/src/index.ts b/libs/components/charts/src/index.ts index 43535c0d42..8f8eae07c4 100644 --- a/libs/components/charts/src/index.ts +++ b/libs/components/charts/src/index.ts @@ -1,5 +1,5 @@ export { SkyChartComponent } from './lib/modules/chart/chart.component'; -export { SkyChartClickedDataPoint } from './lib/modules/shared/types/chart-clicked-data-point'; +export { SkyChartDataPointClickArgs } from './lib/modules/shared/types/chart-data-point-click-args'; // Axis export { SkyChartCategoryAxisComponent } from './lib/modules/axis/chart-category-axis.component'; diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts index d587801fbd..f53f4aeb0c 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts @@ -20,7 +20,7 @@ import { SkyChartMeasureAxisConfig, } from '../shared/types/axis-types'; import { SkyCategory } from '../shared/types/category'; -import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +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'; @@ -468,7 +468,7 @@ export interface SkyBarChartOptions { dataPointsClickable: boolean; callbacks?: { - onDataPointClick: (event: SkyChartClickedDataPoint) => void; + onDataPointClick: (event: SkyChartDataPointClickArgs) => void; }; } diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts b/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts index 03eb427af4..7477c50ca7 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts +++ b/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts @@ -20,7 +20,7 @@ import { SkyChartCategoryAxisConfig, SkyChartMeasureAxisConfig, } from '../shared/types/axis-types'; -import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; import { SkyChartSeries } from '../shared/types/chart-series'; import { @@ -84,7 +84,7 @@ export class SkyBarChartComponent { // #region Outputs public readonly dataPointClicked = - output>(); + output>(); // #endregion // #region View Children diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts index bd9f3f807c..d37c951f3d 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts @@ -9,7 +9,7 @@ import { import { SkyChartStyleService } from '../shared/services/chart-style.service'; import { SkyChartGlobalConfigService } from '../shared/services/global-chart-config.service'; -import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; import { SkyChartSeries } from '../shared/types/chart-series'; import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; @@ -154,7 +154,9 @@ export interface SkyDonutChartOptions { dataPointsClickable: boolean; callbacks?: { - onDataPointClick: (event: SkyChartClickedDataPoint) => void; + onDataPointClick: ( + event: SkyChartDataPointClickArgs, + ) => void; }; } // #endregion diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts b/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts index f93ebf0226..39c95b705d 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts +++ b/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts @@ -15,7 +15,7 @@ import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; import { SkyChartService } from '../chart/chart.service'; import { SkyChartJsDirective } from '../chartjs.directive'; import { getLegendItems } from '../shared/chart-helpers'; -import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; import { SkyChartSeries } from '../shared/types/chart-series'; import { @@ -70,7 +70,7 @@ export class SkyDonutChartComponent { // #region Outputs public readonly dataPointClicked = - output>(); + output>(); // #endregion // #region View Children diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts index d3dbe66911..aac6e9d7a0 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts @@ -19,7 +19,7 @@ import { SkyChartMeasureAxisConfig, } from '../shared/types/axis-types'; import { SkyCategory } from '../shared/types/category'; -import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +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'; @@ -326,7 +326,7 @@ export interface SkyLineChartOptions { dataPointsClickable: boolean; callbacks?: { - onDataPointClick: (event: SkyChartClickedDataPoint) => void; + onDataPointClick: (event: SkyChartDataPointClickArgs) => void; }; } diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts b/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts index c23a17641e..c713d29c21 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts +++ b/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts @@ -20,7 +20,7 @@ import { SkyChartCategoryAxisConfig, SkyChartMeasureAxisConfig, } from '../shared/types/axis-types'; -import type { SkyChartClickedDataPoint } from '../shared/types/chart-clicked-data-point'; +import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; import { SkyChartSeries } from '../shared/types/chart-series'; import { @@ -79,7 +79,7 @@ export class SkyLineChartComponent { // #region Outputs public readonly dataPointClicked = - output>(); + output>(); // #endregion // #region View Children diff --git a/libs/components/charts/src/lib/modules/shared/types/chart-clicked-data-point.ts b/libs/components/charts/src/lib/modules/shared/types/chart-data-point-click-args.ts similarity index 84% rename from libs/components/charts/src/lib/modules/shared/types/chart-clicked-data-point.ts rename to libs/components/charts/src/lib/modules/shared/types/chart-data-point-click-args.ts index 88b2c68c4c..09f784d464 100644 --- a/libs/components/charts/src/lib/modules/shared/types/chart-clicked-data-point.ts +++ b/libs/components/charts/src/lib/modules/shared/types/chart-data-point-click-args.ts @@ -3,7 +3,7 @@ import type { SkyCategory } from './category'; /** * Data emitted when a chart's data point is activated. */ -export interface SkyChartClickedDataPoint { +export interface SkyChartDataPointClickArgs { /** The series the activated data point belongs to. */ series: string; From 8d11b80790b2661ae4af1f9a0f041c6e37d5e7b5 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 14:43:38 -0400 Subject: [PATCH 17/34] refactor: rename file ands symbols to match the `sky-chart-*` / `SkyChart*` pattern --- .../chart-bar-demo-routes.ts} | 4 +- .../chart-bar-demo.component.html} | 132 +++++++++--------- .../chart-bar-demo.component.ts} | 24 ++-- .../chart-donut-demo-routes.ts} | 4 +- .../chart-donut-demo.component.html} | 54 +++---- .../chart-donut-demo.component.ts} | 24 ++-- .../chart-line-demo-routes.ts} | 4 +- .../chart-line-demo.component.html} | 132 +++++++++--------- .../chart-line-demo.component.ts} | 24 ++-- .../app/components/charts/charts-routes.ts | 12 +- libs/components/charts/documentation.json | 4 +- libs/components/charts/src/index.ts | 28 ++-- ...vice.ts => chart-axis-registry.service.ts} | 0 .../axis/chart-category-axis.component.ts | 7 +- .../axis/chart-measure-axis.component.ts | 2 +- .../chart-bar-config.service.ts} | 38 ++--- .../chart-bar-registry.service.ts} | 14 +- .../chart-bar-series-data-point.component.ts} | 18 +-- .../chart-bar-series.component.ts} | 12 +- .../chart-bar-types.ts} | 8 +- .../chart-bar.component.ts} | 38 ++--- .../chart-donut-config.service.ts} | 12 +- .../chart-donut-registry.service.ts} | 12 +- ...hart-donut-series-data-point.component.ts} | 18 +-- .../chart-donut-series.component.ts} | 12 +- .../chart-donut-types.ts} | 6 +- .../chart-donut.component.ts} | 24 ++-- .../chart-line-config.service.ts} | 26 ++-- .../chart-line-registry.service.ts} | 14 +- ...chart-line-series-data-point.component.ts} | 18 +-- .../chart-line-series.component.ts} | 16 +-- .../chart-line-types.ts} | 6 +- .../chart-line.component.ts} | 28 ++-- .../chart-bar-harness.filters.ts} | 4 +- .../chart-bar-harness.spec.ts} | 12 +- .../chart-bar-harness.ts} | 12 +- .../chart-bar-harness-test.component.html} | 0 .../chart-bar-harness-test.component.ts} | 2 +- .../charts/testing/src/public-api.ts | 4 +- 39 files changed, 408 insertions(+), 401 deletions(-) rename apps/playground/src/app/components/charts/{bar-chart-demo/bar-chart-demo-routes.ts => chart-bar-demo/chart-bar-demo-routes.ts} (73%) rename apps/playground/src/app/components/charts/{bar-chart-demo/bar-chart-demo.component.html => chart-bar-demo/chart-bar-demo.component.html} (91%) rename apps/playground/src/app/components/charts/{bar-chart-demo/bar-chart-demo.component.ts => chart-bar-demo/chart-bar-demo.component.ts} (91%) rename apps/playground/src/app/components/charts/{donut-chart-demo/donut-chart-demo-routes.ts => chart-donut-demo/chart-donut-demo-routes.ts} (73%) rename apps/playground/src/app/components/charts/{donut-chart-demo/donut-chart-demo.component.html => chart-donut-demo/chart-donut-demo.component.html} (87%) rename apps/playground/src/app/components/charts/{donut-chart-demo/donut-chart-demo.component.ts => chart-donut-demo/chart-donut-demo.component.ts} (76%) rename apps/playground/src/app/components/charts/{line-chart-demo/line-chart-demo-routes.ts => chart-line-demo/chart-line-demo-routes.ts} (73%) rename apps/playground/src/app/components/charts/{line-chart-demo/line-chart-demo.component.html => chart-line-demo/chart-line-demo.component.html} (90%) rename apps/playground/src/app/components/charts/{line-chart-demo/line-chart-demo.component.ts => chart-line-demo/chart-line-demo.component.ts} (90%) rename libs/components/charts/src/lib/modules/axis/{sky-chart-axis-registry.service.ts => chart-axis-registry.service.ts} (100%) rename libs/components/charts/src/lib/modules/{bar-chart/bar-chart-config.service.ts => chart-bar/chart-bar-config.service.ts} (94%) rename libs/components/charts/src/lib/modules/{bar-chart/bar-chart-registry.service.ts => chart-bar/chart-bar-registry.service.ts} (82%) rename libs/components/charts/src/lib/modules/{bar-chart/bar-chart-series-datapoint.component.ts => chart-bar/chart-bar-series-data-point.component.ts} (69%) rename libs/components/charts/src/lib/modules/{bar-chart/bar-chart-series.component.ts => chart-bar/chart-bar-series.component.ts} (74%) rename libs/components/charts/src/lib/modules/{bar-chart/bar-chart-types.ts => chart-bar/chart-bar-types.ts} (59%) rename libs/components/charts/src/lib/modules/{bar-chart/bar-chart.component.ts => chart-bar/chart-bar.component.ts} (87%) rename libs/components/charts/src/lib/modules/{donut-chart/donut-chart-config.service.ts => chart-donut/chart-donut-config.service.ts} (93%) rename libs/components/charts/src/lib/modules/{donut-chart/donut-chart-registry.service.ts => chart-donut/chart-donut-registry.service.ts} (79%) rename libs/components/charts/src/lib/modules/{donut-chart/donut-chart-series-datapoint.component.ts => chart-donut/chart-donut-series-data-point.component.ts} (69%) rename libs/components/charts/src/lib/modules/{donut-chart/donut-chart-series.component.ts => chart-donut/chart-donut-series.component.ts} (73%) rename libs/components/charts/src/lib/modules/{donut-chart/donut-chart-types.ts => chart-donut/chart-donut-types.ts} (67%) rename libs/components/charts/src/lib/modules/{donut-chart/donut-chart.component.ts => chart-donut/chart-donut.component.ts} (89%) rename libs/components/charts/src/lib/modules/{line-chart/line-chart-config.service.ts => chart-line/chart-line-config.service.ts} (93%) rename libs/components/charts/src/lib/modules/{line-chart/line-chart-registry.service.ts => chart-line/chart-line-registry.service.ts} (82%) rename libs/components/charts/src/lib/modules/{line-chart/line-chart-series-datapoint.component.ts => chart-line/chart-line-series-data-point.component.ts} (69%) rename libs/components/charts/src/lib/modules/{line-chart/line-chart-series.component.ts => chart-line/chart-line-series.component.ts} (69%) rename libs/components/charts/src/lib/modules/{line-chart/line-chart-types.ts => chart-line/chart-line-types.ts} (68%) rename libs/components/charts/src/lib/modules/{line-chart/line-chart.component.ts => chart-line/chart-line.component.ts} (88%) rename libs/components/charts/testing/src/modules/{bar-chart/bar-chart-harness.filters.ts => chart-bar/chart-bar-harness.filters.ts} (54%) rename libs/components/charts/testing/src/modules/{bar-chart/bar-chart-harness.spec.ts => chart-bar/chart-bar-harness.spec.ts} (74%) rename libs/components/charts/testing/src/modules/{bar-chart/bar-chart-harness.ts => chart-bar/chart-bar-harness.ts} (53%) rename libs/components/charts/testing/src/modules/{bar-chart/fixtures/bar-chart-harness-test.component.html => chart-bar/fixtures/chart-bar-harness-test.component.html} (100%) rename libs/components/charts/testing/src/modules/{bar-chart/fixtures/bar-chart-harness-test.component.ts => chart-bar/fixtures/chart-bar-harness-test.component.ts} (79%) diff --git a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo-routes.ts b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo-routes.ts similarity index 73% rename from apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo-routes.ts rename to apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo-routes.ts index 6b827e0fc9..0127539cd6 100644 --- a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo-routes.ts +++ b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo-routes.ts @@ -1,11 +1,11 @@ import { Routes } from '@angular/router'; -import { BarChartDemoComponent } from './bar-chart-demo.component'; +import { ChartBarDemoComponent } from './chart-bar-demo.component'; const BAR_CHART_ROUTES: Routes = [ { path: '', - component: BarChartDemoComponent, + component: ChartBarDemoComponent, title: 'Charts - Bar chart demo', data: { name: 'Bar chart', diff --git a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.html similarity index 91% rename from apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html rename to apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.html index 102c336b5f..87655ebc47 100644 --- a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.html @@ -54,15 +54,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -101,15 +101,15 @@ series of linear.multiSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -147,15 +147,15 @@ /> @for (series of linear.stacked; track series.labelText) { - + @for (point of series.data; track $index) { - } - + } @@ -201,15 +201,15 @@ [preferredMax]="10000" /> - + @for (point of log.singleSeries[0].data; track $index) { - } - + @@ -244,15 +244,15 @@ /> @for (series of log.multiSeries; track series.labelText) { - + @for (point of series.data; track $index) { - } - + } @@ -288,15 +288,15 @@ /> @for (series of log.stacked; track series.labelText) { - + @for (point of series.data; track $index) { - } - + } @@ -345,15 +345,15 @@ series of density.single1x3; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -395,15 +395,15 @@ series of density.single1x6; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -445,15 +445,15 @@ series of density.single1x9; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -495,15 +495,15 @@ series of density.single1x12; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -547,15 +547,15 @@ series of density.multi3x8; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -596,15 +596,15 @@ series of density.multi6x8; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -645,15 +645,15 @@ series of density.multi9x8; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -694,15 +694,15 @@ series of density.multi12x8; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -747,15 +747,15 @@ series of density.stacked3x3; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -797,15 +797,15 @@ series of density.stacked3x6; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -847,15 +847,15 @@ series of density.stacked3x9; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -897,15 +897,15 @@ series of density.stacked3x12; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -949,15 +949,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -995,15 +995,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -1041,15 +1041,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -1090,15 +1090,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } diff --git a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.ts similarity index 91% rename from apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts rename to apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.ts index 4605828e08..0badb8bbfa 100644 --- a/apps/playground/src/app/components/charts/bar-chart-demo/bar-chart-demo.component.ts +++ b/apps/playground/src/app/components/charts/chart-bar-demo/chart-bar-demo.component.ts @@ -1,10 +1,10 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { - SkyBarChartComponent, - SkyBarChartSeriesComponent, - SkyBarChartSeriesDatapointComponent, - type SkyBarDatum, + SkyChartBarComponent, + type SkyChartBarDatum, + SkyChartBarSeriesComponent, + SkyChartBarSeriesDataPointComponent, SkyChartCategoryAxisComponent, SkyChartComponent, type SkyChartDataPointClickArgs, @@ -19,27 +19,27 @@ import { SkyTabsModule } from '@skyux/tabs'; import { ChartDemoUtils } from '../shared/chart-demo-utils'; @Component({ - selector: 'app-bar-chart-demo', - templateUrl: './bar-chart-demo.component.html', + selector: 'app-chart-bar-demo', + templateUrl: './chart-bar-demo.component.html', styles: [], imports: [ FormsModule, SkyPageModule, - SkyBarChartComponent, + SkyChartBarComponent, SkyBoxModule, SkyRadioModule, SkyTabsModule, SkyFluidGridModule, - SkyBarChartComponent, - SkyBarChartSeriesComponent, - SkyBarChartSeriesDatapointComponent, + SkyChartBarComponent, + SkyChartBarSeriesComponent, + SkyChartBarSeriesDataPointComponent, SkyChartCategoryAxisComponent, SkyChartMeasureAxisComponent, SkyChartComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BarChartDemoComponent { +export class ChartBarDemoComponent { protected readonly orientation = signal<'vertical' | 'horizontal'>( 'vertical', ); @@ -167,7 +167,7 @@ export class BarChartDemoComponent { }; public onDataPointClicked( - event: SkyChartDataPointClickArgs, + event: SkyChartDataPointClickArgs, ): void { console.log(JSON.stringify(event, null, 2)); } diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo-routes.ts b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo-routes.ts similarity index 73% rename from apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo-routes.ts rename to apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo-routes.ts index 009fa13154..a9a3215788 100644 --- a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo-routes.ts +++ b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo-routes.ts @@ -1,11 +1,11 @@ import { Routes } from '@angular/router'; -import { DonutChartDemoComponent } from './donut-chart-demo.component'; +import { ChartDonutDemoComponent } from './chart-donut-demo.component'; const DONUT_CHART_ROUTES: Routes = [ { path: '', - component: DonutChartDemoComponent, + component: ChartDonutDemoComponent, title: 'Charts - Donut chart demo', data: { name: 'Donut chart', diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.html similarity index 87% rename from apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html rename to apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.html index 8c01c18261..e1a4c02935 100644 --- a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.html @@ -27,15 +27,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of chart1; track $index) { - } - + @@ -66,15 +66,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of density.three; track $index) { - } - + @@ -99,15 +99,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of density.six; track $index) { - } - + @@ -132,15 +132,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of density.nine; track $index) { - } - + @@ -165,15 +165,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of density.twelve; track $index) { - } - + @@ -204,15 +204,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of chart1; track $index) { - } - + @@ -237,15 +237,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of chart1; track $index) { - } - + @@ -270,15 +270,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of chart1; track $index) { - } - + @@ -306,15 +306,15 @@ dataPointsClickable (dataPointClicked)="onDataPointClicked($event)" > - + @for (item of chart1; track $index) { - } - + diff --git a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.ts similarity index 76% rename from apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts rename to apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.ts index b3c827b7b4..6f9134568d 100644 --- a/apps/playground/src/app/components/charts/donut-chart-demo/donut-chart-demo.component.ts +++ b/apps/playground/src/app/components/charts/chart-donut-demo/chart-donut-demo.component.ts @@ -2,10 +2,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { SkyChartComponent, type SkyChartDataPointClickArgs, - SkyDonutChartComponent, - SkyDonutChartSeriesComponent, - SkyDonutChartSeriesDatapointComponent, - SkyDonutDatum, + SkyChartDonutComponent, + SkyChartDonutDatum, + SkyChartDonutSeriesComponent, + SkyChartDonutSeriesDataPointComponent, } from '@skyux/charts'; import { SkyBoxModule, SkyFluidGridModule } from '@skyux/layout'; import { SkyPageModule } from '@skyux/pages'; @@ -17,23 +17,23 @@ import { } from '../shared/chart-demo-utils'; @Component({ - selector: 'app-donut-chart-demo', - templateUrl: 'donut-chart-demo.component.html', + selector: 'app-chart-donut-demo', + templateUrl: 'chart-donut-demo.component.html', styles: [], imports: [ SkyPageModule, SkyTabsModule, - SkyDonutChartComponent, + SkyChartDonutComponent, SkyBoxModule, SkyFluidGridModule, SkyChartComponent, - SkyDonutChartComponent, - SkyDonutChartSeriesComponent, - SkyDonutChartSeriesDatapointComponent, + SkyChartDonutComponent, + SkyChartDonutSeriesComponent, + SkyChartDonutSeriesDataPointComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DonutChartDemoComponent { +export class ChartDonutDemoComponent { protected chart1: DemoSeriesData[] = [ { category: 'Securities', @@ -68,7 +68,7 @@ export class DonutChartDemoComponent { // #endregion public onDataPointClicked( - event: SkyChartDataPointClickArgs, + event: SkyChartDataPointClickArgs, ): void { console.log(JSON.stringify(event, null, 2)); } diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo-routes.ts b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo-routes.ts similarity index 73% rename from apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo-routes.ts rename to apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo-routes.ts index bfbfd7c2c7..e105de0773 100644 --- a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo-routes.ts +++ b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo-routes.ts @@ -1,11 +1,11 @@ import { Routes } from '@angular/router'; -import { LineChartDemoComponent } from './line-chart-demo.component'; +import { ChartLineDemoComponent } from './chart-line-demo.component'; const LINE_CHART_ROUTES: Routes = [ { path: '', - component: LineChartDemoComponent, + component: ChartLineDemoComponent, title: 'Charts - Line chart demo', data: { name: 'Line chart', diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.html similarity index 90% rename from apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html rename to apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.html index 60d9656529..676da3048c 100644 --- a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.html +++ b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.html @@ -41,15 +41,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -87,15 +87,15 @@ series of linear.multiSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -130,15 +130,15 @@ /> @for (series of linear.stacked; track series.labelText) { - + @for (point of series.data; track $index) { - } - + } @@ -187,15 +187,15 @@ series of log.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -233,15 +233,15 @@ /> @for (series of log.multiSeries; track series.labelText) { - + @for (point of series.data; track $index) { - } - + } @@ -279,15 +279,15 @@ /> @for (series of log.stacked; track series.labelText) { - + @for (point of series.data; track $index) { - } - + } @@ -335,15 +335,15 @@ series of density.single1x3; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -384,15 +384,15 @@ series of density.single1x6; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -433,15 +433,15 @@ series of density.single1x9; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -482,15 +482,15 @@ series of density.single1x12; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -534,15 +534,15 @@ series of density.multi3x3; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -583,15 +583,15 @@ series of density.multi3x6; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -632,15 +632,15 @@ series of density.multi3x9; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -681,15 +681,15 @@ series of density.multi3x12; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -730,15 +730,15 @@ series of density.stacked3x3; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -776,15 +776,15 @@ series of density.stacked3x6; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -822,15 +822,15 @@ series of density.stacked3x9; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -868,15 +868,15 @@ series of density.stacked3x12; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -919,15 +919,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -964,15 +964,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -1009,15 +1009,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } @@ -1057,15 +1057,15 @@ series of linear.singleSeries; track series.labelText ) { - + @for (point of series.data; track $index) { - } - + } diff --git a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.ts similarity index 90% rename from apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts rename to apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.ts index f5305ab7d8..af1dea6903 100644 --- a/apps/playground/src/app/components/charts/line-chart-demo/line-chart-demo.component.ts +++ b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.ts @@ -3,11 +3,11 @@ import { SkyChartCategoryAxisComponent, SkyChartComponent, SkyChartDataPointClickArgs, + SkyChartLineComponent, + SkyChartLineDatum, + SkyChartLineSeriesComponent, + SkyChartLineSeriesDataPointComponent, SkyChartMeasureAxisComponent, - SkyLineChartComponent, - SkyLineChartSeriesComponent, - SkyLineChartSeriesDatapointComponent, - SkyLineDatum, } from '@skyux/charts'; import { SkyBoxModule } from '@skyux/layout'; import { SkyFluidGridModule } from '@skyux/layout'; @@ -17,24 +17,24 @@ import { SkyTabsModule } from '@skyux/tabs'; import { ChartDemoUtils } from '../shared/chart-demo-utils'; @Component({ - selector: 'app-bar-chart-demo', - templateUrl: 'line-chart-demo.component.html', + selector: 'app-chart-line-demo', + templateUrl: 'chart-line-demo.component.html', imports: [ SkyPageModule, SkyTabsModule, - SkyLineChartComponent, + SkyChartLineComponent, SkyBoxModule, SkyFluidGridModule, SkyChartComponent, - SkyLineChartComponent, - SkyLineChartSeriesComponent, - SkyLineChartSeriesDatapointComponent, + SkyChartLineComponent, + SkyChartLineSeriesComponent, + SkyChartLineSeriesDataPointComponent, SkyChartCategoryAxisComponent, SkyChartMeasureAxisComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class LineChartDemoComponent { +export class ChartLineDemoComponent { protected readonly linear = { singleSeries: [ { @@ -148,7 +148,7 @@ export class LineChartDemoComponent { }; public onDataPointClicked( - event: SkyChartDataPointClickArgs, + 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 index f79fed883f..313ae3b4ea 100644 --- a/apps/playground/src/app/components/charts/charts-routes.ts +++ b/apps/playground/src/app/components/charts/charts-routes.ts @@ -2,16 +2,16 @@ import { Routes } from '@angular/router'; const CHARTS_ROUTES: Routes = [ { - path: 'bar-chart-demos', - loadChildren: () => import('./bar-chart-demo/bar-chart-demo-routes'), + path: 'chart-bar-demos', + loadChildren: () => import('./chart-bar-demo/chart-bar-demo-routes'), }, { - path: 'line-chart-demos', - loadChildren: () => import('./line-chart-demo/line-chart-demo-routes'), + path: 'chart-line-demos', + loadChildren: () => import('./chart-line-demo/chart-line-demo-routes'), }, { - path: 'donut-chart-demos', - loadChildren: () => import('./donut-chart-demo/donut-chart-demo-routes'), + path: 'chart-donut-demos', + loadChildren: () => import('./chart-donut-demo/chart-donut-demo-routes'), }, ]; diff --git a/libs/components/charts/documentation.json b/libs/components/charts/documentation.json index 937e4167f4..ef37894f08 100644 --- a/libs/components/charts/documentation.json +++ b/libs/components/charts/documentation.json @@ -4,9 +4,9 @@ "charts": { "development": { "docsIds": [ - "SkyBarChartComponent", + "SkyChartBarComponent", "SkyBarChartSeriesComponent", - "SkyBarChartSeriesDatapointComponent", + "SkyChartBarSeriesDataPointComponent", "SkyChartComponent", "SkyChartCategoryAxisComponent", "SkyChartMeasureAxisComponent" diff --git a/libs/components/charts/src/index.ts b/libs/components/charts/src/index.ts index 8f8eae07c4..ec595d78e8 100644 --- a/libs/components/charts/src/index.ts +++ b/libs/components/charts/src/index.ts @@ -6,22 +6,22 @@ export { SkyChartCategoryAxisComponent } from './lib/modules/axis/chart-category export { SkyChartMeasureAxisComponent } from './lib/modules/axis/chart-measure-axis.component'; // Bar Chart -export { SkyBarChartComponent } from './lib/modules/bar-chart/bar-chart.component'; -export { SkyBarChartSeriesComponent } from './lib/modules/bar-chart/bar-chart-series.component'; -export { SkyBarChartSeriesDatapointComponent } from './lib/modules/bar-chart/bar-chart-series-datapoint.component'; +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 { - SkyBarChartOrientation, - SkyBarDatum, -} from './lib/modules/bar-chart/bar-chart-types'; + SkyChartBarOrientation, + SkyChartBarDatum, +} from './lib/modules/chart-bar/chart-bar-types'; // Line Chart -export { SkyLineChartComponent } from './lib/modules/line-chart/line-chart.component'; -export { SkyLineChartSeriesComponent } from './lib/modules/line-chart/line-chart-series.component'; -export { SkyLineChartSeriesDatapointComponent } from './lib/modules/line-chart/line-chart-series-datapoint.component'; -export { SkyLineDatum } from './lib/modules/line-chart/line-chart-types'; +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 { SkyDonutChartComponent } from './lib/modules/donut-chart/donut-chart.component'; -export { SkyDonutChartSeriesComponent } from './lib/modules/donut-chart/donut-chart-series.component'; -export { SkyDonutChartSeriesDatapointComponent } from './lib/modules/donut-chart/donut-chart-series-datapoint.component'; -export { SkyDonutDatum } from './lib/modules/donut-chart/donut-chart-types'; +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/sky-chart-axis-registry.service.ts b/libs/components/charts/src/lib/modules/axis/chart-axis-registry.service.ts similarity index 100% rename from libs/components/charts/src/lib/modules/axis/sky-chart-axis-registry.service.ts rename to libs/components/charts/src/lib/modules/axis/chart-axis-registry.service.ts 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 index f0d7724fbc..146541befe 100644 --- 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 @@ -8,9 +8,12 @@ import { input, } from '@angular/core'; -import { SkyChartCategoryAxisConfig, SkyChartAxisLabelText } from '../shared/types/axis-types'; +import { + SkyChartAxisLabelText, + SkyChartCategoryAxisConfig, +} from '../shared/types/axis-types'; -import { SKY_CHART_AXIS_REGISTRY } from './sky-chart-axis-registry.service'; +import { SKY_CHART_AXIS_REGISTRY } from './chart-axis-registry.service'; /** * Configures the chart's category axis 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 index be920fc018..c4f8338d8d 100644 --- 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 @@ -15,7 +15,7 @@ import { SkyChartMeasureAxisConfig, } from '../shared/types/axis-types'; -import { SKY_CHART_AXIS_REGISTRY } from './sky-chart-axis-registry.service'; +import { SKY_CHART_AXIS_REGISTRY } from './chart-axis-registry.service'; /** * Configures the Chart's measure axis. diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-config.service.ts similarity index 94% rename from libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts rename to libs/components/charts/src/lib/modules/chart-bar/chart-bar-config.service.ts index f53f4aeb0c..d89882b114 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-config.service.ts @@ -25,16 +25,16 @@ import { SkyChartSeries } from '../shared/types/chart-series'; import { DeepPartial } from '../shared/types/deep-partial-type'; import { - SkyBarChartOrientation, - SkyBarChartPoint, - SkyBarDatum, -} from './bar-chart-types'; + SkyChartBarDatum, + SkyChartBarOrientation, + SkyChartBarPoint, +} from './chart-bar-types'; /** * Configuration service for the Bar Chart component. */ @Injectable({ providedIn: 'root' }) -export class SkyBarChartConfigService { +export class SkyChartBarConfigService { readonly #chartStyleService = inject(SkyChartStyleService); readonly #globalConfig = inject(SkyChartGlobalConfigService); @@ -43,7 +43,7 @@ export class SkyBarChartConfigService { * @remarks This uses the `SkyChartStyleService.styles` signal to support runtime theming recalculations * @param options The bar chart options */ - public buildConfig(options: SkyBarChartOptions): ChartConfiguration<'bar'> { + public buildConfig(options: SkyChartBarOptions): ChartConfiguration<'bar'> { const styles = this.#chartStyleService.styles(); const orientation = options.orientation || 'vertical'; @@ -54,7 +54,7 @@ export class SkyBarChartConfigService { // Build datasets from series const datasets: ChartDataset<'bar'>[] = options.series.map((series) => { - const dataByCategory = new Map(); + const dataByCategory = new Map(); for (const p of series.data) { dataByCategory.set(p.category, p.value); @@ -157,7 +157,7 @@ export class SkyBarChartConfigService { * @param options The bar chart options * @returns A CSS height value (e.g. '400px') for the chart container */ - public getChartHeight(options: SkyBarChartOptions): string { + public getChartHeight(options: SkyChartBarOptions): string { const styles = this.#chartStyleService.styles(); if (options.orientation === 'vertical') { @@ -185,7 +185,7 @@ export class SkyBarChartConfigService { // #region Bar Element sizing #getBarDatasetOptions( - options: SkyBarChartOptions, + options: SkyChartBarOptions, categoryCount: number, styles: SkyChartStyles, ): DeepPartial { @@ -286,7 +286,7 @@ export class SkyBarChartConfigService { // #region Scales #createScales( styles: SkyChartStyles, - options: SkyBarChartOptions, + options: SkyChartBarOptions, ): ChartOptions<'bar'>['scales'] { const orientation = options.orientation ?? 'vertical'; const categoryScale = this.#createCategoryScale(styles, options); @@ -340,7 +340,7 @@ export class SkyBarChartConfigService { #createCategoryScale( styles: SkyChartStyles, - options: SkyBarChartOptions, + options: SkyChartBarOptions, ): PartialBarScale { const base = this.#getBaseScale(styles); @@ -370,7 +370,7 @@ export class SkyBarChartConfigService { #createMeasureScale( styles: SkyChartStyles, - options: SkyBarChartOptions, + options: SkyChartBarOptions, ): PartialBarScale { if (options.measureAxis?.scaleType === 'logarithmic') { return this.#createLogarithmicMeasureScale(styles, options); @@ -381,7 +381,7 @@ export class SkyBarChartConfigService { #createLinearMeasureScale( styles: SkyChartStyles, - options: SkyBarChartOptions, + options: SkyChartBarOptions, ): PartialBarScale { const base = this.#getBaseScale(styles); @@ -410,7 +410,7 @@ export class SkyBarChartConfigService { #createLogarithmicMeasureScale( styles: SkyChartStyles, - options: SkyBarChartOptions, + options: SkyChartBarOptions, ): PartialBarScale { const base = this.#getBaseScale(styles); @@ -448,12 +448,12 @@ export class SkyBarChartConfigService { // #region Types /** Configuration for the bar chart component. */ -export interface SkyBarChartOptions { +export interface SkyChartBarOptions { /** Orientation of the chart. */ - orientation?: SkyBarChartOrientation; + orientation?: SkyChartBarOrientation; /** The data series for the chart. */ - series: SkyChartSeries[]; + series: SkyChartSeries[]; /** Whether the chart should display stacked series. */ stacked?: boolean; @@ -468,7 +468,9 @@ export interface SkyBarChartOptions { dataPointsClickable: boolean; callbacks?: { - onDataPointClick: (event: SkyChartDataPointClickArgs) => void; + onDataPointClick: ( + event: SkyChartDataPointClickArgs, + ) => void; }; } diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-registry.service.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-registry.service.ts similarity index 82% rename from libs/components/charts/src/lib/modules/bar-chart/bar-chart-registry.service.ts rename to libs/components/charts/src/lib/modules/chart-bar/chart-bar-registry.service.ts index 3932c5a089..990dc83569 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-registry.service.ts +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-registry.service.ts @@ -1,6 +1,6 @@ import { Injectable, signal } from '@angular/core'; -import { SkyChartAxisRegistry } from '../axis/sky-chart-axis-registry.service'; +import { SkyChartAxisRegistry } from '../axis/chart-axis-registry.service'; import { SkyChartRegistry } from '../shared/services/chart-registry.service'; import { SkyChartCategoryAxisConfig, @@ -8,11 +8,11 @@ import { } from '../shared/types/axis-types'; import { SkyChartSeries } from '../shared/types/chart-series'; -import { SkyBarChartPoint } from './bar-chart-types'; +import { SkyChartBarPoint } from './chart-bar-types'; @Injectable() -export class SkyBarChartRegistry - implements SkyChartRegistry, SkyChartAxisRegistry +export class SkyChartBarRegistry + implements SkyChartRegistry, SkyChartAxisRegistry { public readonly categoryAxis = signal( undefined, @@ -20,7 +20,7 @@ export class SkyBarChartRegistry public readonly measureAxis = signal( undefined, ); - public readonly series = signal[]>([]); + public readonly series = signal[]>([]); public upsertCategoryAxis(axis: SkyChartCategoryAxisConfig): void { this.categoryAxis.set(axis); @@ -38,7 +38,7 @@ export class SkyBarChartRegistry this.measureAxis.set(undefined); } - public upsertSeries(series: SkyChartSeries): void { + public upsertSeries(series: SkyChartSeries): void { this.series.update((list) => { const idx = list.findIndex((s) => s.id === series.id); @@ -56,7 +56,7 @@ export class SkyBarChartRegistry this.series.update((list) => list.filter((s) => s.id !== seriesId)); } - public upsertPoint(seriesId: number, point: SkyBarChartPoint): void { + public upsertPoint(seriesId: number, point: SkyChartBarPoint): void { this.series.update((list) => list.map((s) => { if (s.id !== seriesId) { diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series-data-point.component.ts similarity index 69% rename from libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts rename to libs/components/charts/src/lib/modules/chart-bar/chart-bar-series-data-point.component.ts index da293d86d0..0d7164ef94 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series-datapoint.component.ts +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series-data-point.component.ts @@ -10,9 +10,9 @@ import { import { SkyCategory } from '../shared/types/category'; -import { SkyBarChartRegistry } from './bar-chart-registry.service'; -import { SkyBarChartSeriesComponent } from './bar-chart-series.component'; -import { SkyBarChartPoint, SkyBarDatum } from './bar-chart-types'; +import { SkyChartBarRegistry } from './chart-bar-registry.service'; +import { SkyChartBarSeriesComponent } from './chart-bar-series.component'; +import { SkyChartBarDatum, SkyChartBarPoint } from './chart-bar-types'; let nextId = 0; @@ -20,13 +20,13 @@ let nextId = 0; * Represents a single data point within a chart series. */ @Component({ - selector: 'sky-bar-chart-series-datapoint', + selector: 'sky-chart-bar-series-datapoint', template: '', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyBarChartSeriesDatapointComponent implements OnDestroy { - readonly #registry = inject(SkyBarChartRegistry); - readonly #series = inject(SkyBarChartSeriesComponent); +export class SkyChartBarSeriesDataPointComponent implements OnDestroy { + readonly #registry = inject(SkyChartBarRegistry); + readonly #series = inject(SkyChartBarSeriesComponent); /** * The category bucket this data point belongs to (e.g. a month name or a label on the category axis). @@ -41,14 +41,14 @@ export class SkyBarChartSeriesDatapointComponent implements OnDestroy { /** * The numeric value for this data point. */ - public readonly value = input.required(); + public readonly value = input.required(); /** * A unique ID for this data point component instance. */ readonly #id = nextId++; - readonly #datapoint = computed(() => { + readonly #datapoint = computed(() => { return { id: this.#id, category: this.category(), diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series.component.ts similarity index 74% rename from libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts rename to libs/components/charts/src/lib/modules/chart-bar/chart-bar-series.component.ts index 6b8231f091..d79e2f6de1 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-series.component.ts +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-series.component.ts @@ -10,8 +10,8 @@ import { import { SkyChartSeries } from '../shared/types/chart-series'; -import { SkyBarChartRegistry } from './bar-chart-registry.service'; -import { SkyBarChartPoint } from './bar-chart-types'; +import { SkyChartBarRegistry } from './chart-bar-registry.service'; +import { SkyChartBarPoint } from './chart-bar-types'; let nextId = 0; @@ -19,12 +19,12 @@ let nextId = 0; * Represents a named data series in a chart. */ @Component({ - selector: 'sky-bar-chart-series', + selector: 'sky-chart-bar-series', template: '', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyBarChartSeriesComponent implements OnDestroy { - readonly #registry = inject(SkyBarChartRegistry); +export class SkyChartBarSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyChartBarRegistry); /** * The display label for this series. Shown in the chart legend and tooltips. @@ -37,7 +37,7 @@ export class SkyBarChartSeriesComponent implements OnDestroy { */ public readonly id = nextId++; - readonly #series = computed>(() => ({ + readonly #series = computed>(() => ({ id: this.id, labelText: this.labelText(), data: [], // Data will be dynamically set from children datapoints diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-types.ts similarity index 59% rename from libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts rename to libs/components/charts/src/lib/modules/chart-bar/chart-bar-types.ts index 60ebee1117..7710724483 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart-types.ts +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar-types.ts @@ -3,18 +3,18 @@ import { SkyChartDataPoint } from '../shared/types/chart-data-point'; /** * The orientation of the bar chart */ -export type SkyBarChartOrientation = 'vertical' | 'horizontal'; +export type SkyChartBarOrientation = 'vertical' | 'horizontal'; /** * A bar chart data point, which can be a single numeric value. */ -export type SkyBarDatum = number; +export type SkyChartBarDatum = number; /** * A single data point within a bar chart series. * @internal */ -export interface SkyBarChartPoint extends SkyChartDataPoint { +export interface SkyChartBarPoint extends SkyChartDataPoint { /** The bar value */ - value: SkyBarDatum; + value: SkyChartBarDatum; } diff --git a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar.component.ts similarity index 87% rename from libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts rename to libs/components/charts/src/lib/modules/chart-bar/chart-bar.component.ts index 7477c50ca7..27f921ad16 100644 --- a/libs/components/charts/src/lib/modules/bar-chart/bar-chart.component.ts +++ b/libs/components/charts/src/lib/modules/chart-bar/chart-bar.component.ts @@ -11,7 +11,7 @@ import { viewChild, } from '@angular/core'; -import { SKY_CHART_AXIS_REGISTRY } from '../axis/sky-chart-axis-registry.service'; +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.directive'; @@ -24,15 +24,15 @@ import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-poin import { SkyChartSeries } from '../shared/types/chart-series'; import { - SkyBarChartConfigService, - SkyBarChartOptions, -} from './bar-chart-config.service'; -import { SkyBarChartRegistry } from './bar-chart-registry.service'; + SkyChartBarConfigService, + SkyChartBarOptions, +} from './chart-bar-config.service'; +import { SkyChartBarRegistry } from './chart-bar-registry.service'; import { - SkyBarChartOrientation, - SkyBarChartPoint, - SkyBarDatum, -} from './bar-chart-types'; + SkyChartBarDatum, + SkyChartBarOrientation, + SkyChartBarPoint, +} from './chart-bar-types'; /** * Displays a bar chart visualization. @@ -56,20 +56,20 @@ import { styles: '.chart-container { position: relative; }', imports: [SkyChartJsDirective], providers: [ - SkyBarChartRegistry, - { provide: SKY_CHART_AXIS_REGISTRY, useExisting: SkyBarChartRegistry }, + SkyChartBarRegistry, + { provide: SKY_CHART_AXIS_REGISTRY, useExisting: SkyChartBarRegistry }, ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyBarChartComponent { +export class SkyChartBarComponent { // #region Dependency Injection readonly #chartService = inject(SkyChartService); - readonly #chartRegistry = inject(SkyBarChartRegistry); - readonly #chartConfigService = inject(SkyBarChartConfigService); + readonly #chartRegistry = inject(SkyChartBarRegistry); + readonly #chartConfigService = inject(SkyChartBarConfigService); // #endregion // #region Inputs - public readonly orientation = input('vertical'); + public readonly orientation = input('vertical'); public readonly stacked = input(false, { transform: booleanAttribute }); public readonly dataPointsClickable = input(false, { transform: booleanAttribute, @@ -84,7 +84,7 @@ export class SkyBarChartComponent { // #region Outputs public readonly dataPointClicked = - output>(); + output>(); // #endregion // #region View Children @@ -179,12 +179,12 @@ export class SkyBarChartComponent { // #region Private #parseOptions(context: { dataPointsClickable: boolean; - orientation: SkyBarChartOrientation; + orientation: SkyChartBarOrientation; stacked: boolean; categoryAxis: Readonly | undefined; measureAxis: Readonly | undefined; - series: SkyChartSeries[]; - }): SkyBarChartOptions { + series: SkyChartSeries[]; + }): SkyChartBarOptions { const { dataPointsClickable, orientation, diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-config.service.ts similarity index 93% rename from libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts rename to libs/components/charts/src/lib/modules/chart-donut/chart-donut-config.service.ts index d37c951f3d..2fcd376a69 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-config.service.ts @@ -12,13 +12,13 @@ import { SkyChartGlobalConfigService } from '../shared/services/global-chart-con import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; import { SkyChartSeries } from '../shared/types/chart-series'; -import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; +import { SkyChartDonutDatum, SkyChartDonutSlice } from './chart-donut-types'; /** * Configuration service for the Donut Chart component. */ @Injectable({ providedIn: 'root' }) -export class SkyDonutChartConfigService { +export class SkyChartDonutConfigService { readonly #chartStyleService = inject(SkyChartStyleService); readonly #globalConfig = inject(SkyChartGlobalConfigService); @@ -28,7 +28,7 @@ export class SkyDonutChartConfigService { * @param options bar chart options */ public buildConfig( - options: SkyDonutChartOptions, + options: SkyChartDonutOptions, ): ChartConfiguration<'doughnut'> { const styles = this.#chartStyleService.styles(); @@ -146,16 +146,16 @@ function percentOfVisibleDataset(context: TooltipItem<'doughnut'>): number { // #region Types /** Configuration for the donut chart component. */ -export interface SkyDonutChartOptions { +export interface SkyChartDonutOptions { /** The data series for the chart. */ - series: SkyChartSeries; + series: SkyChartSeries; /** Are the data points clickable */ dataPointsClickable: boolean; callbacks?: { onDataPointClick: ( - event: SkyChartDataPointClickArgs, + event: SkyChartDataPointClickArgs, ) => void; }; } diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-registry.service.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-registry.service.ts similarity index 79% rename from libs/components/charts/src/lib/modules/donut-chart/donut-chart-registry.service.ts rename to libs/components/charts/src/lib/modules/chart-donut/chart-donut-registry.service.ts index b34f095734..ec7b3b3a62 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-registry.service.ts +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-registry.service.ts @@ -3,15 +3,15 @@ import { Injectable, signal } from '@angular/core'; import { SkyChartRegistry } from '../shared/services/chart-registry.service'; import { SkyChartSeries } from '../shared/types/chart-series'; -import { SkyDonutChartSlice } from './donut-chart-types'; +import { SkyChartDonutSlice } from './chart-donut-types'; @Injectable() -export class SkyDonutChartRegistry - implements SkyChartRegistry +export class SkyChartDonutRegistry + implements SkyChartRegistry { - public readonly series = signal[]>([]); + public readonly series = signal[]>([]); - public upsertSeries(series: SkyChartSeries): void { + public upsertSeries(series: SkyChartSeries): void { this.series.update((list) => { const idx = list.findIndex((s) => s.id === series.id); @@ -29,7 +29,7 @@ export class SkyDonutChartRegistry this.series.update((list) => list.filter((s) => s.id !== seriesId)); } - public upsertPoint(seriesId: number, point: SkyDonutChartSlice): void { + public upsertPoint(seriesId: number, point: SkyChartDonutSlice): void { this.series.update((list) => list.map((s) => { if (s.id !== seriesId) { diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series-data-point.component.ts similarity index 69% rename from libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts rename to libs/components/charts/src/lib/modules/chart-donut/chart-donut-series-data-point.component.ts index b119f2267f..ac1820edf5 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series-datapoint.component.ts +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series-data-point.component.ts @@ -10,9 +10,9 @@ import { import { SkyCategory } from '../shared/types/category'; -import { SkyDonutChartRegistry } from './donut-chart-registry.service'; -import { SkyDonutChartSeriesComponent } from './donut-chart-series.component'; -import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; +import { SkyChartDonutRegistry } from './chart-donut-registry.service'; +import { SkyChartDonutSeriesComponent } from './chart-donut-series.component'; +import { SkyChartDonutDatum, SkyChartDonutSlice } from './chart-donut-types'; let nextId = 0; @@ -20,13 +20,13 @@ let nextId = 0; * Represents a single data point within a donut chart series. */ @Component({ - selector: 'sky-donut-chart-series-datapoint', + selector: 'sky-chart-donut-series-datapoint', template: '', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyDonutChartSeriesDatapointComponent implements OnDestroy { - readonly #registry = inject(SkyDonutChartRegistry); - readonly #series = inject(SkyDonutChartSeriesComponent); +export class SkyChartDonutSeriesDataPointComponent implements OnDestroy { + readonly #registry = inject(SkyChartDonutRegistry); + readonly #series = inject(SkyChartDonutSeriesComponent); /** * The category bucket this data point belongs to (e.g. a month name or a label on the category axis). @@ -41,14 +41,14 @@ export class SkyDonutChartSeriesDatapointComponent implements OnDestroy { /** * The numeric value for this data point. */ - public readonly value = input.required(); + public readonly value = input.required(); /** * A unique ID for this data point component instance. */ readonly #id = nextId++; - readonly #datapoint = computed(() => { + readonly #datapoint = computed(() => { return { id: this.#id, category: this.category(), diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series.component.ts similarity index 73% rename from libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts rename to libs/components/charts/src/lib/modules/chart-donut/chart-donut-series.component.ts index b96cee68ba..0573e6b08c 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-series.component.ts +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-series.component.ts @@ -10,8 +10,8 @@ import { import { SkyChartSeries } from '../shared/types/chart-series'; -import { SkyDonutChartRegistry } from './donut-chart-registry.service'; -import { SkyDonutChartSlice } from './donut-chart-types'; +import { SkyChartDonutRegistry } from './chart-donut-registry.service'; +import { SkyChartDonutSlice } from './chart-donut-types'; let nextId = 0; @@ -19,12 +19,12 @@ let nextId = 0; * Represents a named data series in a chart. */ @Component({ - selector: 'sky-donut-chart-series', + selector: 'sky-chart-donut-series', template: '', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyDonutChartSeriesComponent implements OnDestroy { - readonly #registry = inject(SkyDonutChartRegistry); +export class SkyChartDonutSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyChartDonutRegistry); /** * The display label for this series. Shown in the chart legend and tooltips. @@ -37,7 +37,7 @@ export class SkyDonutChartSeriesComponent implements OnDestroy { */ public readonly id = nextId++; - readonly #series = computed>(() => ({ + readonly #series = computed>(() => ({ id: this.id, labelText: this.labelText(), data: [], // Data will be dynamically set from children datapoints diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-types.ts similarity index 67% rename from libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts rename to libs/components/charts/src/lib/modules/chart-donut/chart-donut-types.ts index 9cbecf1d28..e4fbbe99db 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart-types.ts +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut-types.ts @@ -3,13 +3,13 @@ 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 SkyDonutDatum = number; +export type SkyChartDonutDatum = number; /** * A single data point within a donut chart series. * @internal */ -export interface SkyDonutChartSlice extends SkyChartDataPoint { +export interface SkyChartDonutSlice extends SkyChartDataPoint { /** Numeric value */ - value: SkyDonutDatum; + value: SkyChartDonutDatum; } diff --git a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut.component.ts similarity index 89% rename from libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts rename to libs/components/charts/src/lib/modules/chart-donut/chart-donut.component.ts index 39c95b705d..424ebcd2b7 100644 --- a/libs/components/charts/src/lib/modules/donut-chart/donut-chart.component.ts +++ b/libs/components/charts/src/lib/modules/chart-donut/chart-donut.component.ts @@ -19,11 +19,11 @@ import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-poin import { SkyChartSeries } from '../shared/types/chart-series'; import { - SkyDonutChartConfigService, - SkyDonutChartOptions, -} from './donut-chart-config.service'; -import { SkyDonutChartRegistry } from './donut-chart-registry.service'; -import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; + 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. @@ -46,14 +46,14 @@ import { SkyDonutChartSlice, SkyDonutDatum } from './donut-chart-types'; // See: https://www.chartjs.org/docs/latest/configuration/responsive.html styles: '.chart-container { position: relative; }', imports: [SkyChartJsDirective], - providers: [SkyDonutChartRegistry], + providers: [SkyChartDonutRegistry], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyDonutChartComponent { +export class SkyChartDonutComponent { // #region Dependency Injection readonly #chartService = inject(SkyChartService); - readonly #chartRegistry = inject(SkyDonutChartRegistry); - readonly #chartConfigService = inject(SkyDonutChartConfigService); + readonly #chartRegistry = inject(SkyChartDonutRegistry); + readonly #chartConfigService = inject(SkyChartDonutConfigService); // #endregion // #region Inputs @@ -70,7 +70,7 @@ export class SkyDonutChartComponent { // #region Outputs public readonly dataPointClicked = - output>(); + output>(); // #endregion // #region View Children @@ -158,8 +158,8 @@ export class SkyDonutChartComponent { // #region Private #parseOptions(context: { dataPointsClickable: boolean; - series: SkyChartSeries[]; - }): SkyDonutChartOptions { + series: SkyChartSeries[]; + }): SkyChartDonutOptions { const { dataPointsClickable, series } = context; // Donut charts only supports a single series diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-config.service.ts similarity index 93% rename from libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts rename to libs/components/charts/src/lib/modules/chart-line/chart-line-config.service.ts index aac6e9d7a0..086a2f5570 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-config.service.ts +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-config.service.ts @@ -23,13 +23,13 @@ import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-poin import { SkyChartSeries } from '../shared/types/chart-series'; import { DeepPartial } from '../shared/types/deep-partial-type'; -import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; +import { SkyChartLineDatum, SkyChartLinePoint } from './chart-line-types'; /** * Configuration service for the Line Chart component. */ @Injectable({ providedIn: 'root' }) -export class SkyLineChartConfigService { +export class SkyChartLineConfigService { readonly #chartStyleService = inject(SkyChartStyleService); readonly #globalConfig = inject(SkyChartGlobalConfigService); @@ -38,7 +38,7 @@ export class SkyLineChartConfigService { * @remarks This uses the `SkyChartStyleService.styles` signal to support runtime theming recalculations * @param options bar chart options */ - public buildConfig(options: SkyLineChartOptions): ChartConfiguration<'line'> { + public buildConfig(options: SkyChartLineOptions): ChartConfiguration<'line'> { const styles = this.#chartStyleService.styles(); // Build categories from series data @@ -46,7 +46,7 @@ export class SkyLineChartConfigService { // Build datasets from series const datasets: ChartDataset<'line'>[] = options.series.map((series) => { - const dataByCategory = new Map(); + const dataByCategory = new Map(); for (const p of series.data) { dataByCategory.set(p.category, p.value); @@ -158,7 +158,7 @@ export class SkyLineChartConfigService { #createScales( styles: SkyChartStyles, - config: SkyLineChartOptions, + config: SkyChartLineOptions, ): ChartOptions<'line'>['scales'] { const categoryScale = this.#createCategoryScale(styles, config); const measureScale = this.#createMeasureScale(styles, config); @@ -207,7 +207,7 @@ export class SkyLineChartConfigService { #createCategoryScale( styles: SkyChartStyles, - config: SkyLineChartOptions, + config: SkyChartLineOptions, ): PartialLineScale { const base = this.#getBaseScale(styles); @@ -232,7 +232,7 @@ export class SkyLineChartConfigService { #createMeasureScale( styles: SkyChartStyles, - config: SkyLineChartOptions, + config: SkyChartLineOptions, ): PartialLineScale { if (config.measureAxis?.scaleType === 'logarithmic') { return this.#createLogarithmicMeasureScale(styles, config); @@ -243,7 +243,7 @@ export class SkyLineChartConfigService { #createLinearMeasureScale( styles: SkyChartStyles, - config: SkyLineChartOptions, + config: SkyChartLineOptions, ): PartialLineScale { const base = this.#getBaseScale(styles); @@ -272,7 +272,7 @@ export class SkyLineChartConfigService { #createLogarithmicMeasureScale( styles: SkyChartStyles, - config: SkyLineChartOptions, + config: SkyChartLineOptions, ): PartialLineScale { const base = this.#getBaseScale(styles); @@ -309,9 +309,9 @@ export class SkyLineChartConfigService { // #region Types /** Configuration for the line chart component. */ -export interface SkyLineChartOptions { +export interface SkyChartLineOptions { /** The data series for the chart. */ - series: SkyChartSeries[]; + series: SkyChartSeries[]; /** Whether the chart should display stacked series. */ stacked?: boolean; @@ -326,7 +326,9 @@ export interface SkyLineChartOptions { dataPointsClickable: boolean; callbacks?: { - onDataPointClick: (event: SkyChartDataPointClickArgs) => void; + onDataPointClick: ( + event: SkyChartDataPointClickArgs, + ) => void; }; } diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-registry.service.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-registry.service.ts similarity index 82% rename from libs/components/charts/src/lib/modules/line-chart/line-chart-registry.service.ts rename to libs/components/charts/src/lib/modules/chart-line/chart-line-registry.service.ts index f5a9bac390..a5b17d96dd 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-registry.service.ts +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-registry.service.ts @@ -1,6 +1,6 @@ import { Injectable, signal } from '@angular/core'; -import { SkyChartAxisRegistry } from '../axis/sky-chart-axis-registry.service'; +import { SkyChartAxisRegistry } from '../axis/chart-axis-registry.service'; import { SkyChartRegistry } from '../shared/services/chart-registry.service'; import { SkyChartCategoryAxisConfig, @@ -8,11 +8,11 @@ import { } from '../shared/types/axis-types'; import { SkyChartSeries } from '../shared/types/chart-series'; -import { SkyLineChartPoint } from './line-chart-types'; +import { SkyChartLinePoint } from './chart-line-types'; @Injectable() -export class SkyLineChartRegistry - implements SkyChartRegistry, SkyChartAxisRegistry +export class SkyChartLineRegistry + implements SkyChartRegistry, SkyChartAxisRegistry { public readonly categoryAxis = signal( undefined, @@ -20,7 +20,7 @@ export class SkyLineChartRegistry public readonly measureAxis = signal( undefined, ); - public readonly series = signal[]>([]); + public readonly series = signal[]>([]); public upsertCategoryAxis(axis: SkyChartCategoryAxisConfig): void { this.categoryAxis.set(axis); @@ -38,7 +38,7 @@ export class SkyLineChartRegistry this.measureAxis.set(undefined); } - public upsertSeries(series: SkyChartSeries): void { + public upsertSeries(series: SkyChartSeries): void { this.series.update((list) => { const idx = list.findIndex((s) => s.id === series.id); @@ -56,7 +56,7 @@ export class SkyLineChartRegistry this.series.update((list) => list.filter((s) => s.id !== seriesId)); } - public upsertPoint(seriesId: number, point: SkyLineChartPoint): void { + public upsertPoint(seriesId: number, point: SkyChartLinePoint): void { this.series.update((list) => list.map((s) => { if (s.id !== seriesId) { diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-series-data-point.component.ts similarity index 69% rename from libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts rename to libs/components/charts/src/lib/modules/chart-line/chart-line-series-data-point.component.ts index d625227fae..c2eac99125 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-series-datapoint.component.ts +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-series-data-point.component.ts @@ -10,9 +10,9 @@ import { import { SkyCategory } from '../shared/types/category'; -import { SkyLineChartRegistry } from './line-chart-registry.service'; -import { SkyLineChartSeriesComponent } from './line-chart-series.component'; -import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; +import { SkyChartLineRegistry } from './chart-line-registry.service'; +import { SkyChartLineSeriesComponent } from './chart-line-series.component'; +import { SkyChartLineDatum, SkyChartLinePoint } from './chart-line-types'; let nextId = 0; @@ -20,13 +20,13 @@ let nextId = 0; * Represents a single data point within a line chart series. */ @Component({ - selector: 'sky-line-chart-series-datapoint', + selector: 'sky-chart-line-series-datapoint', template: '', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyLineChartSeriesDatapointComponent implements OnDestroy { - readonly #registry = inject(SkyLineChartRegistry); - readonly #series = inject(SkyLineChartSeriesComponent); +export class SkyChartLineSeriesDataPointComponent implements OnDestroy { + readonly #registry = inject(SkyChartLineRegistry); + readonly #series = inject(SkyChartLineSeriesComponent); /** * The category bucket this data point belongs to (e.g. a month name or a label on the category axis). @@ -41,14 +41,14 @@ export class SkyLineChartSeriesDatapointComponent implements OnDestroy { /** * The numeric value for this data point. */ - public readonly value = input.required(); + public readonly value = input.required(); /** * A unique ID for this data point component instance. */ readonly #id = nextId++; - readonly #datapoint = computed(() => { + readonly #datapoint = computed(() => { return { id: this.#id, category: this.category(), diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-series.component.ts similarity index 69% rename from libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts rename to libs/components/charts/src/lib/modules/chart-line/chart-line-series.component.ts index 65777d4dc5..4cce047691 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-series.component.ts +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-series.component.ts @@ -11,9 +11,9 @@ import { import { SkyChartSeries } from '../shared/types/chart-series'; -import { SkyLineChartRegistry } from './line-chart-registry.service'; -import { SkyLineChartSeriesDatapointComponent } from './line-chart-series-datapoint.component'; -import { SkyLineChartPoint } from './line-chart-types'; +import { SkyChartLineRegistry } from './chart-line-registry.service'; +import { SkyChartLineSeriesDataPointComponent } from './chart-line-series-data-point.component'; +import { SkyChartLinePoint } from './chart-line-types'; let nextId = 0; @@ -21,12 +21,12 @@ let nextId = 0; * Represents a named data series in a chart. */ @Component({ - selector: 'sky-line-chart-series', + selector: 'sky-chart-line-series', template: '', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyLineChartSeriesComponent implements OnDestroy { - readonly #registry = inject(SkyLineChartRegistry); +export class SkyChartLineSeriesComponent implements OnDestroy { + readonly #registry = inject(SkyChartLineRegistry); /** * The display label for this series. Shown in the chart legend and tooltips. @@ -37,7 +37,7 @@ export class SkyLineChartSeriesComponent implements OnDestroy { * The data points that belong to this series. */ protected readonly datapoints = contentChildren( - SkyLineChartSeriesDatapointComponent, + SkyChartLineSeriesDataPointComponent, ); /** @@ -46,7 +46,7 @@ export class SkyLineChartSeriesComponent implements OnDestroy { */ public readonly id = nextId++; - readonly #series = computed>(() => ({ + readonly #series = computed>(() => ({ id: this.id, labelText: this.labelText(), data: [], // Data will be dynamically set from children datapoints diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line-types.ts similarity index 68% rename from libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts rename to libs/components/charts/src/lib/modules/chart-line/chart-line-types.ts index 8e04fb41c0..07f98d8555 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart-types.ts +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line-types.ts @@ -3,13 +3,13 @@ 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 SkyLineDatum = number; +export type SkyChartLineDatum = number; /** * A single data point within a line chart series. * @internal */ -export interface SkyLineChartPoint extends SkyChartDataPoint { +export interface SkyChartLinePoint extends SkyChartDataPoint { /** Numeric value */ - value: SkyLineDatum; + value: SkyChartLineDatum; } diff --git a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line.component.ts similarity index 88% rename from libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts rename to libs/components/charts/src/lib/modules/chart-line/chart-line.component.ts index c713d29c21..4741b61e74 100644 --- a/libs/components/charts/src/lib/modules/line-chart/line-chart.component.ts +++ b/libs/components/charts/src/lib/modules/chart-line/chart-line.component.ts @@ -11,7 +11,7 @@ import { viewChild, } from '@angular/core'; -import { SKY_CHART_AXIS_REGISTRY } from '../axis/sky-chart-axis-registry.service'; +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.directive'; @@ -24,11 +24,11 @@ import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-poin import { SkyChartSeries } from '../shared/types/chart-series'; import { - SkyLineChartConfigService, - SkyLineChartOptions, -} from './line-chart-config.service'; -import { SkyLineChartRegistry } from './line-chart-registry.service'; -import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; + 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. @@ -52,16 +52,16 @@ import { SkyLineChartPoint, SkyLineDatum } from './line-chart-types'; styles: '.chart-container { position: relative; }', imports: [SkyChartJsDirective], providers: [ - SkyLineChartRegistry, - { provide: SKY_CHART_AXIS_REGISTRY, useExisting: SkyLineChartRegistry }, + SkyChartLineRegistry, + { provide: SKY_CHART_AXIS_REGISTRY, useExisting: SkyChartLineRegistry }, ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SkyLineChartComponent { +export class SkyChartLineComponent { // #region Dependency Injection readonly #chartService = inject(SkyChartService); - readonly #chartRegistry = inject(SkyLineChartRegistry); - readonly #chartConfigService = inject(SkyLineChartConfigService); + readonly #chartRegistry = inject(SkyChartLineRegistry); + readonly #chartConfigService = inject(SkyChartLineConfigService); // #endregion // #region Inputs @@ -79,7 +79,7 @@ export class SkyLineChartComponent { // #region Outputs public readonly dataPointClicked = - output>(); + output>(); // #endregion // #region View Children @@ -174,8 +174,8 @@ export class SkyLineChartComponent { stacked: boolean; categoryAxis: Readonly | undefined; measureAxis: Readonly | undefined; - series: SkyChartSeries[]; - }): SkyLineChartOptions { + series: SkyChartSeries[]; + }): SkyChartLineOptions { const { dataPointsClickable, stacked, categoryAxis, measureAxis, series } = context; diff --git a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.filters.ts b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.filters.ts similarity index 54% rename from libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.filters.ts rename to libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.filters.ts index fd79e57337..d127a6cca8 100644 --- a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.filters.ts +++ b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.filters.ts @@ -1,7 +1,7 @@ import { SkyHarnessFilters } from '@skyux/core/testing'; /** - * A set of criteria that can be used to filter a list of `SkyBarChartHarness` instances. + * 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 SkyBarChartHarnessFilters extends SkyHarnessFilters {} +export interface SkyChartBarHarnessFilters extends SkyHarnessFilters {} diff --git a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.spec.ts b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.spec.ts similarity index 74% rename from libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.spec.ts rename to libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.spec.ts index 144d1b5dcc..17de8bbfb0 100644 --- a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.spec.ts +++ b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.spec.ts @@ -3,8 +3,8 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SkyHelpTestingModule } from '@skyux/core/testing'; -import { SkyBarChartHarness } from './bar-chart-harness'; -import { BarChartHarnessTestComponent } from './fixtures/bar-chart-harness-test.component'; +import { SkyChartBarHarness } from './chart-bar-harness'; +import { BarChartHarnessTestComponent } from './fixtures/chart-bar-harness-test.component'; describe('Bar chart test harness', () => { async function setupTest( @@ -12,7 +12,7 @@ describe('Bar chart test harness', () => { dataSkyId?: string; } = {}, ): Promise<{ - boxHarness: SkyBarChartHarness; + boxHarness: SkyChartBarHarness; fixture: ComponentFixture; loader: HarnessLoader; }> { @@ -23,11 +23,11 @@ describe('Bar chart test harness', () => { const fixture = TestBed.createComponent(BarChartHarnessTestComponent); const loader = TestbedHarnessEnvironment.documentRootLoader(fixture); - const barChartHarness: SkyBarChartHarness = options.dataSkyId + const barChartHarness: SkyChartBarHarness = options.dataSkyId ? await loader.getHarness( - SkyBarChartHarness.with({ dataSkyId: options.dataSkyId }), + SkyChartBarHarness.with({ dataSkyId: options.dataSkyId }), ) - : await loader.getHarness(SkyBarChartHarness); + : await loader.getHarness(SkyChartBarHarness); return { boxHarness: barChartHarness, fixture, loader }; } diff --git a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.ts similarity index 53% rename from libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts rename to libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.ts index a047ea74e4..800853f21a 100644 --- a/libs/components/charts/testing/src/modules/bar-chart/bar-chart-harness.ts +++ b/libs/components/charts/testing/src/modules/chart-bar/chart-bar-harness.ts @@ -1,12 +1,12 @@ import { HarnessPredicate } from '@angular/cdk/testing'; import { SkyComponentHarness } from '@skyux/core/testing'; -import { SkyBarChartHarnessFilters } from './bar-chart-harness.filters'; +import { SkyChartBarHarnessFilters } from './chart-bar-harness.filters'; /** * Harness for interacting with a bar chart component in tests. */ -export class SkyBarChartHarness extends SkyComponentHarness { +export class SkyChartBarHarness extends SkyComponentHarness { /** * @internal */ @@ -14,11 +14,11 @@ export class SkyBarChartHarness extends SkyComponentHarness { /** * Gets a `HarnessPredicate` that can be used to search for a - * `SkyBarChartHarness` that meets certain criteria + * `SkyChartBarHarness` that meets certain criteria */ public static with( - filters: SkyBarChartHarnessFilters, - ): HarnessPredicate { - return SkyBarChartHarness.getDataSkyIdPredicate(filters); + filters: SkyChartBarHarnessFilters, + ): HarnessPredicate { + return SkyChartBarHarness.getDataSkyIdPredicate(filters); } } diff --git a/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.html b/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.html similarity index 100% rename from libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.html rename to libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.html diff --git a/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.ts b/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.ts similarity index 79% rename from libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.ts rename to libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.ts index 31f29f4dea..07627166ce 100644 --- a/libs/components/charts/testing/src/modules/bar-chart/fixtures/bar-chart-harness-test.component.ts +++ b/libs/components/charts/testing/src/modules/chart-bar/fixtures/chart-bar-harness-test.component.ts @@ -3,7 +3,7 @@ import { Component } from '@angular/core'; // #region Test component @Component({ selector: 'sky-bar-chart-fixture', - templateUrl: './bar-chart-harness-test.component.html', + templateUrl: './chart-bar-harness-test.component.html', imports: [], providers: [], }) diff --git a/libs/components/charts/testing/src/public-api.ts b/libs/components/charts/testing/src/public-api.ts index f1270d9d2b..a9ac7e041e 100644 --- a/libs/components/charts/testing/src/public-api.ts +++ b/libs/components/charts/testing/src/public-api.ts @@ -1,2 +1,2 @@ -export { SkyBarChartHarness } from './modules/bar-chart/bar-chart-harness'; -export { SkyBarChartHarnessFilters } from './modules/bar-chart/bar-chart-harness.filters'; +export { SkyChartBarHarness } from './modules/chart-bar/chart-bar-harness'; +export { SkyChartBarHarnessFilters } from './modules/chart-bar/chart-bar-harness.filters'; From 2d9afe136ecdc85d3104093049583564882501bf Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 14:52:08 -0400 Subject: [PATCH 18/34] refactor: rename `dataPointsClickable` to `dataPointsClickEnabled` and `dataPointClicked` to `dataPointClick` --- .../chart-bar-demo.component.html | 88 +++++++++---------- .../chart-bar-demo.component.ts | 2 +- .../chart-donut-demo.component.html | 36 ++++---- .../chart-donut-demo.component.ts | 2 +- .../chart-line-demo.component.html | 88 +++++++++---------- .../chart-line-demo.component.ts | 2 +- .../chart-bar/chart-bar-config.service.ts | 6 +- .../modules/chart-bar/chart-bar.component.ts | 16 ++-- .../chart-donut/chart-donut-config.service.ts | 6 +- .../chart-donut/chart-donut.component.ts | 16 ++-- .../chart-line/chart-line-config.service.ts | 6 +- .../chart-line/chart-line.component.ts | 23 +++-- .../indicator/indicator-plugin-options.ts | 2 +- .../plugins/indicator/indicator-plugin.ts | 6 +- 14 files changed, 152 insertions(+), 147 deletions(-) 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 index 87655ebc47..5a538da91f 100644 --- 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 @@ -41,8 +41,8 @@ , ): void { console.log(JSON.stringify(event, null, 2)); 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 index e1a4c02935..80d47b5d3f 100644 --- 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 @@ -24,8 +24,8 @@ [subtitleHidden]="false" > @for (item of chart1; track $index) { @@ -63,8 +63,8 @@ [headingStyle]="3" > @for (item of density.three; track $index) { @@ -96,8 +96,8 @@ [headingStyle]="3" > @for (item of density.six; track $index) { @@ -129,8 +129,8 @@ [headingStyle]="3" > @for (item of density.nine; track $index) { @@ -162,8 +162,8 @@ [headingStyle]="3" > @for (item of density.twelve; track $index) { @@ -201,8 +201,8 @@ [headingStyle]="3" > @for (item of chart1; track $index) { @@ -234,8 +234,8 @@ [headingStyle]="3" > @for (item of chart1; track $index) { @@ -267,8 +267,8 @@ [headingStyle]="3" > @for (item of chart1; track $index) { @@ -303,8 +303,8 @@ [headingStyle]="3" > @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 index 6f9134568d..2646039985 100644 --- 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 @@ -67,7 +67,7 @@ export class ChartDonutDemoComponent { // #endregion - public onDataPointClicked( + 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.component.html b/apps/playground/src/app/components/charts/chart-line-demo/chart-line-demo.component.html index 676da3048c..49255099c2 100644 --- 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 @@ -27,9 +27,9 @@ [subtitleHidden]="false" > , ): void { console.log(JSON.stringify(event, null, 2)); 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 index d89882b114..2334f1eafa 100644 --- 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 @@ -75,7 +75,7 @@ export class SkyChartBarConfigService { // Build Plugin options const pluginOptions: ChartOptions<'bar'>['plugins'] = { - sky_indicator: { dataPointsClickable: options.dataPointsClickable }, + sky_indicator: { dataPointsClickEnabled: options.dataPointsClickEnabled }, sky_keyboard_nav: { valueLabel: (datasetIndex, dataIndex) => { const series = options.series[datasetIndex]; @@ -120,7 +120,7 @@ export class SkyChartBarConfigService { plugins: pluginOptions, onClick: (_, elements): void => { if ( - !options.dataPointsClickable || + !options.dataPointsClickEnabled || !options.callbacks?.onDataPointClick || elements.length === 0 ) { @@ -465,7 +465,7 @@ export interface SkyChartBarOptions { measureAxis?: SkyChartMeasureAxisConfig; /** Are the data points clickable */ - dataPointsClickable: boolean; + dataPointsClickEnabled: boolean; callbacks?: { onDataPointClick: ( 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 index 27f921ad16..8744e3e27b 100644 --- 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 @@ -71,7 +71,7 @@ export class SkyChartBarComponent { // #region Inputs public readonly orientation = input('vertical'); public readonly stacked = input(false, { transform: booleanAttribute }); - public readonly dataPointsClickable = input(false, { + public readonly dataPointsClickEnabled = input(false, { transform: booleanAttribute, }); @@ -83,7 +83,7 @@ export class SkyChartBarComponent { // #endregion // #region Outputs - public readonly dataPointClicked = + public readonly dataPointClick = output>(); // #endregion @@ -105,7 +105,7 @@ export class SkyChartBarComponent { }); readonly #chartOptions = computed(() => { - const dataPointsClickable = this.dataPointsClickable(); + const dataPointsClickEnabled = this.dataPointsClickEnabled(); const orientation = this.orientation(); const stacked = this.stacked(); @@ -114,7 +114,7 @@ export class SkyChartBarComponent { const series = this.#chartRegistry.series(); const options = this.#parseOptions({ - dataPointsClickable: dataPointsClickable, + dataPointsClickEnabled: dataPointsClickEnabled, orientation: orientation, stacked: stacked, categoryAxis: categoryAxis, @@ -178,7 +178,7 @@ export class SkyChartBarComponent { // #region Private #parseOptions(context: { - dataPointsClickable: boolean; + dataPointsClickEnabled: boolean; orientation: SkyChartBarOrientation; stacked: boolean; categoryAxis: Readonly | undefined; @@ -186,7 +186,7 @@ export class SkyChartBarComponent { series: SkyChartSeries[]; }): SkyChartBarOptions { const { - dataPointsClickable, + dataPointsClickEnabled, orientation, stacked, categoryAxis, @@ -200,9 +200,9 @@ export class SkyChartBarComponent { series: series, categoryAxis: categoryAxis ? categoryAxis : undefined, measureAxis: measureAxis ? measureAxis : undefined, - dataPointsClickable: dataPointsClickable, + dataPointsClickEnabled: dataPointsClickEnabled, callbacks: { - onDataPointClick: (dataPoint) => this.dataPointClicked.emit(dataPoint), + onDataPointClick: (dataPoint) => this.dataPointClick.emit(dataPoint), }, }; } 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 index 2fcd376a69..77aa90bb86 100644 --- 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 @@ -43,7 +43,7 @@ export class SkyChartDonutConfigService { // Build Plugin options const pluginOptions: ChartOptions<'doughnut'>['plugins'] = { - sky_indicator: { dataPointsClickable: options.dataPointsClickable }, + sky_indicator: { dataPointsClickEnabled: options.dataPointsClickEnabled }, sky_keyboard_nav: { valueLabel: (_datasetIndex, dataIndex) => { const series = options.series; @@ -87,7 +87,7 @@ export class SkyChartDonutConfigService { plugins: pluginOptions, onClick: (_, elements): void => { if ( - !options.dataPointsClickable || + !options.dataPointsClickEnabled || !options.callbacks?.onDataPointClick || elements.length === 0 ) { @@ -151,7 +151,7 @@ export interface SkyChartDonutOptions { series: SkyChartSeries; /** Are the data points clickable */ - dataPointsClickable: boolean; + dataPointsClickEnabled: boolean; callbacks?: { onDataPointClick: ( 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 index 424ebcd2b7..3afd82ac2d 100644 --- 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 @@ -57,7 +57,7 @@ export class SkyChartDonutComponent { // #endregion // #region Inputs - public readonly dataPointsClickable = input(false, { + public readonly dataPointsClickEnabled = input(false, { transform: booleanAttribute, }); @@ -69,7 +69,7 @@ export class SkyChartDonutComponent { // #endregion // #region Outputs - public readonly dataPointClicked = + public readonly dataPointClick = output>(); // #endregion @@ -90,10 +90,10 @@ export class SkyChartDonutComponent { readonly #refreshLegendItems = signal(0); readonly #chartOptions = computed(() => { - const dataPointsClickable = this.dataPointsClickable(); + const dataPointsClickEnabled = this.dataPointsClickEnabled(); const series = this.#chartRegistry.series(); const options = this.#parseOptions({ - dataPointsClickable: dataPointsClickable, + dataPointsClickEnabled: dataPointsClickEnabled, series: series, }); @@ -157,10 +157,10 @@ export class SkyChartDonutComponent { // #region Private #parseOptions(context: { - dataPointsClickable: boolean; + dataPointsClickEnabled: boolean; series: SkyChartSeries[]; }): SkyChartDonutOptions { - const { dataPointsClickable, series } = context; + const { dataPointsClickEnabled, series } = context; // Donut charts only supports a single series if (series.length > 1) { @@ -169,9 +169,9 @@ export class SkyChartDonutComponent { return { series: series[0], - dataPointsClickable: dataPointsClickable, + dataPointsClickEnabled: dataPointsClickEnabled, callbacks: { - onDataPointClick: (dataPoint) => this.dataPointClicked.emit(dataPoint), + onDataPointClick: (dataPoint) => this.dataPointClick.emit(dataPoint), }, }; } 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 index 086a2f5570..acf9c576f1 100644 --- 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 @@ -67,7 +67,7 @@ export class SkyChartLineConfigService { // Build Plugin options const pluginOptions: ChartOptions<'line'>['plugins'] = { - sky_indicator: { dataPointsClickable: options.dataPointsClickable }, + sky_indicator: { dataPointsClickEnabled: options.dataPointsClickEnabled }, sky_keyboard_nav: { valueLabel: (datasetIndex, dataIndex) => { const series = options.series[datasetIndex]; @@ -115,7 +115,7 @@ export class SkyChartLineConfigService { plugins: pluginOptions, onClick: (_, elements): void => { if ( - !options.dataPointsClickable || + !options.dataPointsClickEnabled || !options.callbacks?.onDataPointClick || elements.length === 0 ) { @@ -323,7 +323,7 @@ export interface SkyChartLineOptions { measureAxis?: SkyChartMeasureAxisConfig; /** Are the data points clickable */ - dataPointsClickable: boolean; + dataPointsClickEnabled: boolean; callbacks?: { onDataPointClick: ( 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 index 4741b61e74..f505dc4d08 100644 --- 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 @@ -65,7 +65,7 @@ export class SkyChartLineComponent { // #endregion // #region Inputs - public readonly dataPointsClickable = input(false, { + public readonly dataPointsClickEnabled = input(false, { transform: booleanAttribute, }); public readonly stacked = input(false, { transform: booleanAttribute }); @@ -78,7 +78,7 @@ export class SkyChartLineComponent { // #endregion // #region Outputs - public readonly dataPointClicked = + public readonly dataPointClick = output>(); // #endregion @@ -99,7 +99,7 @@ export class SkyChartLineComponent { readonly #refreshLegendItems = signal(0); readonly #chartOptions = computed(() => { - const dataPointsClickable = this.dataPointsClickable(); + const dataPointsClickEnabled = this.dataPointsClickEnabled(); const stacked = this.stacked(); const categoryAxis = this.#chartRegistry.categoryAxis(); @@ -107,7 +107,7 @@ export class SkyChartLineComponent { const series = this.#chartRegistry.series(); const options = this.#parseOptions({ - dataPointsClickable: dataPointsClickable, + dataPointsClickEnabled: dataPointsClickEnabled, stacked: stacked, categoryAxis: categoryAxis, measureAxis: measureAxis, @@ -170,23 +170,28 @@ export class SkyChartLineComponent { // #region Private #parseOptions(context: { - dataPointsClickable: boolean; + dataPointsClickEnabled: boolean; stacked: boolean; categoryAxis: Readonly | undefined; measureAxis: Readonly | undefined; series: SkyChartSeries[]; }): SkyChartLineOptions { - const { dataPointsClickable, stacked, categoryAxis, measureAxis, series } = - context; + const { + dataPointsClickEnabled, + stacked, + categoryAxis, + measureAxis, + series, + } = context; return { stacked: stacked, series: series, categoryAxis: categoryAxis ? categoryAxis : undefined, measureAxis: measureAxis ? measureAxis : undefined, - dataPointsClickable: dataPointsClickable, + dataPointsClickEnabled: dataPointsClickEnabled, callbacks: { - onDataPointClick: (dataPoint) => this.dataPointClicked.emit(dataPoint), + onDataPointClick: (dataPoint) => this.dataPointClick.emit(dataPoint), }, }; } 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 index a359a1b434..c9bddaf3ef 100644 --- 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 @@ -5,7 +5,7 @@ import type { ChartType } from 'chart.js'; */ export interface SkyIndicatorPluginOptions { /** Whether data points should be clickable */ - dataPointsClickable?: boolean; + dataPointsClickEnabled?: boolean; } // Augment Chart.js so `chart.options.plugins.sky_indicator` is strongly typed. 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 index 367d30f151..f3f4c62ca0 100644 --- 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 @@ -139,7 +139,7 @@ export function createIndicatorPlugin( afterEvent(chart, args, options): void { if (args.event.type === 'mousemove') { const elements = chart.getActiveElements(); - const clickable = options.dataPointsClickable; + const clickable = options.dataPointsClickEnabled; const showPointer = clickable && elements.length > 0; chart.canvas.style.cursor = showPointer ? 'pointer' : 'default'; } @@ -169,7 +169,7 @@ function resolveIndicatorStates( // Hover is the baseline (drawn first, lowest visual precedence). // Only shown when datapoint activation is enabled. const hovered = chart.getActiveElements(); - if (hovered?.length && options.dataPointsClickable) { + if (hovered?.length && options.dataPointsClickEnabled) { states.push({ elements: hovered, styles: { @@ -185,7 +185,7 @@ function resolveIndicatorStates( // Active overlays hover (pointer down / Space held). // Only shown when datapoint activation is enabled. const pressed = pressedElements.get(chart); - if (pressed?.length && options.dataPointsClickable) { + if (pressed?.length && options.dataPointsClickEnabled) { states.push({ elements: pressed, styles: { From e024b036e13103620d4561fca4797c4aad4ceb46 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 14:55:48 -0400 Subject: [PATCH 19/34] refactor: update documentation with latest class names --- libs/components/charts/documentation.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/libs/components/charts/documentation.json b/libs/components/charts/documentation.json index ef37894f08..8526ddc239 100644 --- a/libs/components/charts/documentation.json +++ b/libs/components/charts/documentation.json @@ -5,16 +5,22 @@ "development": { "docsIds": [ "SkyChartBarComponent", - "SkyBarChartSeriesComponent", + "SkyChartBarSeriesComponent", "SkyChartBarSeriesDataPointComponent", - "SkyChartComponent", "SkyChartCategoryAxisComponent", + "SkyChartComponent", + "SkyChartDonutComponent", + "SkyChartDonutSeriesComponent", + "SkyChartDonutSeriesDataPointComponent", + "SkyChartLineComponent", + "SkyChartLineSeriesComponent", + "SkyChartLineSeriesDataPointComponent", "SkyChartMeasureAxisComponent" ], "primaryDocsId": "SkyChartComponent" }, "testing": { - "docsIds": [] + "docsIds": ["SkyChartBarHarness", "SkyChartBarHarnessFilters"] }, "codeExamples": { "docsIds": [] From 4ac0139f5e065bed8c30e4911b6c61765ab6c88a Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Tue, 14 Apr 2026 18:04:41 -0400 Subject: [PATCH 20/34] refactor: update measure axis configuration to support overflow options and streamline scale creation --- .../chart-bar-demo.component.html | 3 +- .../chart-line-demo.component.html | 6 +- .../axis/chart-measure-axis.component.ts | 38 ++-- .../chart-bar/chart-bar-config.service.ts | 146 ++----------- .../chart-line/chart-line-config.service.ts | 151 ++----------- .../src/lib/modules/shared/chart-helpers.ts | 20 -- .../src/lib/modules/shared/scale-mapping.ts | 201 ++++++++++++++++++ .../lib/modules/shared/types/axis-types.ts | 10 +- 8 files changed, 267 insertions(+), 308 deletions(-) create mode 100644 libs/components/charts/src/lib/modules/shared/scale-mapping.ts 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 index 5a538da91f..d1bc2a8632 100644 --- 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 @@ -198,7 +198,8 @@ 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 index 49255099c2..c828ce9fb4 100644 --- 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 @@ -179,8 +179,10 @@ @for ( 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 index c4f8338d8d..fd6a641dc3 100644 --- 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 @@ -2,13 +2,13 @@ import { ChangeDetectionStrategy, Component, OnDestroy, + booleanAttribute, computed, effect, inject, input, numberAttribute, } from '@angular/core'; -import { SkyLogService } from '@skyux/core'; import { SkyChartAxisLabelText, @@ -26,7 +26,6 @@ import { SKY_CHART_AXIS_REGISTRY } from './chart-axis-registry.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkyChartMeasureAxisComponent implements OnDestroy { - readonly #logger = inject(SkyLogService); readonly #registry = inject(SKY_CHART_AXIS_REGISTRY); /** @@ -55,17 +54,19 @@ export class SkyChartMeasureAxisComponent implements OnDestroy { }); /** - * The preferred lower bound for the measure axis. The chart may still go below this value if the data requires it. + * 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 preferredMin = input(undefined, { - transform: numberAttribute, + public readonly allowMinOverflow = input(false, { + transform: booleanAttribute, }); /** - * The preferred upper bound for the measure axis. The chart may still exceed this value if the data requires it. + * 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 preferredMax = input(undefined, { - transform: numberAttribute, + public readonly allowMaxOverflow = input(false, { + transform: booleanAttribute, }); /** @@ -78,29 +79,14 @@ export class SkyChartMeasureAxisComponent implements OnDestroy { scaleType: this.scaleType(), min: this.min(), max: this.max(), - preferredMin: this.preferredMin(), - preferredMax: this.preferredMax(), + allowMinOverflow: this.allowMinOverflow(), + allowMaxOverflow: this.allowMaxOverflow(), }; }); constructor() { effect(() => { - const axis = this.axis(); - const { min, max, preferredMin, preferredMax } = axis; - - if (min !== undefined && preferredMin !== undefined) { - this.#logger.warn( - 'Both `min` and `preferredMin` are set on the measure axis. The `preferredMin` value will be ignored because `min` sets a hard lower bound.', - ); - } - - if (max !== undefined && preferredMax !== undefined) { - this.#logger.warn( - 'Both `max` and `preferredMax` are set on the measure axis. The `preferredMax` value will be ignored because `max` sets a hard upper bound.', - ); - } - - this.#registry.upsertMeasureAxis(axis); + this.#registry.upsertMeasureAxis(this.axis()); }); } 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 index 2334f1eafa..3f4c68040d 100644 --- 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 @@ -9,7 +9,12 @@ import { ScaleOptionsByType, } from 'chart.js'; -import { createLogTickFilter, parseCategories } from '../shared/chart-helpers'; +import { parseCategories } from '../shared/chart-helpers'; +import { + buildCategoryScale, + buildLinearMeasureScale, + buildLogarithmicMeasureScale, +} from '../shared/scale-mapping'; import { SkyChartStyleService, SkyChartStyles, @@ -299,149 +304,44 @@ export class SkyChartBarConfigService { } } - #getBaseScale(styles: SkyChartStyles): PartialBarScale { - const base: PartialBarScale = { - 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, - }, - }, - }; - - return base; - } - #createCategoryScale( styles: SkyChartStyles, options: SkyChartBarOptions, ): PartialBarScale { - const base = this.#getBaseScale(styles); + const scale = buildCategoryScale({ + styles, + stacked: options.stacked, + categoryAxis: options.categoryAxis, + }); - const categoryScale: PartialBarScale = { - type: 'category', - stacked: options.stacked ?? false, + return { + ...scale, grid: { + ...scale.grid, + // Hide grid lines to improve readability display: false, lineWidth: 0, drawTicks: false, tickLength: 0, }, - border: base.border, - ticks: { - ...base.ticks, - padding: styles.axis.ticks.padding, - }, - title: { - ...base.title, - display: !!options.categoryAxis?.labelText, - text: options.categoryAxis?.labelText ?? '', - }, }; - - return categoryScale; } #createMeasureScale( styles: SkyChartStyles, options: SkyChartBarOptions, ): PartialBarScale { - if (options.measureAxis?.scaleType === 'logarithmic') { - return this.#createLogarithmicMeasureScale(styles, options); - } else { - return this.#createLinearMeasureScale(styles, options); - } - } - - #createLinearMeasureScale( - styles: SkyChartStyles, - options: SkyChartBarOptions, - ): PartialBarScale { - const base = this.#getBaseScale(styles); - - const valueScale: PartialBarScale = { - type: 'linear', + const params = { + styles: styles, stacked: options.stacked ?? false, - min: options.measureAxis?.min, - max: options.measureAxis?.max, - suggestedMin: options.measureAxis?.preferredMin, - suggestedMax: options.measureAxis?.preferredMax, - grid: base.grid, - border: base.border, - ticks: { - ...base.ticks, - padding: styles.axis.ticks.padding, - }, - title: { - ...base.title, - display: !!options.measureAxis?.labelText, - text: options.measureAxis?.labelText, - }, + measureAxis: options.measureAxis, }; - return valueScale; - } - - #createLogarithmicMeasureScale( - styles: SkyChartStyles, - options: SkyChartBarOptions, - ): PartialBarScale { - const base = this.#getBaseScale(styles); - - const valueScale: PartialBarScale = { - type: 'logarithmic', - stacked: options.stacked ?? false, - min: options.measureAxis?.min, - max: options.measureAxis?.max, - suggestedMin: options.measureAxis?.preferredMin, - suggestedMax: options.measureAxis?.preferredMax, - grid: { - ...base.grid, - lineWidth: (ctx) => { - const tick = ctx.tick; - return !tick?.label ? 0 : styles.axis.grid.width; - }, - }, - border: base.border, - ticks: { - ...base.ticks, - padding: styles.axis.ticks.padding, - callback: createLogTickFilter, - }, - title: { - ...base.title, - display: !!options.measureAxis?.labelText, - text: options.measureAxis?.labelText, - }, - }; - - return valueScale; + if (options.measureAxis?.scaleType === 'logarithmic') { + return buildLogarithmicMeasureScale(params); + } else { + return buildLinearMeasureScale(params); + } } // #endregion } 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 index acf9c576f1..ecfd82f2a5 100644 --- 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 @@ -8,7 +8,12 @@ import { ScaleOptionsByType, } from 'chart.js'; -import { createLogTickFilter, parseCategories } from '../shared/chart-helpers'; +import { parseCategories } from '../shared/chart-helpers'; +import { + buildCategoryScale, + buildLinearMeasureScale, + buildLogarithmicMeasureScale, +} from '../shared/scale-mapping'; import { SkyChartStyleService, SkyChartStyles, @@ -160,151 +165,33 @@ export class SkyChartLineConfigService { styles: SkyChartStyles, config: SkyChartLineOptions, ): ChartOptions<'line'>['scales'] { - const categoryScale = this.#createCategoryScale(styles, config); + const categoryScale = buildCategoryScale({ + styles: styles, + stacked: config.stacked, + categoryAxis: config.categoryAxis, + }); + const measureScale = this.#createMeasureScale(styles, config); return { x: categoryScale, y: measureScale }; } - #getBaseScale(styles: SkyChartStyles): PartialLineScale { - const base: PartialLineScale = { - 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, - }, - }, - }; - - return base; - } - - #createCategoryScale( + #createMeasureScale( styles: SkyChartStyles, config: SkyChartLineOptions, ): PartialLineScale { - const base = this.#getBaseScale(styles); - - const categoryScale: PartialLineScale = { - type: 'category', - stacked: config.stacked ?? false, - grid: base.grid, - border: base.border, - ticks: { - ...base.ticks, - padding: styles.axis.ticks.padding, - }, - title: { - ...base.title, - display: !!config.categoryAxis?.labelText, - text: config.categoryAxis?.labelText, - }, + const params = { + styles, + stacked: config.stacked, + measureAxis: config.measureAxis, }; - return categoryScale; - } - - #createMeasureScale( - styles: SkyChartStyles, - config: SkyChartLineOptions, - ): PartialLineScale { if (config.measureAxis?.scaleType === 'logarithmic') { - return this.#createLogarithmicMeasureScale(styles, config); + return buildLogarithmicMeasureScale(params); } else { - return this.#createLinearMeasureScale(styles, config); + return buildLinearMeasureScale(params); } } - - #createLinearMeasureScale( - styles: SkyChartStyles, - config: SkyChartLineOptions, - ): PartialLineScale { - const base = this.#getBaseScale(styles); - - const valueScale: PartialLineScale = { - type: 'linear', - stacked: config.stacked ?? false, - min: config.measureAxis?.min, - max: config.measureAxis?.max, - suggestedMin: config.measureAxis?.preferredMin, - suggestedMax: config.measureAxis?.preferredMax, - grid: base.grid, - border: base.border, - ticks: { - ...base.ticks, - padding: styles.axis.ticks.padding, - }, - title: { - ...base.title, - display: !!config.measureAxis?.labelText, - text: config.measureAxis?.labelText, - }, - }; - - return valueScale; - } - - #createLogarithmicMeasureScale( - styles: SkyChartStyles, - config: SkyChartLineOptions, - ): PartialLineScale { - const base = this.#getBaseScale(styles); - - const valueScale: PartialLineScale = { - type: 'logarithmic', - stacked: config.stacked ?? false, - min: config.measureAxis?.min, - max: config.measureAxis?.max, - suggestedMin: config.measureAxis?.preferredMin, - suggestedMax: config.measureAxis?.preferredMax, - grid: { - ...base.grid, - lineWidth: (ctx) => { - const tick = ctx.tick; - return !tick?.label ? 0 : styles.axis.grid.width; - }, - }, - border: base.border, - ticks: { - ...base.ticks, - padding: styles.axis.ticks.padding, - callback: createLogTickFilter, - }, - title: { - ...base.title, - display: !!config.measureAxis?.labelText, - text: config.measureAxis?.labelText, - }, - }; - - return valueScale; - } } // #region Types diff --git a/libs/components/charts/src/lib/modules/shared/chart-helpers.ts b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts index c5e7c5b3b9..5efc31646a 100644 --- a/libs/components/charts/src/lib/modules/shared/chart-helpers.ts +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts @@ -133,23 +133,3 @@ export function getLegendItems(context: { return item; }); } - -/** - * 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 - */ -export 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/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/types/axis-types.ts b/libs/components/charts/src/lib/modules/shared/types/axis-types.ts index 80658e48e5..6d89a5e401 100644 --- a/libs/components/charts/src/lib/modules/shared/types/axis-types.ts +++ b/libs/components/charts/src/lib/modules/shared/types/axis-types.ts @@ -39,12 +39,14 @@ export interface SkyChartMeasureAxisConfig extends SkyChartAxisConfig { max?: number; /** - * The preferred minimum value for the axis. The axis may still go below this value if the data requires it. + * 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. */ - preferredMin?: number; + allowMinOverflow?: boolean; /** - * The preferred maximum value for the axis. The axis may still exceed this value if the data requires it. + * 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. */ - preferredMax?: number; + allowMaxOverflow?: boolean; } From 0228464b546ce30998b30028084a80e9c9d2735b Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Wed, 15 Apr 2026 16:27:05 -0400 Subject: [PATCH 21/34] Run "npm run dev:format" --- .../src/lib/modules/axis/chart-axis-registry.service.ts | 3 +-- .../lib/modules/chart-legend/chart-legend.component.ts | 4 +++- .../plugins/keyboard-nav/cartesian-navigation-strategy.ts | 2 +- .../src/lib/modules/shared/plugins/keyboard-nav/keys.ts | 8 ++++---- 4 files changed, 9 insertions(+), 8 deletions(-) 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 index fbbeefe3bc..992a416d2d 100644 --- 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 @@ -12,7 +12,6 @@ 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 */ @@ -36,7 +35,7 @@ export interface SkyChartAxisRegistry { /** * Updates or inserts the measure axis configuration. - * @param axis + * @param axis */ upsertMeasureAxis(axis: SkyChartMeasureAxisConfig): void; 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 index a1347a99cf..6522bcde68 100644 --- 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 @@ -38,7 +38,9 @@ export class SkyChartLegendComponent { protected readonly activeLegendIndex = signal(0); readonly #isLastVisible = computed(() => { - const visibleLegendItems = this.legendItems().filter((i) => i.isVisible).length; + const visibleLegendItems = this.legendItems().filter( + (i) => i.isVisible, + ).length; return visibleLegendItems === 1; }); 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 index 9a769d153d..3a366ec7fa 100644 --- 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 @@ -1,11 +1,11 @@ import type { ActiveElement, Chart } from 'chart.js'; +import type { NavigationKey } from './keys'; import type { ElementDescription, FocusedElement, NavigationStrategy, } from './navigation-strategy'; -import type { NavigationKey } from './keys'; /** * Navigation strategy for cartesian charts (bar, line, combo). 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 index 0703d37f6a..4bb087f8e1 100644 --- 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 @@ -9,9 +9,9 @@ export const ChartKeys = { Escape: 'Escape', /** Keys for activating a focused item */ - Activation: { - Space: ' ', - Enter: 'Enter' + Activation: { + Space: ' ', + Enter: 'Enter', }, /** Keys for navigating between data points and series within the chart. */ @@ -42,4 +42,4 @@ const arrowKeySet = new Set(Object.values(ChartKeys.Navigation)); export function isNavigationKey(key: string): key is NavigationKey { return arrowKeySet.has(key as NavigationKey); -} \ No newline at end of file +} From f48ccec38b73d0818a8aca5a9fe012fd604916d5 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Wed, 15 Apr 2026 17:50:48 -0400 Subject: [PATCH 22/34] tests: add ChartLegend tests and update ChartStyle tests --- .../chart-legend.component.spec.ts | 396 ++++++++++++++++++ .../chart-legend/chart-legend.component.ts | 12 +- .../chart-line/chart-line-config.service.ts | 2 +- .../services/chart-style.service.spec.ts | 160 +++++-- 4 files changed, 541 insertions(+), 29 deletions(-) create mode 100644 libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.spec.ts 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..d038c8b114 --- /dev/null +++ b/libs/components/charts/src/lib/modules/chart-legend/chart-legend.component.spec.ts @@ -0,0 +1,396 @@ +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 set aria-pressed to true for visible items', () => { + const items = createItems(1); + items[0].isVisible = true; + setItems(items); + + expect(getLegendButtons()[0].getAttribute('aria-pressed')).toBe('true'); + }); + + it('should set aria-pressed to false for hidden items', () => { + const items = createItems(1); + items[0].isVisible = false; + setItems(items); + + expect(getLegendButtons()[0].getAttribute('aria-pressed')).toBe('false'); + }); + + 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 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. + + SkyAppTestUtility.fireDomEvent(getLegendList(), 'focusin', { + customEventInit: { relatedTarget: null }, + }); + fixture.detectChanges(); + + 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 index 6522bcde68..edec6a5676 100644 --- 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 @@ -58,10 +58,14 @@ export class SkyChartLegendComponent { } protected onLegendFocusIn(event: FocusEvent): void { - const host = event.currentTarget as HTMLElement | null; - const related = event.relatedTarget as Node | null; - const enteredFromOutside = !related || !host?.contains(related); - + const host = event.currentTarget; + const related = event.relatedTarget; + const enteredFromOutside = + !(host instanceof HTMLElement) || + !(related instanceof Node) || + !host.contains(related); + + // When focus entered the legend from outside, set the active index to the first item and focus it. if (enteredFromOutside) { this.activeLegendIndex.set(0); this.#focusLegendButton(0); 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 index ecfd82f2a5..7c7e7cb48c 100644 --- 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 @@ -182,7 +182,7 @@ export class SkyChartLineConfigService { ): PartialLineScale { const params = { styles, - stacked: config.stacked, + stacked: config.stacked ?? false, measureAxis: config.measureAxis, }; 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 index c4f028a322..49738cf768 100644 --- 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 @@ -73,9 +73,14 @@ describe('SkyChartStyleService', () => { SkyThemeMode.presets.light, ); - it('should resolve series colors', () => { + it('should resolve palette colors', () => { const { service } = setupTest({ theme }); - expect(service.styles().series).toEqual(DefaultTheme.series); + 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', () => { @@ -180,9 +185,14 @@ describe('SkyChartStyleService', () => { SkyThemeMode.presets.light, ); - it('should resolve series colors', () => { + 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().series).toEqual(ModernTheme.series); + expect(service.styles().height).toEqual(ModernTheme.height); }); it('should resolve font family and chart padding', () => { @@ -280,16 +290,59 @@ describe('SkyChartStyleService', () => { // #region Test Data const DefaultTheme: SkyChartStyles = { - series: [ - '#06a39e', - '#6d3c96', - '#5589dd', - '#004252', - '#ce5600', - '#822325', - '#c650c1', - '#077e43', - ], + 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: { @@ -391,6 +444,14 @@ const DefaultTheme: SkyChartStyles = { borderColor: '#ffffff', borderWidth: 1, borderRadius: 2, + vertical: { + maxBarThickness: 112.5, + }, + horizontal: { + minBarThickness: 11.25, + maxBarThickness: 15, + minCategoryGap: 7.5, + }, }, line: { tension: 0.2, @@ -407,16 +468,59 @@ const DefaultTheme: SkyChartStyles = { }; const ModernTheme: SkyChartStyles = { - series: [ - '#06a39e', - '#6d3c96', - '#5589dd', - '#004252', - '#ce5600', - '#822325', - '#c650c1', - '#077e43', - ], + 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: { @@ -518,6 +622,14 @@ const ModernTheme: SkyChartStyles = { borderColor: '#ffffff', borderWidth: 1, borderRadius: 2, + vertical: { + maxBarThickness: 120, + }, + horizontal: { + minBarThickness: 12, + maxBarThickness: 16, + minCategoryGap: 8, + }, }, line: { tension: 0.2, From d478b1bc4b8b692090cc04ba3ca30f0b16f73a27 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Sat, 18 Apr 2026 14:28:31 -0400 Subject: [PATCH 23/34] fix: update the canvas' role from "img" to "application" so that screen readers do not eat keyboard input --- .../src/assets/locales/resources_en_US.json | 4 + .../modules/chart-bar/chart-bar.component.ts | 2 +- .../chart-donut/chart-donut.component.ts | 2 +- .../chart-line/chart-line.component.ts | 2 +- .../modules/chartjs/chartjs.directive.spec.ts | 232 ++++++++++++++++++ .../{ => chartjs}/chartjs.directive.ts | 14 +- .../shared/sky-charts-resources.module.ts | 1 + 7 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 libs/components/charts/src/lib/modules/chartjs/chartjs.directive.spec.ts rename libs/components/charts/src/lib/modules/{ => chartjs}/chartjs.directive.ts (86%) diff --git a/libs/components/charts/src/assets/locales/resources_en_US.json b/libs/components/charts/src/assets/locales/resources_en_US.json index 0b774fe332..3687f39f28 100644 --- a/libs/components/charts/src/assets/locales/resources_en_US.json +++ b/libs/components/charts/src/assets/locales/resources_en_US.json @@ -1,4 +1,8 @@ { + "chart.canvas.role_description": { + "_description": "The role description announced by screen readers when the chart canvas receives focus.", + "message": "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}.", 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 index 8744e3e27b..07c17ada3d 100644 --- 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 @@ -14,7 +14,7 @@ import { 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.directive'; +import { SkyChartJsDirective } from '../chartjs/chartjs.directive'; import { getLegendItems } from '../shared/chart-helpers'; import { SkyChartCategoryAxisConfig, 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 index 3afd82ac2d..80e24126b2 100644 --- 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 @@ -13,7 +13,7 @@ import { import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; import { SkyChartService } from '../chart/chart.service'; -import { SkyChartJsDirective } from '../chartjs.directive'; +import { SkyChartJsDirective } from '../chartjs/chartjs.directive'; import { getLegendItems } from '../shared/chart-helpers'; import type { SkyChartDataPointClickArgs } from '../shared/types/chart-data-point-click-args'; import { SkyChartSeries } from '../shared/types/chart-series'; 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 index f505dc4d08..34fb0ddedb 100644 --- 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 @@ -14,7 +14,7 @@ import { 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.directive'; +import { SkyChartJsDirective } from '../chartjs/chartjs.directive'; import { getLegendItems } from '../shared/chart-helpers'; import { SkyChartCategoryAxisConfig, 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.directive.ts b/libs/components/charts/src/lib/modules/chartjs/chartjs.directive.ts similarity index 86% rename from libs/components/charts/src/lib/modules/chartjs.directive.ts rename to libs/components/charts/src/lib/modules/chartjs/chartjs.directive.ts index d6cfbaed8d..0cd110f2fd 100644 --- a/libs/components/charts/src/lib/modules/chartjs.directive.ts +++ b/libs/components/charts/src/lib/modules/chartjs/chartjs.directive.ts @@ -11,6 +11,8 @@ import { signal, untracked, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { SkyLibResourcesService } from '@skyux/i18n'; import { Chart, ChartConfiguration, registerables } from 'chart.js'; @@ -25,7 +27,11 @@ Chart.register(...registerables); selector: 'canvas[skyChartJs]', host: { tabindex: '0', - role: 'img', + // 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"', @@ -35,6 +41,7 @@ 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 @@ -57,6 +64,11 @@ export class SkyChartJsDirective implements OnDestroy, AfterViewInit { public readonly chartUpdated = output(); // #endregion + protected readonly roleDescription = toSignal( + this.#resources.getString('chart.canvas.role_description'), + { initialValue: 'chart' }, + ); + readonly #canvasContext: CanvasRenderingContext2D; /** 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 index 23e6a2346a..9f38f10f10 100644 --- 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 @@ -14,6 +14,7 @@ import { const RESOURCES: Record = { 'EN-US': { + 'chart.canvas.role_description': { message: 'chart' }, 'chart.focus_element.multi_series.description': { message: '{0}, series {1} of {2}. {3}: {4}. Point {5} of {6}.', }, From 075a9cebdcaf9ef41e88ebef1642db327e21722c Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Sat, 18 Apr 2026 18:14:35 -0400 Subject: [PATCH 24/34] refactor: implement legend accessibility feedback --- .../src/assets/locales/resources_en_US.json | 13 +++- .../chart-legend/chart-legend.component.html | 65 +++++++++++-------- .../chart-legend/chart-legend.component.scss | 3 - .../chart-legend.component.spec.ts | 57 ++++++++++++++-- .../chart-legend/chart-legend.component.ts | 18 +++-- .../shared/sky-charts-resources.module.ts | 8 ++- 6 files changed, 123 insertions(+), 41 deletions(-) diff --git a/libs/components/charts/src/assets/locales/resources_en_US.json b/libs/components/charts/src/assets/locales/resources_en_US.json index 3687f39f28..994b842fb2 100644 --- a/libs/components/charts/src/assets/locales/resources_en_US.json +++ b/libs/components/charts/src/assets/locales/resources_en_US.json @@ -22,10 +22,21 @@ "_2": "Data point index (1-based)", "_3": "Total data points in the series" }, - "chart.legend.list_label": { + "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.view_data_table": { "_description": "The label for the 'View data table' chart menu item", "message": "View data table" 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 index d3c2bdf730..c929702093 100644 --- 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 @@ -1,8 +1,13 @@ @if (hasLegendItems()) { -
    {{ + 'chart.legend.legend_item.aria_description' | skyLibResources + }} + +
    @@ -10,29 +15,37 @@ item of legendItems(); track item.labelText + '-' + item.datasetIndex + '-' + item.index ) { -
  • - -
  • + {{ item.labelText }} + } -
+
} 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 index 69ede0c55e..a71c1cf2ac 100644 --- 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 @@ -8,9 +8,6 @@ display: flex; flex-wrap: wrap; justify-content: center; - list-style: none; - margin: 0; - padding: 0; } .sky-chart-legend-button { 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 index d038c8b114..81557e53eb 100644 --- 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 @@ -123,20 +123,55 @@ describe('SkyChartLegendComponent', () => { expect(icon.style.background).toBe('rgb(255, 0, 0)'); }); - it('should set aria-pressed to true for visible items', () => { + 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-pressed')).toBe('true'); + expect(getLegendButtons()[0].getAttribute('aria-checked')).toBe('true'); }); - it('should set aria-pressed to false for hidden items', () => { + 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-pressed')).toBe('false'); + 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', () => { @@ -152,6 +187,12 @@ describe('SkyChartLegendComponent', () => { 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)); @@ -338,11 +379,17 @@ describe('SkyChartLegendComponent', () => { 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: null }, + customEventInit: { relatedTarget: externalEl }, }); fixture.detectChanges(); + document.body.removeChild(externalEl); + 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 index edec6a5676..607b78ce1f 100644 --- 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 @@ -9,6 +9,7 @@ import { signal, viewChildren, } from '@angular/core'; +import { SkyIdModule } from '@skyux/core'; import { SkyChartsResourcesModule } from '../shared/sky-charts-resources.module'; @@ -18,7 +19,7 @@ import { SkyChartLegendItem } from './chart-legend-item'; selector: 'sky-chart-legend', templateUrl: './chart-legend.component.html', styleUrl: './chart-legend.component.scss', - imports: [SkyChartsResourcesModule], + imports: [SkyChartsResourcesModule, SkyIdModule], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkyChartLegendComponent { @@ -57,15 +58,18 @@ export class SkyChartLegendComponent { }); } + /** + * 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 Node) || + host instanceof HTMLElement && + related instanceof HTMLElement && !host.contains(related); - // When focus entered the legend from outside, set the active index to the first item and focus it. if (enteredFromOutside) { this.activeLegendIndex.set(0); this.#focusLegendButton(0); @@ -117,9 +121,13 @@ export class SkyChartLegendComponent { } } + 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 (item.isVisible && this.#isLastVisible()) { + if (this.isItemDisabled(item)) { return; } 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 index 9f38f10f10..bd54c4096f 100644 --- 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 @@ -21,7 +21,13 @@ const RESOURCES: Record = { 'chart.focus_element.single_series.description': { message: '{0}: {1}. Point {2} of {3}.', }, - 'chart.legend.list_label': { message: 'Chart legend' }, + '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.view_data_table': { message: 'View data table' }, 'chart_data_grid.category_column_name': { message: 'Category' }, 'chart_data_grid.close_button': { message: 'Close' }, From 656b7ca12c2a637c5cfbacf3842def9eaace541a Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Sat, 18 Apr 2026 18:18:00 -0400 Subject: [PATCH 25/34] (test) add chart css/style/helper tests --- .../lib/modules/shared/chart-helpers.spec.ts | 281 ++++++++++++++++ .../services/chart-css-utils.service.spec.ts | 310 ++++++++++++++++++ .../services/chart-style.service.spec.ts | 12 + 3 files changed, 603 insertions(+) create mode 100644 libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/services/chart-css-utils.service.spec.ts 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..b6a66f533c --- /dev/null +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts @@ -0,0 +1,281 @@ +import { Chart, LegendItem } from 'chart.js'; + +import { + getChartType, + getDatasetType, + getLegendItems, + isDatasetType, + isDonutChart, + parseCategories, +} from './chart-helpers'; +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); + }); + }); +}); + +// #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/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-style.service.spec.ts b/libs/components/charts/src/lib/modules/shared/services/chart-style.service.spec.ts index 49738cf768..2ceb8141e2 100644 --- 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 @@ -7,6 +7,7 @@ import { SkyThemeSettings, } from '@skyux/theme'; +import { SkyChartCssUtilsService } from './chart-css-utils.service'; import { SkyChartStyleService, type SkyChartStyles, @@ -286,6 +287,17 @@ describe('SkyChartStyleService', () => { 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 From 110744228f447557e18a8b92eb496105e17abde5 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Sat, 18 Apr 2026 18:24:02 -0400 Subject: [PATCH 26/34] (test) add tests for shared/plugins --- .../auto-color/auto-color-plugin.spec.ts | 246 +++++++ .../indicator/bar-indicator-bounds.spec.ts | 276 ++++++++ .../indicator/donut-indicator-bounds.spec.ts | 137 ++++ .../plugins/indicator/indicator-draw.spec.ts | 499 ++++++++++++++ .../indicator/indicator-plugin.spec.ts | 615 ++++++++++++++++++ .../indicator/line-indicator-bounds.spec.ts | 115 ++++ .../cartesian-navigation-strategy.spec.ts | 348 ++++++++++ .../cartesian-navigation-strategy.ts | 7 +- .../create-navigation-strategy.spec.ts | 39 ++ .../keyboard-nav/keyboard-nav-plugin.spec.ts | 602 +++++++++++++++++ .../keyboard-nav/keyboard-nav-plugin.ts | 26 +- .../shared/plugins/keyboard-nav/keys.spec.ts | 39 ++ .../radial-navigation-strategy.spec.ts | 204 ++++++ .../plugins/tooltip/tooltip-options.spec.ts | 185 ++++++ .../tooltip/tooltip-shadow-plugin.spec.ts | 243 +++++++ 15 files changed, 3571 insertions(+), 10 deletions(-) create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/auto-color/auto-color-plugin.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/bar-indicator-bounds.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/donut-indicator-bounds.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-draw.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/indicator-plugin.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/indicator/line-indicator-bounds.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/cartesian-navigation-strategy.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/create-navigation-strategy.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keyboard-nav-plugin.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/keys.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/keyboard-nav/radial-navigation-strategy.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-options.spec.ts create mode 100644 libs/components/charts/src/lib/modules/shared/plugins/tooltip/tooltip-shadow-plugin.spec.ts 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/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/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/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-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/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/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 index 3a366ec7fa..76de10bd05 100644 --- 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 @@ -150,12 +150,15 @@ export class CartesianNavigationStrategy implements NavigationStrategy { return this.#chart.data.datasets[datasetIndex]?.data.length ?? 0; } - #getActiveElement(datasetIndex: number, index: number): ActiveElement | null { + #getActiveElement( + datasetIndex: number, + index: number, + ): ActiveElement | undefined { const meta = this.#chart.getDatasetMeta(datasetIndex); const dataElement = meta?.data[index]; if (!dataElement) { - return null; + return undefined; } return { datasetIndex, index, element: dataElement }; 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/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 index 55be8ee0b0..ea7716b0c3 100644 --- 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 @@ -92,7 +92,6 @@ class ChartKeyboardManager { readonly #canvas: HTMLCanvasElement; readonly #resources: SkyLibResourcesService; readonly #liveAnnouncer: SkyLiveAnnouncerService; - readonly #getValueLabel: SkyValueLabelFn | undefined; readonly #boundKeyDownHandler: (e: KeyboardEvent) => void; @@ -101,8 +100,8 @@ class ChartKeyboardManager { readonly #boundBlurHandler: () => void; readonly #boundMouseDownHandler: () => void; - #focusedElement: FocusedElement | null = null; - #strategy: NavigationStrategy | null = null; + #focusedElement: FocusedElement | undefined = undefined; + #strategy: NavigationStrategy | undefined = undefined; #isNavigating = false; #focusFromMouse = false; @@ -217,8 +216,8 @@ class ChartKeyboardManager { #endNavigation(): void { this.#isNavigating = false; - this.#focusedElement = null; - this.#strategy = null; + this.#focusedElement = undefined; + this.#strategy = undefined; // Clear shared focus state so the indicator plugin stops drawing. focusedElementsState.set(this.#chart, []); @@ -253,7 +252,12 @@ class ChartKeyboardManager { ); if (element && this.#chart.config.options?.onClick) { - const chartEvent = {} as ChartEvent; + const chartEvent: ChartEvent = { + native: new Event('keyboard-activation'), + type: 'keydown', + x: 0, + y: 0, + }; this.#chart.config.options.onClick(chartEvent, [element], this.#chart); } } @@ -276,6 +280,7 @@ class ChartKeyboardManager { * 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; } @@ -298,6 +303,7 @@ class ChartKeyboardManager { * 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; @@ -314,6 +320,7 @@ class ChartKeyboardManager { * 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; } @@ -357,12 +364,15 @@ class ChartKeyboardManager { } } - #getActiveElement(datasetIndex: number, index: number): ActiveElement | null { + #getActiveElement( + datasetIndex: number, + index: number, + ): ActiveElement | undefined { const meta = this.#chart.getDatasetMeta(datasetIndex); const dataElement = meta?.data[index]; if (!dataElement) { - return null; + 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/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/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-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 From d1903e54033149f9a93371783c70f1348db2decb Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Sun, 19 Apr 2026 16:14:53 -0400 Subject: [PATCH 27/34] (test) add tests for category axis, measure axis, and chart data grid modal --- libs/components/charts/project.json | 3 +- .../chart-category-axis.component.spec.ts | 102 ++++++++ .../axis/chart-category-axis.component.ts | 5 +- .../axis/chart-measure-axis.component.spec.ts | 186 ++++++++++++++ .../axis/chart-measure-axis.component.ts | 4 +- .../chart-data-grid-modal.component.spec.ts | 227 ++++++++++++++++++ .../chart-data-grid-modal.component.ts | 21 +- 7 files changed, 535 insertions(+), 13 deletions(-) create mode 100644 libs/components/charts/src/lib/modules/axis/chart-category-axis.component.spec.ts create mode 100644 libs/components/charts/src/lib/modules/axis/chart-measure-axis.component.spec.ts create mode 100644 libs/components/charts/src/lib/modules/chart-data-grid-modal/chart-data-grid-modal.component.spec.ts diff --git a/libs/components/charts/project.json b/libs/components/charts/project.json index 034268050f..6957040b25 100644 --- a/libs/components/charts/project.json +++ b/libs/components/charts/project.json @@ -43,7 +43,8 @@ "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/theme/src/lib/styles/themes/modern/styles.scss", + "libs/components/ag-grid/src/lib/styles/ag-grid-styles.scss" ], "codeCoverage": true, "codeCoverageExclude": ["**/fixtures/**"], 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 index 146541befe..324cb2b883 100644 --- 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 @@ -33,9 +33,8 @@ export class SkyChartCategoryAxisComponent implements OnDestroy { /** * The axis object - * @internal */ - public readonly axis = computed(() => { + readonly #axis = computed(() => { return { labelText: this.labelText(), }; @@ -43,7 +42,7 @@ export class SkyChartCategoryAxisComponent implements OnDestroy { constructor() { effect(() => { - const axis = this.axis(); + const axis = this.#axis(); this.#registry.upsertCategoryAxis(axis); }); } 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 index fd6a641dc3..59f3e86918 100644 --- 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 @@ -73,7 +73,7 @@ export class SkyChartMeasureAxisComponent implements OnDestroy { * The axis object * @internal */ - public readonly axis = computed(() => { + readonly #axis = computed(() => { return { labelText: this.labelText(), scaleType: this.scaleType(), @@ -86,7 +86,7 @@ export class SkyChartMeasureAxisComponent implements OnDestroy { constructor() { effect(() => { - this.#registry.upsertMeasureAxis(this.axis()); + this.#registry.upsertMeasureAxis(this.#axis()); }); } 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 index cc6b32d3cb..dff7a33ad1 100644 --- 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 @@ -21,6 +21,7 @@ import { } 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'; @@ -35,7 +36,7 @@ ModuleRegistry.registerModules([ RowStyleModule, ColumnApiModule, RowApiModule, - // Editing isn't needed but SkyUX's implementation uses `api.getEditingCells` which requires this module. + // Editing isn't needed but skyux/ag-charts uses `api.getEditingCells` which requires this module. TextEditorModule, ]); @@ -110,7 +111,7 @@ export class SkyChartDataGridModalComponent { } #buildRowData( - categories: readonly (string | number)[], + categories: readonly SkyCategory[], series: readonly SkyChartSeries[], ): ChartDataGridRow[] { const rows: ChartDataGridRow[] = []; @@ -132,18 +133,24 @@ export class SkyChartDataGridModalComponent { return rows; } - #getSeriesFieldId(seriesIndex: number): string { + #getSeriesFieldId(seriesIndex: number): SeriesFieldId { return `series_${seriesIndex}`; } } interface ChartDataGridRow { /** The category for this row. */ - category: string | number; + category: SkyCategory; /** - * Key: the series' label - * Value: the data point's label + * Dynamically generated fields for each series. */ - [key: string]: string | number; + [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}`; From 93baa9d4810d2b552f46c143b6cae5ef44cbf9f9 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 20 Apr 2026 09:38:06 -0400 Subject: [PATCH 28/34] (fix) chart should not be wrapped in a
--- .../modules/chart/chart-header-id-token.ts | 22 ------------------- .../lib/modules/chart/chart.component.html | 12 ++-------- .../src/lib/modules/chart/chart.component.ts | 7 +----- 3 files changed, 3 insertions(+), 38 deletions(-) delete mode 100644 libs/components/charts/src/lib/modules/chart/chart-header-id-token.ts diff --git a/libs/components/charts/src/lib/modules/chart/chart-header-id-token.ts b/libs/components/charts/src/lib/modules/chart/chart-header-id-token.ts deleted file mode 100644 index ec8cb99e65..0000000000 --- a/libs/components/charts/src/lib/modules/chart/chart-header-id-token.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { InjectionToken, Provider, inject } from '@angular/core'; -import { SkyIdService } from '@skyux/core'; - -/** - * Injection token for the chart header ID, used to associate the chart header with its content for accessibility purposes. - */ -export const SKY_CHART_HEADER_ID = new InjectionToken( - 'SKY_CHART_HEADER_ID', -); - -/** - * Factory function to provide a unique ID for the chart header. - */ -export function provideSkyChartHeaderId(): Provider { - return { - provide: SKY_CHART_HEADER_ID, - useFactory(): string { - const idService = inject(SkyIdService); - return idService.generateId(); - }, - }; -} diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.html b/libs/components/charts/src/lib/modules/chart/chart.component.html index 17f1734bcc..92c298e011 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.html +++ b/libs/components/charts/src/lib/modules/chart/chart.component.html @@ -1,8 +1,4 @@ -
+

{{ headingText() }}

{{ headingText() }}

{{ headingText() }}

{{ headingText() }}

-
+
diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.ts b/libs/components/charts/src/lib/modules/chart/chart.component.ts index a591929de6..97a02575b5 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.ts +++ b/libs/components/charts/src/lib/modules/chart/chart.component.ts @@ -28,10 +28,6 @@ import { headingStyleInputTransformer, } from '../shared/types/chart-heading-style'; -import { - SKY_CHART_HEADER_ID, - provideSkyChartHeaderId, -} from './chart-header-id-token'; import { SkyChartService } from './chart.service'; /** @@ -48,12 +44,11 @@ import { SkyChartService } from './chart.service'; SkyHelpInlineModule, SkyChartLegendComponent, ], - providers: [provideSkyChartHeaderId(), SkyChartService], + providers: [SkyChartService], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkyChartComponent { // #region Dependency Injection - protected readonly headerId = inject(SKY_CHART_HEADER_ID); readonly #modalService = inject(SkyModalService); readonly #chartService = inject(SkyChartService); // #endregion From f66048cae38f4bc4f8e2659a2aee90e4d88890c7 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 20 Apr 2026 19:17:06 -0400 Subject: [PATCH 29/34] Add missing peers --- libs/components/charts/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/components/charts/package.json b/libs/components/charts/package.json index e0b64def95..1229da02e2 100644 --- a/libs/components/charts/package.json +++ b/libs/components/charts/package.json @@ -21,9 +21,12 @@ "@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" }, From 032f25a35de5fd0128f187041e560e94f4a11e70 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 20 Apr 2026 19:28:34 -0400 Subject: [PATCH 30/34] (feat) update chart context menu and items to have accessible labels --- .../charts/src/assets/locales/resources_en_US.json | 10 ++++++++++ .../src/lib/modules/chart/chart.component.html | 14 ++++++++++++-- .../modules/shared/sky-charts-resources.module.ts | 4 ++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/libs/components/charts/src/assets/locales/resources_en_US.json b/libs/components/charts/src/assets/locales/resources_en_US.json index 994b842fb2..b8d686da99 100644 --- a/libs/components/charts/src/assets/locales/resources_en_US.json +++ b/libs/components/charts/src/assets/locales/resources_en_US.json @@ -37,10 +37,20 @@ "_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" diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.html b/libs/components/charts/src/lib/modules/chart/chart.component.html index 92c298e011..81c62cfe2b 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.html +++ b/libs/components/charts/src/lib/modules/chart/chart.component.html @@ -78,10 +78,20 @@
- + - 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 index bd54c4096f..4c0611a881 100644 --- 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 @@ -28,7 +28,11 @@ const RESOURCES: Record = { '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' }, }, From 59331817df6cb410961b37abfc54a817ad64b284 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 20 Apr 2026 19:55:24 -0400 Subject: [PATCH 31/34] (fix) hide heading and subtitle from screen reader when not visible --- .../lib/modules/chart/chart.component.html | 100 +++++++----------- .../src/lib/modules/chart/chart.component.ts | 5 - 2 files changed, 41 insertions(+), 64 deletions(-) diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.html b/libs/components/charts/src/lib/modules/chart/chart.component.html index 81c62cfe2b..5172e50686 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.html +++ b/libs/components/charts/src/lib/modules/chart/chart.component.html @@ -1,67 +1,49 @@
-
- -
- @switch (headingLevel()) { - @case (2) { - -

{{ headingText() }}

- } - @case (4) { - -

{{ headingText() }}

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

{{ headingText() }}

- } + @if (!headingHidden() || (subtitleText() && !subtitleHidden())) { +
+ + @if (!headingHidden()) { +
+ @switch (headingLevel()) { + @case (2) { + +

{{ headingText() }}

+ } + @case (4) { + +

{{ headingText() }}

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

{{ headingText() }}

+ } + } + + @if (helpPopoverContent() || helpKey()) { + + + + } +
} - @if ((helpPopoverContent() || helpKey()) && !headingHidden()) { - - - + + @if (subtitleText() && !subtitleHidden()) { +
+ {{ subtitleText() }} +
}
- - - @if (subtitleText()) { -
- {{ subtitleText() }} -
- } -
+ }
diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.ts b/libs/components/charts/src/lib/modules/chart/chart.component.ts index 97a02575b5..f213087e44 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.ts +++ b/libs/components/charts/src/lib/modules/chart/chart.component.ts @@ -117,11 +117,6 @@ export class SkyChartComponent { public helpKey = input(); // #endregion - protected readonly includeHeaderMargin = computed(() => { - const headingVisible = !!this.headingText() && !this.headingHidden(); - const subtitleVisible = !!this.subtitleText() && !this.subtitleHidden(); - return headingVisible || subtitleVisible; - }); protected readonly headingClass = computed( () => `sky-font-heading-${this.headingStyle()}`, ); From b7854bac69995102e46b5905a8a0653d67e87839 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 20 Apr 2026 22:05:03 -0400 Subject: [PATCH 32/34] (feat) surround chart in a figure and generate chart summary from chart config to use as figure's aria label --- .../src/assets/locales/resources_en_US.json | 41 +++++++- .../modules/chart-bar/chart-bar.component.ts | 81 +++++++++++++++- .../chart-donut/chart-donut.component.ts | 67 +++++++++++-- .../chart-line/chart-line.component.ts | 94 +++++++++++++++++-- .../lib/modules/chart/chart.component.html | 8 +- .../lib/modules/chart/chart.component.scss | 1 + .../src/lib/modules/chart/chart.component.ts | 5 + .../src/lib/modules/chart/chart.service.ts | 4 + .../lib/modules/shared/chart-helpers.spec.ts | 25 +++++ .../src/lib/modules/shared/chart-helpers.ts | 15 +++ .../shared/sky-charts-resources.module.ts | 22 ++++- 11 files changed, 337 insertions(+), 26 deletions(-) diff --git a/libs/components/charts/src/assets/locales/resources_en_US.json b/libs/components/charts/src/assets/locales/resources_en_US.json index b8d686da99..6359c08216 100644 --- a/libs/components/charts/src/assets/locales/resources_en_US.json +++ b/libs/components/charts/src/assets/locales/resources_en_US.json @@ -3,6 +3,18 @@ "_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}.", @@ -28,7 +40,7 @@ }, "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" + "message": "Press Space or Enter to toggle inclusion in chart." }, "chart.legend.legend_item.aria_label": { "_description": "The aria-label for a legend button.", @@ -58,5 +70,32 @@ "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/lib/modules/chart-bar/chart-bar.component.ts b/libs/components/charts/src/lib/modules/chart-bar/chart-bar.component.ts index 07c17ada3d..b550ddf1ef 100644 --- 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 @@ -10,12 +10,17 @@ import { signal, viewChild, } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { Observable, combineLatest, of } from 'rxjs'; +import { map, 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 { getLegendItems } from '../shared/chart-helpers'; +import { getAxisLabelText, getLegendItems } from '../shared/chart-helpers'; import { SkyChartCategoryAxisConfig, SkyChartMeasureAxisConfig, @@ -45,7 +50,7 @@ import {
@@ -66,6 +71,7 @@ export class SkyChartBarComponent { readonly #chartService = inject(SkyChartService); readonly #chartRegistry = inject(SkyChartBarRegistry); readonly #chartConfigService = inject(SkyChartBarConfigService); + readonly #resources = inject(SkyLibResourcesService); // #endregion // #region Inputs @@ -91,7 +97,6 @@ export class SkyChartBarComponent { protected readonly chartDirective = viewChild(SkyChartJsDirective); // #endregion - protected readonly arialLabel = this.#chartService.headingText; readonly #chart = computed(() => this.chartDirective()?.chart()); readonly #chartUpdated = signal(0); readonly #refreshLegendItems = signal(0); @@ -125,6 +130,18 @@ export class SkyChartBarComponent { 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(); @@ -150,6 +167,12 @@ export class SkyChartBarComponent { }); 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(); @@ -221,5 +244,57 @@ export class SkyChartBarComponent { // Refetch the legend items to reflect the updated visibility state this.#refreshLegendItems.update((v) => v + 1); } + + #buildChartSummary(options: SkyChartBarOptions): Observable { + const categoryAxisLabel = getAxisLabelText(options.categoryAxis); + const measureAxisLabel = getAxisLabelText(options.measureAxis); + + const chartTypeDescription$ = this.#resources.getString( + 'chart.summary.bar_chart', + options.series.length, + ); + const categoryAxis$ = categoryAxisLabel + ? this.#resources.getString( + 'chart.summary.category_axis', + categoryAxisLabel, + ) + : of(''); + const measureAxis$ = measureAxisLabel + ? this.#resources.getString( + 'chart.summary.measure_axis', + measureAxisLabel, + ) + : of(''); + + return combineLatest([ + chartTypeDescription$, + categoryAxis$, + measureAxis$, + ]).pipe( + map(([chartTypeDescription, categoryAxis, measureAxis]) => { + const parts: string[] = []; + + const heading = this.#chartService.headingText(); + if (heading) { + parts.push(heading); + } + + parts.push(chartTypeDescription); + + const subtitle = this.#chartService.subtitleText(); + if (subtitle) { + parts.push(subtitle); + } + + if (categoryAxis) { + parts.push(categoryAxis); + } + if (measureAxis) { + parts.push(measureAxis); + } + return parts.join(' '); + }), + ); + } // #endregion } 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 index 80e24126b2..de416316bd 100644 --- 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 @@ -10,6 +10,11 @@ import { signal, viewChild, } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import type { Observable } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; import { SkyChartLegendItem } from '../chart-legend/chart-legend-item'; import { SkyChartService } from '../chart/chart.service'; @@ -36,7 +41,7 @@ import { SkyChartDonutDatum, SkyChartDonutSlice } from './chart-donut-types';
@@ -54,6 +59,7 @@ export class SkyChartDonutComponent { readonly #chartService = inject(SkyChartService); readonly #chartRegistry = inject(SkyChartDonutRegistry); readonly #chartConfigService = inject(SkyChartDonutConfigService); + readonly #resources = inject(SkyLibResourcesService); // #endregion // #region Inputs @@ -77,14 +83,6 @@ export class SkyChartDonutComponent { protected readonly chartDirective = viewChild(SkyChartJsDirective); // #endregion - protected readonly arialLabel = this.#chartService.headingText; - - /** The height of the chart */ - protected readonly chartHeight = computed(() => { - const explicitHeight = this.height(); - return explicitHeight ?? this.#chartConfigService.getChartHeight(); - }); - readonly #chart = computed(() => this.chartDirective()?.chart()); readonly #chartUpdated = signal(0); readonly #refreshLegendItems = signal(0); @@ -100,6 +98,24 @@ export class SkyChartDonutComponent { 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(); @@ -128,6 +144,12 @@ export class SkyChartDonutComponent { }); 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(); @@ -189,5 +211,32 @@ export class SkyChartDonutComponent { // 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 chartTypeDescription$.pipe( + map((chartTypeDescription) => { + const parts: string[] = []; + + const heading = this.#chartService.headingText(); + if (heading) { + parts.push(heading); + } + + parts.push(chartTypeDescription); + + const subtitle = this.#chartService.subtitleText(); + if (subtitle) { + parts.push(subtitle); + } + + return parts.join(' '); + }), + ); + } // #endregion } 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 index 34fb0ddedb..d42dd0aa84 100644 --- 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 @@ -10,12 +10,17 @@ import { signal, viewChild, } from '@angular/core'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { Observable, combineLatest, of } from 'rxjs'; +import { map, 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 { getLegendItems } from '../shared/chart-helpers'; +import { getAxisLabelText, getLegendItems } from '../shared/chart-helpers'; import { SkyChartCategoryAxisConfig, SkyChartMeasureAxisConfig, @@ -41,7 +46,7 @@ import { SkyChartLineDatum, SkyChartLinePoint } from './chart-line-types';
@@ -62,6 +67,7 @@ export class SkyChartLineComponent { readonly #chartService = inject(SkyChartService); readonly #chartRegistry = inject(SkyChartLineRegistry); readonly #chartConfigService = inject(SkyChartLineConfigService); + readonly #resources = inject(SkyLibResourcesService); // #endregion // #region Inputs @@ -86,14 +92,6 @@ export class SkyChartLineComponent { protected readonly chartDirective = viewChild(SkyChartJsDirective); // #endregion - protected readonly arialLabel = this.#chartService.headingText; - - /** The height of the chart */ - protected readonly chartHeight = computed(() => { - const explicitHeight = this.height(); - return explicitHeight ?? this.#chartConfigService.getChartHeight(); - }); - readonly #chart = computed(() => this.chartDirective()?.chart()); readonly #chartUpdated = signal(0); readonly #refreshLegendItems = signal(0); @@ -117,6 +115,24 @@ export class SkyChartLineComponent { 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(); @@ -142,6 +158,12 @@ export class SkyChartLineComponent { }); 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(); @@ -210,5 +232,57 @@ export class SkyChartLineComponent { // Refetch the legend items to reflect the updated visibility state this.#refreshLegendItems.update((v) => v + 1); } + + #buildChartSummary(options: SkyChartLineOptions): Observable { + const categoryAxisLabel = getAxisLabelText(options.categoryAxis); + const measureAxisLabel = getAxisLabelText(options.measureAxis); + + const chartTypeDescription$ = this.#resources.getString( + 'chart.summary.line_chart', + options.series.length, + ); + const categoryAxis$ = categoryAxisLabel + ? this.#resources.getString( + 'chart.summary.category_axis', + categoryAxisLabel, + ) + : of(''); + const measureAxis$ = measureAxisLabel + ? this.#resources.getString( + 'chart.summary.measure_axis', + measureAxisLabel, + ) + : of(''); + + return combineLatest([ + chartTypeDescription$, + categoryAxis$, + measureAxis$, + ]).pipe( + map(([chartTypeDescription, categoryAxis, measureAxis]) => { + const parts: string[] = []; + + const heading = this.#chartService.headingText(); + if (heading) { + parts.push(heading); + } + + parts.push(chartTypeDescription); + + const subtitle = this.#chartService.subtitleText(); + if (subtitle) { + parts.push(subtitle); + } + + if (categoryAxis) { + parts.push(categoryAxis); + } + if (measureAxis) { + parts.push(measureAxis); + } + return parts.join(' '); + }), + ); + } // #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 index 5172e50686..a106126f0a 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.html +++ b/libs/components/charts/src/lib/modules/chart/chart.component.html @@ -46,9 +46,13 @@

{{ headingText() }}

} -
+
-
+ @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 index 801133fa48..c174d23ba6 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.scss +++ b/libs/components/charts/src/lib/modules/chart/chart.component.scss @@ -35,6 +35,7 @@ .sky-chart-content { grid-column: 1 / -1; + margin: 0; } sky-chart-legend { diff --git a/libs/components/charts/src/lib/modules/chart/chart.component.ts b/libs/components/charts/src/lib/modules/chart/chart.component.ts index f213087e44..65a6bbf2e4 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.component.ts +++ b/libs/components/charts/src/lib/modules/chart/chart.component.ts @@ -120,11 +120,16 @@ export class SkyChartComponent { 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 { diff --git a/libs/components/charts/src/lib/modules/chart/chart.service.ts b/libs/components/charts/src/lib/modules/chart/chart.service.ts index 60d5370954..98037dac37 100644 --- a/libs/components/charts/src/lib/modules/chart/chart.service.ts +++ b/libs/components/charts/src/lib/modules/chart/chart.service.ts @@ -11,6 +11,10 @@ import { SkyChartSeries } from '../shared/types/chart-series'; export class SkyChartService { public readonly headingText = signal(''); + public readonly subtitleText = signal(''); + + public readonly generatedChartSummary = signal(''); + public readonly series = signal[]>([]); public readonly legendItems = signal([]); 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 index b6a66f533c..46a6956f09 100644 --- a/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts @@ -1,6 +1,7 @@ import { Chart, LegendItem } from 'chart.js'; import { + getAxisLabelText, getChartType, getDatasetType, getLegendItems, @@ -12,6 +13,30 @@ import { SkyChartDataPoint } from './types/chart-data-point'; import { SkyChartSeries } from './types/chart-series'; describe('chart-helpers', () => { + describe('getAxisLabelText', () => { + it('should return undefined when config is undefined', () => { + expect(getAxisLabelText(undefined)).toBeUndefined(); + }); + + it('should return undefined when config.labelText is undefined', () => { + expect(getAxisLabelText({})).toBeUndefined(); + }); + + it('should return undefined when config.labelText is an empty string', () => { + expect(getAxisLabelText({ labelText: '' })).toBeUndefined(); + }); + + it('should return the string when labelText is a string', () => { + expect(getAxisLabelText({ labelText: 'Revenue' })).toBe('Revenue'); + }); + + it('should join array elements with comma when labelText is an array', () => { + expect(getAxisLabelText({ labelText: ['Line 1', 'Line 2'] })).toBe( + 'Line 1,Line 2', + ); + }); + }); + describe('getDatasetType', () => { it('should return the dataset type when explicitly set', () => { const chart = createMockChart('bar'); diff --git a/libs/components/charts/src/lib/modules/shared/chart-helpers.ts b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts index 5efc31646a..890f5892e8 100644 --- a/libs/components/charts/src/lib/modules/shared/chart-helpers.ts +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts @@ -2,10 +2,25 @@ import { Chart, ChartConfiguration, ChartDataset, ChartType } from 'chart.js'; 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'; +/** + * Returns the label string from an axis config's `labelText`, or `undefined` when no label is set. + * @internal + */ +export function getAxisLabelText( + config: Readonly | undefined, +): string | undefined { + const labelText = config?.labelText; + if (!labelText) { + return undefined; + } + return Array.isArray(labelText) ? labelText.join(',') : labelText; +} + /** * 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. 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 index 4c0611a881..54ae6ab399 100644 --- 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 @@ -15,6 +15,9 @@ import { 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}.', }, @@ -28,13 +31,30 @@ const RESOURCES: Record = { 'chart.legend.legend_item.aria_label': { message: '{0}, Legend item {1} of {2}', }, - 'chart.menu.label': { message: 'Context menu for {0}' }, + '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}.', + }, }, }; From b3d3f2fcd782241eb04ab8ee71b4eccc7a03a4ce Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 20 Apr 2026 22:27:50 -0400 Subject: [PATCH 33/34] refactor: consolidate buildChartSummary into a helper --- .../modules/chart-bar/chart-bar.component.ts | 60 +--- .../chart-donut/chart-donut.component.ts | 29 +- .../chart-line/chart-line.component.ts | 60 +--- .../lib/modules/shared/chart-helpers.spec.ts | 295 ++++++++++++++++-- .../src/lib/modules/shared/chart-helpers.ts | 89 +++++- 5 files changed, 377 insertions(+), 156 deletions(-) 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 index b550ddf1ef..d080e53a8c 100644 --- 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 @@ -13,14 +13,14 @@ import { import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { SkyLibResourcesService } from '@skyux/i18n'; -import { Observable, combineLatest, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +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 { getAxisLabelText, getLegendItems } from '../shared/chart-helpers'; +import { buildChartSummary, getLegendItems } from '../shared/chart-helpers'; import { SkyChartCategoryAxisConfig, SkyChartMeasureAxisConfig, @@ -246,55 +246,19 @@ export class SkyChartBarComponent { } #buildChartSummary(options: SkyChartBarOptions): Observable { - const categoryAxisLabel = getAxisLabelText(options.categoryAxis); - const measureAxisLabel = getAxisLabelText(options.measureAxis); - const chartTypeDescription$ = this.#resources.getString( 'chart.summary.bar_chart', options.series.length, ); - const categoryAxis$ = categoryAxisLabel - ? this.#resources.getString( - 'chart.summary.category_axis', - categoryAxisLabel, - ) - : of(''); - const measureAxis$ = measureAxisLabel - ? this.#resources.getString( - 'chart.summary.measure_axis', - measureAxisLabel, - ) - : of(''); - - return combineLatest([ - chartTypeDescription$, - categoryAxis$, - measureAxis$, - ]).pipe( - map(([chartTypeDescription, categoryAxis, measureAxis]) => { - const parts: string[] = []; - - const heading = this.#chartService.headingText(); - if (heading) { - parts.push(heading); - } - - parts.push(chartTypeDescription); - - const subtitle = this.#chartService.subtitleText(); - if (subtitle) { - parts.push(subtitle); - } - - if (categoryAxis) { - parts.push(categoryAxis); - } - if (measureAxis) { - parts.push(measureAxis); - } - return parts.join(' '); - }), - ); + + 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-donut/chart-donut.component.ts b/libs/components/charts/src/lib/modules/chart-donut/chart-donut.component.ts index de416316bd..7317dd3930 100644 --- 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 @@ -14,12 +14,12 @@ import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { SkyLibResourcesService } from '@skyux/i18n'; import type { Observable } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +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 { getLegendItems } from '../shared/chart-helpers'; +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'; @@ -218,25 +218,12 @@ export class SkyChartDonutComponent { options.series.data.length, ); - return chartTypeDescription$.pipe( - map((chartTypeDescription) => { - const parts: string[] = []; - - const heading = this.#chartService.headingText(); - if (heading) { - parts.push(heading); - } - - parts.push(chartTypeDescription); - - const subtitle = this.#chartService.subtitleText(); - if (subtitle) { - parts.push(subtitle); - } - - return parts.join(' '); - }), - ); + 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-line/chart-line.component.ts b/libs/components/charts/src/lib/modules/chart-line/chart-line.component.ts index d42dd0aa84..c263e7cf3f 100644 --- 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 @@ -13,14 +13,14 @@ import { import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { SkyLibResourcesService } from '@skyux/i18n'; -import { Observable, combineLatest, of } from 'rxjs'; -import { map, switchMap } from 'rxjs/operators'; +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 { getAxisLabelText, getLegendItems } from '../shared/chart-helpers'; +import { buildChartSummary, getLegendItems } from '../shared/chart-helpers'; import { SkyChartCategoryAxisConfig, SkyChartMeasureAxisConfig, @@ -234,55 +234,19 @@ export class SkyChartLineComponent { } #buildChartSummary(options: SkyChartLineOptions): Observable { - const categoryAxisLabel = getAxisLabelText(options.categoryAxis); - const measureAxisLabel = getAxisLabelText(options.measureAxis); - const chartTypeDescription$ = this.#resources.getString( 'chart.summary.line_chart', options.series.length, ); - const categoryAxis$ = categoryAxisLabel - ? this.#resources.getString( - 'chart.summary.category_axis', - categoryAxisLabel, - ) - : of(''); - const measureAxis$ = measureAxisLabel - ? this.#resources.getString( - 'chart.summary.measure_axis', - measureAxisLabel, - ) - : of(''); - - return combineLatest([ - chartTypeDescription$, - categoryAxis$, - measureAxis$, - ]).pipe( - map(([chartTypeDescription, categoryAxis, measureAxis]) => { - const parts: string[] = []; - - const heading = this.#chartService.headingText(); - if (heading) { - parts.push(heading); - } - - parts.push(chartTypeDescription); - - const subtitle = this.#chartService.subtitleText(); - if (subtitle) { - parts.push(subtitle); - } - - if (categoryAxis) { - parts.push(categoryAxis); - } - if (measureAxis) { - parts.push(measureAxis); - } - return parts.join(' '); - }), - ); + + 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/shared/chart-helpers.spec.ts b/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts index 46a6956f09..6ca5044731 100644 --- a/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.spec.ts @@ -1,7 +1,10 @@ +import { SkyLibResourcesService } from '@skyux/i18n'; + import { Chart, LegendItem } from 'chart.js'; +import { of } from 'rxjs'; import { - getAxisLabelText, + buildChartSummary, getChartType, getDatasetType, getLegendItems, @@ -9,34 +12,11 @@ import { 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('getAxisLabelText', () => { - it('should return undefined when config is undefined', () => { - expect(getAxisLabelText(undefined)).toBeUndefined(); - }); - - it('should return undefined when config.labelText is undefined', () => { - expect(getAxisLabelText({})).toBeUndefined(); - }); - - it('should return undefined when config.labelText is an empty string', () => { - expect(getAxisLabelText({ labelText: '' })).toBeUndefined(); - }); - - it('should return the string when labelText is a string', () => { - expect(getAxisLabelText({ labelText: 'Revenue' })).toBe('Revenue'); - }); - - it('should join array elements with comma when labelText is an array', () => { - expect(getAxisLabelText({ labelText: ['Line 1', 'Line 2'] })).toBe( - 'Line 1,Line 2', - ); - }); - }); - describe('getDatasetType', () => { it('should return the dataset type when explicitly set', () => { const chart = createMockChart('bar'); @@ -293,6 +273,271 @@ describe('chart-helpers', () => { 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 diff --git a/libs/components/charts/src/lib/modules/shared/chart-helpers.ts b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts index 890f5892e8..12402bebca 100644 --- a/libs/components/charts/src/lib/modules/shared/chart-helpers.ts +++ b/libs/components/charts/src/lib/modules/shared/chart-helpers.ts @@ -1,4 +1,8 @@ +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'; @@ -7,20 +11,6 @@ import { SkyCategory } from './types/category'; import { SkyChartDataPoint } from './types/chart-data-point'; import { SkyChartSeries } from './types/chart-series'; -/** - * Returns the label string from an axis config's `labelText`, or `undefined` when no label is set. - * @internal - */ -export function getAxisLabelText( - config: Readonly | undefined, -): string | undefined { - const labelText = config?.labelText; - if (!labelText) { - return undefined; - } - return Array.isArray(labelText) ? labelText.join(',') : labelText; -} - /** * 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. @@ -148,3 +138,74 @@ export function getLegendItems(context: { 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(' '))); +} From 3e4850fc146de3ed6b354b94a3f98439707fee94 Mon Sep 17 00:00:00 2001 From: Thomas Ortiz Date: Mon, 20 Apr 2026 23:30:05 -0400 Subject: [PATCH 34/34] refactor: do not expose internal IDs on series and data point components --- .../chart-bar-series-data-point.component.ts | 13 +++++---- .../chart-bar/chart-bar-series.component.ts | 16 +++++++---- .../lib/modules/chart-bar/chart-bar-types.ts | 10 +++++++ ...chart-donut-series-data-point.component.ts | 13 +++++---- .../chart-donut-series.component.ts | 22 +++++++++------ .../modules/chart-donut/chart-donut-types.ts | 10 +++++++ .../chart-donut/chart-donut.component.ts | 7 ++++- .../chart-line-series-data-point.component.ts | 13 +++++---- .../chart-line/chart-line-series.component.ts | 28 +++++++++---------- .../modules/chart-line/chart-line-types.ts | 10 +++++++ 10 files changed, 96 insertions(+), 46 deletions(-) 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 index 0d7164ef94..c043ba5b47 100644 --- 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 @@ -11,8 +11,11 @@ import { import { SkyCategory } from '../shared/types/category'; import { SkyChartBarRegistry } from './chart-bar-registry.service'; -import { SkyChartBarSeriesComponent } from './chart-bar-series.component'; -import { SkyChartBarDatum, SkyChartBarPoint } from './chart-bar-types'; +import { + SKY_CHART_BAR_SERIES_ID, + SkyChartBarDatum, + SkyChartBarPoint, +} from './chart-bar-types'; let nextId = 0; @@ -26,7 +29,7 @@ let nextId = 0; }) export class SkyChartBarSeriesDataPointComponent implements OnDestroy { readonly #registry = inject(SkyChartBarRegistry); - readonly #series = inject(SkyChartBarSeriesComponent); + 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). @@ -60,11 +63,11 @@ export class SkyChartBarSeriesDataPointComponent implements OnDestroy { constructor() { effect(() => { const datapoint = this.#datapoint(); - this.#registry.upsertPoint(this.#series.id, datapoint); + this.#registry.upsertPoint(this.#seriesId, datapoint); }); } public ngOnDestroy(): void { - this.#registry.removePoint(this.#series.id, this.#id); + 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 index d79e2f6de1..a47e2ab3fe 100644 --- 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 @@ -11,7 +11,7 @@ import { import { SkyChartSeries } from '../shared/types/chart-series'; import { SkyChartBarRegistry } from './chart-bar-registry.service'; -import { SkyChartBarPoint } from './chart-bar-types'; +import { SKY_CHART_BAR_SERIES_ID, SkyChartBarPoint } from './chart-bar-types'; let nextId = 0; @@ -22,9 +22,16 @@ let nextId = 0; 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. @@ -33,12 +40,9 @@ export class SkyChartBarSeriesComponent implements OnDestroy { /** * A unique ID for this series component instance. - * @internal */ - public readonly id = nextId++; - readonly #series = computed>(() => ({ - id: this.id, + id: this.#id, labelText: this.labelText(), data: [], // Data will be dynamically set from children datapoints })); @@ -51,6 +55,6 @@ export class SkyChartBarSeriesComponent implements OnDestroy { } public ngOnDestroy(): void { - this.#registry.removeSeries(this.id); + 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 index 7710724483..c672c1bc61 100644 --- 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 @@ -1,3 +1,5 @@ +import { InjectionToken } from '@angular/core'; + import { SkyChartDataPoint } from '../shared/types/chart-data-point'; /** @@ -10,6 +12,14 @@ export type SkyChartBarOrientation = 'vertical' | 'horizontal'; */ 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 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 index ac1820edf5..71bff5fb11 100644 --- 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 @@ -11,8 +11,11 @@ import { import { SkyCategory } from '../shared/types/category'; import { SkyChartDonutRegistry } from './chart-donut-registry.service'; -import { SkyChartDonutSeriesComponent } from './chart-donut-series.component'; -import { SkyChartDonutDatum, SkyChartDonutSlice } from './chart-donut-types'; +import { + SKY_CHART_DONUT_SERIES_ID, + SkyChartDonutDatum, + SkyChartDonutSlice, +} from './chart-donut-types'; let nextId = 0; @@ -26,7 +29,7 @@ let nextId = 0; }) export class SkyChartDonutSeriesDataPointComponent implements OnDestroy { readonly #registry = inject(SkyChartDonutRegistry); - readonly #series = inject(SkyChartDonutSeriesComponent); + 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). @@ -60,11 +63,11 @@ export class SkyChartDonutSeriesDataPointComponent implements OnDestroy { constructor() { effect(() => { const datapoint = this.#datapoint(); - this.#registry.upsertPoint(this.#series.id, datapoint); + this.#registry.upsertPoint(this.#seriesId, datapoint); }); } public ngOnDestroy(): void { - this.#registry.removePoint(this.#series.id, this.#id); + 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 index 0573e6b08c..3f712b2a77 100644 --- 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 @@ -11,7 +11,10 @@ import { import { SkyChartSeries } from '../shared/types/chart-series'; import { SkyChartDonutRegistry } from './chart-donut-registry.service'; -import { SkyChartDonutSlice } from './chart-donut-types'; +import { + SKY_CHART_DONUT_SERIES_ID, + SkyChartDonutSlice, +} from './chart-donut-types'; let nextId = 0; @@ -22,23 +25,24 @@ let nextId = 0; 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(); - /** - * A unique ID for this series component instance. - * @internal - */ - public readonly id = nextId++; - readonly #series = computed>(() => ({ - id: this.id, + id: this.#id, labelText: this.labelText(), data: [], // Data will be dynamically set from children datapoints })); @@ -51,6 +55,6 @@ export class SkyChartDonutSeriesComponent implements OnDestroy { } public ngOnDestroy(): void { - this.#registry.removeSeries(this.id); + 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 index e4fbbe99db..86923ed2bb 100644 --- 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 @@ -1,3 +1,5 @@ +import { InjectionToken } from '@angular/core'; + import { SkyChartDataPoint } from '../shared/types/chart-data-point'; /** @@ -5,6 +7,14 @@ import { SkyChartDataPoint } from '../shared/types/chart-data-point'; */ 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 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 index 7317dd3930..7f31b3e5e1 100644 --- 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 @@ -181,7 +181,7 @@ export class SkyChartDonutComponent { #parseOptions(context: { dataPointsClickEnabled: boolean; series: SkyChartSeries[]; - }): SkyChartDonutOptions { + }): SkyChartDonutOptions | undefined { const { dataPointsClickEnabled, series } = context; // Donut charts only supports a single series @@ -189,6 +189,11 @@ export class SkyChartDonutComponent { 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, 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 index c2eac99125..0262f3be77 100644 --- 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 @@ -11,8 +11,11 @@ import { import { SkyCategory } from '../shared/types/category'; import { SkyChartLineRegistry } from './chart-line-registry.service'; -import { SkyChartLineSeriesComponent } from './chart-line-series.component'; -import { SkyChartLineDatum, SkyChartLinePoint } from './chart-line-types'; +import { + SKY_CHART_LINE_SERIES_ID, + SkyChartLineDatum, + SkyChartLinePoint, +} from './chart-line-types'; let nextId = 0; @@ -26,7 +29,7 @@ let nextId = 0; }) export class SkyChartLineSeriesDataPointComponent implements OnDestroy { readonly #registry = inject(SkyChartLineRegistry); - readonly #series = inject(SkyChartLineSeriesComponent); + 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). @@ -60,11 +63,11 @@ export class SkyChartLineSeriesDataPointComponent implements OnDestroy { constructor() { effect(() => { const datapoint = this.#datapoint(); - this.#registry.upsertPoint(this.#series.id, datapoint); + this.#registry.upsertPoint(this.#seriesId, datapoint); }); } public ngOnDestroy(): void { - this.#registry.removePoint(this.#series.id, this.#id); + 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 index 4cce047691..0ab06274b8 100644 --- 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 @@ -3,7 +3,6 @@ import { Component, OnDestroy, computed, - contentChildren, effect, inject, input, @@ -12,8 +11,10 @@ import { import { SkyChartSeries } from '../shared/types/chart-series'; import { SkyChartLineRegistry } from './chart-line-registry.service'; -import { SkyChartLineSeriesDataPointComponent } from './chart-line-series-data-point.component'; -import { SkyChartLinePoint } from './chart-line-types'; +import { + SKY_CHART_LINE_SERIES_ID, + SkyChartLinePoint, +} from './chart-line-types'; let nextId = 0; @@ -24,30 +25,27 @@ let nextId = 0; 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(); - /** - * The data points that belong to this series. - */ - protected readonly datapoints = contentChildren( - SkyChartLineSeriesDataPointComponent, - ); - /** * A unique ID for this series component instance. - * @internal */ - public readonly id = nextId++; - readonly #series = computed>(() => ({ - id: this.id, + id: this.#id, labelText: this.labelText(), data: [], // Data will be dynamically set from children datapoints })); @@ -60,6 +58,6 @@ export class SkyChartLineSeriesComponent implements OnDestroy { } public ngOnDestroy(): void { - this.#registry.removeSeries(this.id); + 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 index 07f98d8555..282b8ddcfe 100644 --- 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 @@ -1,3 +1,5 @@ +import { InjectionToken } from '@angular/core'; + import { SkyChartDataPoint } from '../shared/types/chart-data-point'; /** @@ -5,6 +7,14 @@ import { SkyChartDataPoint } from '../shared/types/chart-data-point'; */ 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