diff --git a/goldens/material/charts/index.api.md b/goldens/material/charts/index.api.md new file mode 100644 index 000000000000..b5f8673acd16 --- /dev/null +++ b/goldens/material/charts/index.api.md @@ -0,0 +1,82 @@ +## API Report File for "@angular/material_charts" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as i0 from '@angular/core'; +import { AfterViewInit } from '@angular/core'; +import { OnChanges } from '@angular/core'; +import { OnDestroy } from '@angular/core'; +import { PipeTransform } from '@angular/core'; +import { SimpleChanges } from '@angular/core'; + +// @public +export class MatChart implements AfterViewInit, OnChanges, OnDestroy { + constructor(...args: unknown[]); + ariaLabel: string; + color: ThemePalette; + datasets: MatChartDataset[]; + height: number; + hideTooltip(): void; + label: string; + // (undocumented) + ngAfterViewInit(): void; + // (undocumented) + ngOnChanges(_changes: SimpleChanges): void; + // (undocumented) + ngOnDestroy(): void; + // (undocumented)\n static ngAcceptInputType_showLegend: unknown; + // (undocumented) + static ngAcceptInputType_showTooltip: unknown; + showLegend: boolean; + showTooltip: boolean; + showTooltipAt(event: MouseEvent, text: string): void; + type: MatChartType; + valueFor(data: MatChartDataset['data'], label: string): number; + width: number; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public (undocumented) +export interface MatChartDataPoint { + label: string; + value: number; +} + +// @public (undocumented) +export interface MatChartDataset { + color?: string; + data: MatChartDataPoint[]; + label: string; +} + +// @public (undocumented) +export class MatChartsModule { + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵinj: i0.ɵɵInjectorDeclaration; + // (undocumented) + static ɵmod: i0.ɵɵNgModuleDeclaration; +} + +// @public +export type MatChartType = 'line' | 'bar' | 'pie'; + +// @public (undocumented) +export class MatChartValuePipe implements PipeTransform { + // (undocumented) + transform(data: MatChartDataPoint[], label: string): number; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; + // (undocumented) + static ɵpipe: i0.ɵɵPipeDeclaration; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/src/material/BUILD.bazel b/src/material/BUILD.bazel index 4640784669a3..3f0fa88091dd 100644 --- a/src/material/BUILD.bazel +++ b/src/material/BUILD.bazel @@ -40,6 +40,7 @@ sass_library( "//src/material/button:theme", "//src/material/button-toggle:theme", "//src/material/card:theme", + "//src/material/charts:theme", "//src/material/checkbox:theme", "//src/material/chips:theme", "//src/material/core:core_sass", @@ -116,6 +117,7 @@ ng_package( "//src/material/button:theme", "//src/material/button-toggle:theme", "//src/material/card:theme", + "//src/material/charts:theme", "//src/material/checkbox:theme", "//src/material/chips:theme", "//src/material/core:core_sass", diff --git a/src/material/_index.scss b/src/material/_index.scss index 851e39654078..297b4472c698 100644 --- a/src/material/_index.scss +++ b/src/material/_index.scss @@ -143,3 +143,5 @@ tree-base, tree-overrides; @forward './timepicker/timepicker-theme' as timepicker-* show timepicker-theme, timepicker-color, timepicker-typography, timepicker-density, timepicker-base, timepicker-overrides; +@forward './charts/chart-theme' as chart-* show chart-theme, chart-color, chart-typography, + chart-density, chart-base, chart-overrides; diff --git a/src/material/charts/BUILD.bazel b/src/material/charts/BUILD.bazel new file mode 100644 index 000000000000..74eb6dc6d3a3 --- /dev/null +++ b/src/material/charts/BUILD.bazel @@ -0,0 +1,94 @@ +load( + "//tools:defaults.bzl", + "extract_tokens", + "markdown_to_html", + "ng_project", + "ng_web_test_suite", + "sass_library", +) + +package(default_visibility = ["//visibility:public"]) + +sass_library( + name = "m3", + srcs = ["_m3-chart.scss"], + deps = [ + "//src/material/core/style:sass_utils", + "//src/material/core/tokens:m3_utils", + ], +) + +sass_library( + name = "m2", + srcs = ["_m2-chart.scss"], + deps = [ + "//src/material/core/theming:_inspection", + ], +) + +sass_library( + name = "theme", + srcs = ["_chart-theme.scss"], + deps = [ + ":m2", + ":m3", + "//src/material/core/theming", + "//src/material/core/theming:_inspection", + "//src/material/core/theming:_validation", + "//src/material/core/tokens:token_utils", + "//src/material/core/typography", + ], +) + +ng_project( + name = "charts", + srcs = [ + "chart.ts", + "chart-types.ts", + "chart-value.pipe.ts", + "charts-module.ts", + "index.ts", + "public-api.ts", + ], + assets = ["chart.html"], + deps = [ + "//:node_modules/@angular/common", + "//:node_modules/@angular/core", + "//src/material/core", + ], +) + +ng_project( + name = "unit_test_sources", + testonly = True, + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":charts", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/material/core", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) + +markdown_to_html( + name = "overview", + srcs = [":charts.md"], +) + +extract_tokens( + name = "tokens", + srcs = [":theme"], +) + +filegroup( + name = "source-files", + srcs = glob(["**/*.ts"]), +) diff --git a/src/material/charts/_chart-theme.scss b/src/material/charts/_chart-theme.scss new file mode 100644 index 000000000000..3e66653b8163 --- /dev/null +++ b/src/material/charts/_chart-theme.scss @@ -0,0 +1,82 @@ +@use '../core/theming/inspection'; +@use '../core/tokens/token-utils'; +@use '../core/typography/typography'; +@use './m2-chart'; +@use './m3-chart'; +@use 'sass:map'; + +/// Outputs base theme styles for the mat-chart. +/// @param {Map} $theme The theme to generate base styles for. +@mixin base($theme) { + $tokens: map.get(m2-chart.get-tokens($theme), base); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-chart.get-tokens($theme), base); + } + + @include token-utils.values($tokens); +} + +/// Outputs color theme styles for the mat-chart. +/// @param {Map} $theme The theme to generate color styles for. +/// @param {String} $color-variant The color variant to use. +@mixin color($theme, $color-variant: null) { + $tokens: map.get(m2-chart.get-tokens($theme), color); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-chart.get-tokens($theme, $color-variant), color); + } + + @include token-utils.values($tokens); +} + +/// Outputs typography theme styles for the mat-chart. +/// @param {Map} $theme The theme to generate typography styles for. +@mixin typography($theme) { + $tokens: map.get(m2-chart.get-tokens($theme), typography); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-chart.get-tokens($theme), typography); + } + + @include token-utils.values($tokens); +} + +/// Outputs density theme styles for the mat-chart. +/// @param {Map} $theme The theme to generate density styles for. +@mixin density($theme) { + $tokens: map.get(m2-chart.get-tokens($theme), density); + @if inspection.get-theme-version($theme) == 1 { + $tokens: map.get(m3-chart.get-tokens($theme), density); + } + + @include token-utils.values($tokens); +} + +/// Outputs the CSS variable values for the given tokens. +/// @param {Map} $tokens The token values to emit. +@mixin overrides($tokens: ()) { + @each $token, $value in $tokens { + --mat-chart-#{$token}: #{$value}; + } +} + +/// Outputs all theme styles for the mat-chart. +/// @param {Map} $theme The theme to generate styles for. +/// @param {String} $color-variant The color variant to use. +@mixin theme($theme, $color-variant: null) { + @if inspection.get-theme-version($theme) == 1 { + @include base($theme); + @include color($theme, $color-variant); + @include density($theme); + @include typography($theme); + } @else { + @include base($theme); + @if inspection.theme-has($theme, color) { + @include color($theme); + } + @if inspection.theme-has($theme, density) { + @include density($theme); + } + @if inspection.theme-has($theme, typography) { + @include typography($theme); + } + } +} diff --git a/src/material/charts/_m2-chart.scss b/src/material/charts/_m2-chart.scss new file mode 100644 index 000000000000..72813c1e43d9 --- /dev/null +++ b/src/material/charts/_m2-chart.scss @@ -0,0 +1,29 @@ +@use 'sass:map'; +@use '../core/theming/inspection'; + +/// Generates custom tokens for the mat-chart (M2 theme). +@function get-tokens($theme) { + $foreground: inspection.get-theme-color($theme, foreground); + $background: inspection.get-theme-color($theme, background); + + @return ( + base: ( + chart-axis-color: map.get($foreground, divider), + chart-grid-color: map.get($foreground, divider), + ), + color: ( + chart-label-text-color: map.get($foreground, text), + chart-axis-label-color: map.get($foreground, secondary-text), + chart-tooltip-background: map.get($foreground, text), + chart-tooltip-text-color: map.get($background, card), + chart-legend-text-color: map.get($foreground, secondary-text), + ), + typography: ( + chart-label-text-size: 14px, + chart-label-text-weight: 500, + chart-axis-label-size: 11px, + chart-legend-text-size: 12px, + ), + density: (), + ); +} diff --git a/src/material/charts/_m3-chart.scss b/src/material/charts/_m3-chart.scss new file mode 100644 index 000000000000..12d7f859dda0 --- /dev/null +++ b/src/material/charts/_m3-chart.scss @@ -0,0 +1,34 @@ +@use 'sass:map'; +@use '../core/tokens/m3-utils'; +@use '../core/tokens/m3'; + +/// Generates custom tokens for the mat-chart. +@function get-tokens($theme: m3.$sys-theme, $color-variant: null) { + $system: m3-utils.get-system($theme); + @if $color-variant { + $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); + } + + $tokens: ( + base: ( + chart-axis-color: map.get($system, outline), + chart-grid-color: map.get($system, outline-variant), + ), + color: ( + chart-label-text-color: map.get($system, on-surface), + chart-axis-label-color: map.get($system, on-surface-variant), + chart-tooltip-background: map.get($system, inverse-surface), + chart-tooltip-text-color: map.get($system, inverse-on-surface), + chart-legend-text-color: map.get($system, on-surface-variant), + ), + typography: ( + chart-label-text-size: map.get($system, title-small-size), + chart-label-text-weight: map.get($system, title-small-weight), + chart-axis-label-size: map.get($system, label-small-size), + chart-legend-text-size: map.get($system, label-small-size), + ), + density: (), + ); + + @return $tokens; +} diff --git a/src/material/charts/chart-types.ts b/src/material/charts/chart-types.ts new file mode 100644 index 000000000000..65f922805f36 --- /dev/null +++ b/src/material/charts/chart-types.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** Supported chart types. */ +export type MatChartType = 'line' | 'bar' | 'pie'; + +/** A single data point in a chart dataset. */ +export interface MatChartDataPoint { + label: string; + value: number; +} + +/** A dataset to be rendered in the chart. */ +export interface MatChartDataset { + label: string; + data: MatChartDataPoint[]; + color?: string; +} diff --git a/src/material/charts/chart-value.pipe.ts b/src/material/charts/chart-value.pipe.ts new file mode 100644 index 000000000000..d370247478b7 --- /dev/null +++ b/src/material/charts/chart-value.pipe.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Pipe, PipeTransform} from '@angular/core'; +import {MatChartDataPoint} from './chart-types'; + +/** + * Looks up the numeric value for a given label inside a dataset's data array. + * Used in the chart template to avoid calling methods inside `@for` loops. + * + * @example + * {{ dataset.data | chartValue: 'Jan' }} // → 42 + */ +@Pipe({name: 'chartValue'}) +export class MatChartValuePipe implements PipeTransform { + transform(data: MatChartDataPoint[], label: string): number { + return data.find(pt => pt.label === label)?.value ?? 0; + } +} diff --git a/src/material/charts/chart.html b/src/material/charts/chart.html new file mode 100644 index 000000000000..24d011a9e0c3 --- /dev/null +++ b/src/material/charts/chart.html @@ -0,0 +1,180 @@ + + +@if (label) { +
{{ label }}
+} + + + + +@if (showLegend && _legendItems.length) { +
+ @for (item of _legendItems; track item.label) { +
+ + {{ item.label }} +
+ } +
+} diff --git a/src/material/charts/chart.scss b/src/material/charts/chart.scss new file mode 100644 index 000000000000..db130677facc --- /dev/null +++ b/src/material/charts/chart.scss @@ -0,0 +1,113 @@ +.mat-chart { + display: block; + position: relative; + width: 100%; + font-family: var(--mat-sys-body-medium-font, inherit); +} + +.mat-chart-label { + font-size: var(--mat-chart-label-text-size, 14px); + font-weight: var(--mat-chart-label-text-weight, 500); + color: var(--mat-chart-label-text-color, var(--mat-sys-on-surface, #1c1b1f)); + margin-bottom: 8px; + text-align: center; +} + +.mat-chart-svg { + display: block; + overflow: visible; + width: 100%; +} + +// ── Axes ────────────────────────────────────────────────────────────────── + +.mat-chart-axis-line { + stroke: var(--mat-chart-axis-color, var(--mat-sys-outline, #79747e)); + stroke-width: 1; +} + +.mat-chart-axis-label { + fill: var(--mat-chart-axis-label-color, var(--mat-sys-on-surface-variant, #49454f)); + font-size: var(--mat-chart-axis-label-size, 11px); +} + +.mat-chart-grid-line { + stroke: var(--mat-chart-grid-color, var(--mat-sys-outline-variant, #cac4d0)); + stroke-width: 1; + stroke-dasharray: 4 4; +} + +// ── Bar chart ───────────────────────────────────────────────────────────── + +.mat-chart-bar-rect { + transition: opacity 150ms ease; + + &:hover { + opacity: 0.8; + cursor: pointer; + } +} + +// ── Line chart ──────────────────────────────────────────────────────────── + +.mat-chart-line-path { + transition: opacity 150ms ease; +} + +.mat-chart-dot { + transition: r 150ms ease; + + &:hover { + r: 6; + cursor: pointer; + } +} + +// ── Pie chart ───────────────────────────────────────────────────────────── + +.mat-chart-pie-slice { + transition: opacity 150ms ease; + + &:hover { + opacity: 0.85; + cursor: pointer; + } +} + +// ── Tooltip ─────────────────────────────────────────────────────────────── + +.mat-chart-tooltip-bg { + fill: var(--mat-chart-tooltip-background, var(--mat-sys-inverse-surface, #313033)); +} + +.mat-chart-tooltip-text { + fill: var(--mat-chart-tooltip-text-color, var(--mat-sys-inverse-on-surface, #f4eff4)); + font-size: 12px; + dominant-baseline: middle; +} + +// ── Legend ──────────────────────────────────────────────────────────────── + +.mat-chart-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + justify-content: center; + margin-top: 12px; +} + +.mat-chart-legend-item { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--mat-chart-legend-text-size, 12px); + color: var(--mat-chart-legend-text-color, var(--mat-sys-on-surface-variant, #49454f)); +} + +.mat-chart-legend-swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; + flex-shrink: 0; +} diff --git a/src/material/charts/chart.spec.ts b/src/material/charts/chart.spec.ts new file mode 100644 index 000000000000..192fbb457cc7 --- /dev/null +++ b/src/material/charts/chart.spec.ts @@ -0,0 +1,314 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {MatChart} from './chart'; +import {MatChartsModule} from './charts-module'; +import {MatChartDataset} from './chart-types'; + +const DATASETS: MatChartDataset[] = [ + { + label: 'Series A', + data: [ + {label: 'Jan', value: 10}, + {label: 'Feb', value: 20}, + {label: 'Mar', value: 15}, + ], + }, + { + label: 'Series B', + data: [ + {label: 'Jan', value: 5}, + {label: 'Feb', value: 25}, + {label: 'Mar', value: 30}, + ], + }, +]; + +@Component({ + standalone: true, + template: ` + + + `, + imports: [MatChartsModule], +}) +class TestHostComponent { + type: MatChart['type'] = 'bar'; + datasets: MatChartDataset[] = DATASETS; + label = 'Test Chart'; + showLegend = true; + showTooltip = true; +} + +/** Helper: get the MatChart instance from the fixture. */ +function getChart(fixture: ComponentFixture): MatChart { + return fixture.debugElement.query(By.directive(MatChart)).componentInstance as MatChart; +} + +describe('MatChart', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + + // JSDOM has no layout engine so getBoundingClientRect() returns 0. + // Manually set _svgWidth so chart content renders in all tests. + const chart = getChart(fixture); + chart._svgWidth = 400; + chart._svgHeight = 300; + fixture.detectChanges(); + }); + + // ── Creation ────────────────────────────────────────────────────────────── + + it('should create the component', () => { + expect(fixture.debugElement.query(By.directive(MatChart))).toBeTruthy(); + }); + + // ── Host classes ────────────────────────────────────────────────────────── + + it('should apply mat-chart class to host', () => { + const el: HTMLElement = fixture.nativeElement.querySelector('mat-chart'); + expect(el.classList).toContain('mat-chart'); + }); + + it('should apply mat-chart-bar class for bar type', () => { + expect(fixture.nativeElement.querySelector('mat-chart').classList).toContain('mat-chart-bar'); + }); + + it('should apply mat-chart-line class for line type', () => { + host.type = 'line'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('mat-chart').classList).toContain('mat-chart-line'); + }); + + it('should apply mat-chart-pie class for pie type', () => { + host.type = 'pie'; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('mat-chart').classList).toContain('mat-chart-pie'); + }); + + // ── Label ───────────────────────────────────────────────────────────────── + + it('should render the label', () => { + const el = fixture.nativeElement.querySelector('.mat-chart-label'); + expect(el?.textContent?.trim()).toBe('Test Chart'); + }); + + it('should not render label element when label is empty', () => { + host.label = ''; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.mat-chart-label')).toBeNull(); + }); + + // ── SVG ─────────────────────────────────────────────────────────────────── + + it('should render an SVG element', () => { + expect(fixture.nativeElement.querySelector('svg.mat-chart-svg')).toBeTruthy(); + }); + + // ── Accessibility ───────────────────────────────────────────────────────── + + it('should have role="img" on the host element', () => { + const el: HTMLElement = fixture.nativeElement.querySelector('mat-chart'); + expect(el.getAttribute('role')).toBe('img'); + }); + + it('should set aria-label from label input', () => { + const el: HTMLElement = fixture.nativeElement.querySelector('mat-chart'); + expect(el.getAttribute('aria-label')).toBe('Test Chart'); + }); + + it('should prefer aria-label input over label for aria-label attribute', () => { + const chart = getChart(fixture); + chart.ariaLabel = 'Custom ARIA'; + fixture.detectChanges(); + const el: HTMLElement = fixture.nativeElement.querySelector('mat-chart'); + expect(el.getAttribute('aria-label')).toBe('Custom ARIA'); + }); + + // ── Legend ──────────────────────────────────────────────────────────────── + + it('should render the legend when showLegend is true', () => { + expect(fixture.nativeElement.querySelector('.mat-chart-legend')).toBeTruthy(); + }); + + it('should not render the legend when showLegend is false', () => { + host.showLegend = false; + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.mat-chart-legend')).toBeNull(); + }); + + it('should render correct number of legend items', () => { + const items = fixture.nativeElement.querySelectorAll('.mat-chart-legend-item'); + expect(items.length).toBe(DATASETS.length); + }); + + // ── Computed data helpers ───────────────────────────────────────────────── + + it('should compute _allLabels from datasets', () => { + expect(getChart(fixture)._allLabels).toEqual(['Jan', 'Feb', 'Mar']); + }); + + it('should compute _maxValue from datasets', () => { + expect(getChart(fixture)._maxValue).toBe(30); + }); + + it('should compute _totalValue across all datasets', () => { + // 10+20+15 + 5+25+30 = 105 + expect(getChart(fixture)._totalValue).toBe(105); + }); + + it('should return 1 for _maxValue when datasets are empty', () => { + host.datasets = []; + fixture.detectChanges(); + expect(getChart(fixture)._maxValue).toBe(1); + }); + + it('should return 1 for _totalValue when datasets are empty', () => { + host.datasets = []; + fixture.detectChanges(); + expect(getChart(fixture)._totalValue).toBe(1); + }); + + // ── valueFor ───────────────────────────────────────────────────────────── + + it('should return correct value from valueFor()', () => { + const chart = getChart(fixture); + expect(chart.valueFor(DATASETS[0].data, 'Feb')).toBe(20); + }); + + it('should return 0 from valueFor() for a missing label', () => { + const chart = getChart(fixture); + expect(chart.valueFor(DATASETS[0].data, 'Missing')).toBe(0); + }); + + // ── Line chart ──────────────────────────────────────────────────────────── + + it('should generate a valid SVG linePath starting with M', () => { + const chart = getChart(fixture); + const path = chart.linePath(DATASETS[0]); + expect(path).toMatch(/^M/); + expect(path).toContain('L'); + }); + + it('should return empty string for linePath with no data', () => { + const chart = getChart(fixture); + expect(chart.linePath({label: 'Empty', data: []})).toBe(''); + }); + + // ── Pie chart ───────────────────────────────────────────────────────────── + + it('should generate pie slices equal to total data points across all datasets', () => { + host.type = 'pie'; + fixture.detectChanges(); + // 2 datasets × 3 points = 6 slices + expect(getChart(fixture)._pieSlices.length).toBe(6); + }); + + it('each pie slice path should start with M', () => { + host.type = 'pie'; + fixture.detectChanges(); + for (const slice of getChart(fixture)._pieSlices) { + expect(slice.path).toMatch(/^M/); + } + }); + + // ── Y-axis ticks ────────────────────────────────────────────────────────── + + it('should generate 6 y-axis ticks (0 through 5)', () => { + expect(getChart(fixture)._yTicks.length).toBe(6); + }); + + it('first y-tick label should be "0"', () => { + expect(getChart(fixture)._yTicks[0].label).toBe('0'); + }); + + it('last y-tick label should equal _maxValue', () => { + const chart = getChart(fixture); + expect(getChart(fixture)._yTicks[5].label).toBe(String(chart._maxValue)); + }); + + // ── Bar helpers ─────────────────────────────────────────────────────────── + + it('barWidth() should return a positive number', () => { + expect(getChart(fixture).barWidth()).toBeGreaterThan(0); + }); + + it('barX() should return 0 for the first bar in a group', () => { + expect(getChart(fixture).barX(0)).toBe(0); + }); + + // ── Default colors ──────────────────────────────────────────────────────── + + it('_defaultColor should cycle through the palette', () => { + const chart = getChart(fixture); + const c0 = chart._defaultColor(0); + const c8 = chart._defaultColor(8); // palette length is 8, so index 8 === index 0 + expect(c0).toBe(c8); + }); + + // ── Tooltip ─────────────────────────────────────────────────────────────── + + it('hideTooltip() should set _tooltip to null', () => { + const chart = getChart(fixture); + chart._tooltip = {x: 10, y: 10, text: 'test'}; + chart.hideTooltip(); + expect(chart._tooltip).toBeNull(); + }); + + it('showTooltipAt() should not set _tooltip when showTooltip is false', () => { + host.showTooltip = false; + fixture.detectChanges(); + const chart = getChart(fixture); + const fakeEvent = { + currentTarget: {closest: () => ({getBoundingClientRect: () => ({left: 0, top: 0})})}, + clientX: 50, + clientY: 50, + } as unknown as MouseEvent; + chart.showTooltipAt(fakeEvent, 'hello'); + expect(chart._tooltip).toBeNull(); + }); + + // ── Edge cases ──────────────────────────────────────────────────────────── + + it('should handle empty datasets without throwing', () => { + host.datasets = []; + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should handle a single data point without throwing', () => { + host.datasets = [{label: 'Only', data: [{label: 'A', value: 5}]}]; + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should update when datasets input changes', () => { + const chart = getChart(fixture); + const before = chart._maxValue; + host.datasets = [{label: 'X', data: [{label: 'A', value: 999}]}]; + fixture.detectChanges(); + expect(getChart(fixture)._maxValue).not.toBe(before); + expect(getChart(fixture)._maxValue).toBe(999); + }); +}); diff --git a/src/material/charts/chart.ts b/src/material/charts/chart.ts new file mode 100644 index 000000000000..3a93b4c76db2 --- /dev/null +++ b/src/material/charts/chart.ts @@ -0,0 +1,349 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnChanges, + OnDestroy, + SimpleChanges, + ViewChild, + ViewEncapsulation, + booleanAttribute, + inject, + PLATFORM_ID, +} from '@angular/core'; +import {isPlatformBrowser} from '@angular/common'; +import {ThemePalette} from '../core'; +import {MatChartDataset, MatChartType} from './chart-types'; + +/** + * Material-themed chart component supporting line, bar, and pie chart types. + * Renders via SVG for full accessibility and zero external dependencies. + */ +@Component({ + selector: 'mat-chart', + exportAs: 'matChart', + templateUrl: 'chart.html', + host: { + 'class': 'mat-chart', + '[class.mat-chart-line]': 'type === "line"', + '[class.mat-chart-bar]': 'type === "bar"', + '[class.mat-chart-pie]': 'type === "pie"', + 'role': 'img', + '[attr.aria-label]': 'ariaLabel || label', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class MatChart implements AfterViewInit, OnChanges, OnDestroy { + @ViewChild('svgEl', {static: true}) private _svgEl!: ElementRef; + + private _platformId = inject(PLATFORM_ID); + private _elementRef = inject(ElementRef); + + /** The type of chart to render. */ + @Input() type: MatChartType = 'bar'; + + /** Datasets to render. Each dataset maps to a series. */ + @Input() datasets: MatChartDataset[] = []; + + /** Accessible label for the chart. Falls back to `label`. */ + @Input('aria-label') ariaLabel: string = ''; + + /** Human-readable title shown above the chart. */ + @Input() label: string = ''; + + /** + * Theme color of the chart. This API is supported in M2 themes only. + * Has no effect in M3 themes. + */ + @Input() color: ThemePalette = 'primary'; + + /** Whether to show the tooltip on hover. */ + @Input({transform: booleanAttribute}) showTooltip: boolean = true; + + /** Whether to show the legend. */ + @Input({transform: booleanAttribute}) showLegend: boolean = true; + + /** Width of the chart in pixels. Defaults to container width. */ + @Input() width: number = 0; + + /** Height of the chart in pixels. */ + @Input() height: number = 300; + + // Internal computed state exposed to the template. + _svgWidth = 0; + _svgHeight = 0; + _isBrowser: boolean; + + // Padding around the plot area. + private readonly _padding = {top: 20, right: 20, bottom: 40, left: 50}; + + constructor() { + this._isBrowser = isPlatformBrowser(this._platformId); + } + + ngAfterViewInit(): void { + if (this._isBrowser) { + this._measure(); + } + } + + ngOnChanges(_changes: SimpleChanges): void { + if (this._isBrowser && this._svgEl) { + this._measure(); + } + } + + ngOnDestroy(): void {} + + /** Re-measures the host element and triggers a re-render. */ + private _measure(): void { + const hostWidth = + this.width || this._elementRef.nativeElement.getBoundingClientRect().width || 400; + this._svgWidth = hostWidth; + this._svgHeight = this.height || 300; + } + + // ─── Plot-area helpers ──────────────────────────────────────────────────── + + get _plotWidth(): number { + return this._svgWidth - this._padding.left - this._padding.right; + } + + get _plotHeight(): number { + return this._svgHeight - this._padding.top - this._padding.bottom; + } + + get _plotTransform(): string { + return `translate(${this._padding.left},${this._padding.top})`; + } + + // ─── Shared data helpers ────────────────────────────────────────────────── + + /** All unique labels across every dataset (used as x-axis categories). */ + get _allLabels(): string[] { + const seen = new Set(); + for (const ds of this.datasets) { + for (const pt of ds.data) { + seen.add(pt.label); + } + } + return Array.from(seen); + } + + /** Maximum value across all datasets (used to scale y-axis). */ + get _maxValue(): number { + let max = 0; + for (const ds of this.datasets) { + for (const pt of ds.data) { + if (pt.value > max) max = pt.value; + } + } + return max || 1; + } + + /** Total value across all datasets (used for pie slices). */ + get _totalValue(): number { + return ( + this.datasets.reduce((sum, ds) => sum + ds.data.reduce((s, pt) => s + pt.value, 0), 0) || 1 + ); + } + + // ─── Bar chart helpers ──────────────────────────────────────────────────── + + get _barGroups(): { + label: string; + bars: {color: string; height: number; y: number; label: string}[]; + }[] { + const labels = this._allLabels; + const groupWidth = this._plotWidth / (labels.length || 1); + return labels.map(label => ({ + label, + bars: this.datasets.map((ds, i) => { + const pt = ds.data.find(d => d.label === label); + const val = pt?.value ?? 0; + const h = (val / this._maxValue) * this._plotHeight; + return { + color: ds.color || this._defaultColor(i), + height: h, + y: this._plotHeight - h, + label: ds.label, + }; + }), + })); + } + + barGroupTransform(groupIndex: number): string { + const labels = this._allLabels; + const groupWidth = this._plotWidth / (labels.length || 1); + return `translate(${groupIndex * groupWidth}, 0)`; + } + + barX(barIndex: number): number { + const barCount = this.datasets.length || 1; + const groupWidth = this._plotWidth / (this._allLabels.length || 1); + const barWidth = groupWidth / barCount; + return barIndex * barWidth; + } + + barWidth(): number { + const barCount = this.datasets.length || 1; + const groupWidth = this._plotWidth / (this._allLabels.length || 1); + return Math.max(1, groupWidth / barCount - 2); + } + + // ─── Line chart helpers ─────────────────────────────────────────────────── + + linePath(ds: MatChartDataset): string { + const labels = this._allLabels; + const step = this._plotWidth / Math.max(labels.length - 1, 1); + const points = labels.map((lbl, i) => { + const pt = ds.data.find(d => d.label === lbl); + const val = pt?.value ?? 0; + const x = i * step; + const y = this._plotHeight - (val / this._maxValue) * this._plotHeight; + return `${x},${y}`; + }); + return points.length ? `M${points.join('L')}` : ''; + } + + dotCx(labelIndex: number): number { + const step = this._plotWidth / Math.max(this._allLabels.length - 1, 1); + return labelIndex * step; + } + + dotCy(ds: MatChartDataset, label: string): number { + const pt = ds.data.find(d => d.label === label); + const val = pt?.value ?? 0; + return this._plotHeight - (val / this._maxValue) * this._plotHeight; + } + + // ─── Pie chart helpers ──────────────────────────────────────────────────── + + get _pieSlices(): {path: string; color: string; label: string; value: number}[] { + const cx = this._svgWidth / 2; + const cy = this._svgHeight / 2; + const r = Math.min(cx, cy) - 30; + let startAngle = -Math.PI / 2; + const total = this._totalValue; + + return this.datasets.flatMap((ds, di) => + ds.data.map((pt, pi) => { + const slice = (pt.value / total) * 2 * Math.PI; + const endAngle = startAngle + slice; + const path = this._describeArc(cx, cy, r, startAngle, endAngle); + startAngle = endAngle; + return { + path, + color: ds.color || this._defaultColor(di * ds.data.length + pi), + label: pt.label, + value: pt.value, + }; + }), + ); + } + + private _describeArc( + cx: number, + cy: number, + r: number, + startAngle: number, + endAngle: number, + ): string { + const x1 = cx + r * Math.cos(startAngle); + const y1 = cy + r * Math.sin(startAngle); + const x2 = cx + r * Math.cos(endAngle); + const y2 = cy + r * Math.sin(endAngle); + const largeArc = endAngle - startAngle > Math.PI ? 1 : 0; + return `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc},1 ${x2},${y2} Z`; + } + + // ─── Axis helpers ───────────────────────────────────────────────────────── + + get _yTicks(): {y: number; label: string}[] { + const tickCount = 5; + return Array.from({length: tickCount + 1}, (_, i) => { + const fraction = i / tickCount; + return { + y: this._plotHeight * (1 - fraction), + label: String(Math.round(this._maxValue * fraction)), + }; + }); + } + + xLabelX(index: number): number { + const labels = this._allLabels; + if (this.type === 'bar') { + const groupWidth = this._plotWidth / (labels.length || 1); + return index * groupWidth + groupWidth / 2; + } + const step = this._plotWidth / Math.max(labels.length - 1, 1); + return index * step; + } + + // ─── Legend helpers ─────────────────────────────────────────────────────── + + get _legendItems(): {color: string; label: string}[] { + return this.datasets.map((ds, i) => ({ + color: ds.color || this._defaultColor(i), + label: ds.label, + })); + } + + // ─── Tooltip state ──────────────────────────────────────────────────────── + + _tooltip: {x: number; y: number; text: string} | null = null; + + showTooltipAt(event: MouseEvent, text: string): void { + if (!this.showTooltip) return; + const rect = (event.currentTarget as SVGElement).closest('svg')!.getBoundingClientRect(); + this._tooltip = { + x: event.clientX - rect.left + 8, + y: event.clientY - rect.top - 28, + text, + }; + } + + hideTooltip(): void { + this._tooltip = null; + } + + // ─── Utilities ──────────────────────────────────────────────────────────── + + _defaultColor(index: number): string { + const palette = [ + 'var(--mat-sys-primary)', + 'var(--mat-sys-secondary)', + 'var(--mat-sys-tertiary)', + 'var(--mat-sys-error)', + '#4caf50', + '#ff9800', + '#9c27b0', + '#00bcd4', + ]; + return palette[index % palette.length]; + } + + _trackByLabel(_: number, item: {label: string}): string { + return item.label; + } + + _trackByIndex(index: number): number { + return index; + } + + /** Returns the value for a given label from a data array. Used in the template. */ + valueFor(data: MatChartDataset['data'], label: string): number { + return data.find(pt => pt.label === label)?.value ?? 0; + } +} diff --git a/src/material/charts/charts-module.ts b/src/material/charts/charts-module.ts new file mode 100644 index 000000000000..e653d7eea9b0 --- /dev/null +++ b/src/material/charts/charts-module.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {NgModule} from '@angular/core'; +import {MatChart} from './chart'; +import {MatChartValuePipe} from './chart-value.pipe'; + +@NgModule({ + imports: [MatChart, MatChartValuePipe], + exports: [MatChart, MatChartValuePipe], +}) +export class MatChartsModule {} diff --git a/src/material/charts/charts.md b/src/material/charts/charts.md new file mode 100644 index 000000000000..e2fd43f33e01 --- /dev/null +++ b/src/material/charts/charts.md @@ -0,0 +1,39 @@ +# MatChart + +`MatChart` is a Material-themed SVG chart component supporting line, bar, and pie chart types. + +## Usage + +```html + + +``` + +```typescript +import { MatChart, MatChartDataset } from '@angular/material/charts'; + +datasets: MatChartDataset[] = [ + { + label: 'Revenue', + data: [ + { label: 'Jan', value: 120 }, + { label: 'Feb', value: 180 }, + { label: 'Mar', value: 150 }, + ], + }, +]; +``` + +## Theming + +```scss +@use '@angular/material' as mat; + +@include mat.chart-theme($theme); +``` diff --git a/src/material/charts/index.ts b/src/material/charts/index.ts new file mode 100644 index 000000000000..52b3c7a5156f --- /dev/null +++ b/src/material/charts/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './public-api'; diff --git a/src/material/charts/public-api.ts b/src/material/charts/public-api.ts new file mode 100644 index 000000000000..a4f59994cfe7 --- /dev/null +++ b/src/material/charts/public-api.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './charts-module'; +export {MatChart} from './chart'; +export {MatChartValuePipe} from './chart-value.pipe'; +export {MatChartType, MatChartDataPoint, MatChartDataset} from './chart-types'; diff --git a/src/material/config.bzl b/src/material/config.bzl index 7b63b4a3253b..3baa6de066f6 100644 --- a/src/material/config.bzl +++ b/src/material/config.bzl @@ -72,6 +72,7 @@ entryPoints = [ "tooltip/testing", "tree", "tree/testing", + "charts", ] # List of all non-testing entry-points of the Angular Material package.