diff --git a/packages/angular/src/charts.module.ts b/packages/angular/src/charts.module.ts index 79801e40a5..c364154dc9 100644 --- a/packages/angular/src/charts.module.ts +++ b/packages/angular/src/charts.module.ts @@ -25,6 +25,7 @@ import { TreeChartComponent } from './tree-chart.component'; import { TreemapChartComponent } from './treemap-chart.component'; import { CirclePackChartComponent } from './circle-pack-chart.component'; import { WordCloudChartComponent } from './wordcloud-chart.component'; +import { HeatmapChartComponent } from './heatmap-chart.component'; @NgModule({ imports: [CommonModule], @@ -41,6 +42,7 @@ import { WordCloudChartComponent } from './wordcloud-chart.component'; BulletChartComponent, DonutChartComponent, GaugeChartComponent, + HeatmapChartComponent, HistogramChartComponent, LineChartComponent, LollipopChartComponent, @@ -67,6 +69,7 @@ import { WordCloudChartComponent } from './wordcloud-chart.component'; BulletChartComponent, DonutChartComponent, GaugeChartComponent, + HeatmapChartComponent, HistogramChartComponent, LineChartComponent, LollipopChartComponent, diff --git a/packages/angular/src/heatmap-chart.component.ts b/packages/angular/src/heatmap-chart.component.ts new file mode 100644 index 0000000000..d279573f53 --- /dev/null +++ b/packages/angular/src/heatmap-chart.component.ts @@ -0,0 +1,34 @@ +import { + Component, + AfterViewInit +} from "@angular/core"; + +import { BaseChart } from "./base-chart.component"; + +import { HeatmapChart } from "@carbon/charts"; + +/** + * Wrapper around `Heatmap` in carbon charts library + * + * Most functions just call their equivalent from the chart library. + */ +@Component({ + selector: "ibm-heatmap-chart", + template: `` +}) +export class HeatmapChartComponent extends BaseChart implements AfterViewInit { + /** + * Runs after view init to create a chart, attach it to `elementRef` and draw it. + */ + ngAfterViewInit() { + this.chart = new HeatmapChart( + this.elementRef.nativeElement, + { + data: this.data, + options: this.options + } + ); + + Object.assign(this, this.chart); + } +} diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index 1808eb7579..4e2fad006b 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -24,6 +24,7 @@ export * from './treemap-chart.component'; export * from './circle-pack-chart.component'; export * from './wordcloud-chart.component'; export * from './alluvial-chart.component'; +export * from './heatmap-chart.component'; // Diagrams export * from './diagrams/card-node/card-node.module'; diff --git a/packages/core/demo/data/CHART_TYPES.ts b/packages/core/demo/data/CHART_TYPES.ts index bd9c95311f..038b000988 100644 --- a/packages/core/demo/data/CHART_TYPES.ts +++ b/packages/core/demo/data/CHART_TYPES.ts @@ -49,6 +49,11 @@ export default { angular: 'ibm-grouped-bar-chart', vue: 'ccv-grouped-bar-chart', }, + HeatmapChart: { + vanilla: 'HeatmapChart', + angular: 'ibm-heatmap-chart', + vue: 'ccv-heatmap-chart', + }, HistogramChart: { vanilla: 'HistogramChart', angular: 'ibm-histogram-chart', diff --git a/packages/core/demo/data/heatmap.ts b/packages/core/demo/data/heatmap.ts new file mode 100644 index 0000000000..a3f056fec5 --- /dev/null +++ b/packages/core/demo/data/heatmap.ts @@ -0,0 +1,1139 @@ +export const heatmapData = [ + { + letter: 'A', + month: 'January', + value: 41, + }, + { + letter: 'B', + month: 'January', + value: 7, + }, + { + letter: 'C', + month: 'January', + value: 66, + }, + { + letter: 'D', + month: 'January', + value: 85, + }, + { + letter: 'E', + month: 'January', + value: 70, + }, + { + letter: 'F', + month: 'January', + value: 98, + }, + { + letter: 'G', + month: 'January', + value: 90, + }, + { + letter: 'H', + month: 'January', + value: 66, + }, + { + letter: 'I', + month: 'January', + value: 0, + }, + { + letter: 'J', + month: 'January', + value: 13, + }, + { + letter: 'A', + month: 'February', + value: 16, + }, + { + letter: 'B', + month: 'February', + value: 5, + }, + { + letter: 'C', + month: 'February', + value: 6, + }, + { + letter: 'D', + month: 'February', + value: 48, + }, + { + letter: 'E', + month: 'February', + value: 72, + }, + { + letter: 'F', + month: 'February', + value: 26, + }, + { + letter: 'G', + month: 'February', + value: 70, + }, + { + letter: 'H', + month: 'February', + value: 99, + }, + { + letter: 'I', + month: 'February', + value: 79, + }, + { + letter: 'J', + month: 'February', + value: 83, + }, + { + letter: 'A', + month: 'March', + value: 62, + }, + { + letter: 'B', + month: 'March', + value: 57, + }, + { + letter: 'C', + month: 'March', + value: 90, + }, + { + letter: 'D', + month: 'March', + value: 68, + }, + { + letter: 'E', + month: 'March', + value: 84, + }, + { + letter: 'F', + month: 'March', + value: 21, + }, + { + letter: 'G', + month: 'March', + value: 54, + }, + { + letter: 'H', + month: 'March', + value: 25, + }, + { + letter: 'I', + month: 'March', + value: 42, + }, + { + letter: 'J', + month: 'March', + value: 62, + }, + { + letter: 'A', + month: 'April', + value: 15, + }, + { + letter: 'B', + month: 'April', + value: 52, + }, + { + letter: 'C', + month: 'April', + value: 15, + }, + { + letter: 'D', + month: 'April', + value: 22, + }, + { + letter: 'E', + month: 'April', + value: 59, + }, + { + letter: 'F', + month: 'April', + value: 36, + }, + { + letter: 'G', + month: 'April', + value: 5, + }, + { + letter: 'H', + month: 'April', + value: 18, + }, + { + letter: 'I', + month: 'April', + value: 42, + }, + { + letter: 'J', + month: 'April', + value: 72, + }, + { + letter: 'A', + month: 'May', + value: 30, + }, + { + letter: 'B', + month: 'May', + value: 39, + }, + { + letter: 'C', + month: 'May', + value: 69, + }, + { + letter: 'D', + month: 'May', + value: 73, + }, + { + letter: 'E', + month: 'May', + value: 2, + }, + { + letter: 'F', + month: 'May', + value: 15, + }, + { + letter: 'G', + month: 'May', + value: 86, + }, + { + letter: 'H', + month: 'May', + value: 23, + }, + { + letter: 'I', + month: 'May', + value: 65, + }, + { + letter: 'J', + month: 'May', + value: 0, + }, + { + letter: 'A', + month: 'June', + value: 51, + }, + { + letter: 'B', + month: 'June', + value: 30, + }, + { + letter: 'C', + month: 'June', + value: 7, + }, + { + letter: 'D', + month: 'June', + value: 74, + }, + { + letter: 'E', + month: 'June', + value: 44, + }, + { + letter: 'F', + month: 'June', + value: 62, + }, + { + letter: 'G', + month: 'June', + value: 65, + }, + { + letter: 'H', + month: 'June', + value: 35, + }, + { + letter: 'I', + month: 'June', + value: 95, + }, + { + letter: 'J', + month: 'June', + value: 59, + }, + { + letter: 'A', + month: 'July', + value: 89, + }, + { + letter: 'B', + month: 'July', + value: 50, + }, + { + letter: 'C', + month: 'July', + value: 35, + }, + { + letter: 'D', + month: 'July', + value: 45, + }, + { + letter: 'E', + month: 'July', + value: 93, + }, + { + letter: 'F', + month: 'July', + value: 19, + }, + { + letter: 'G', + month: 'July', + value: 52, + }, + { + letter: 'H', + month: 'July', + value: 81, + }, + { + letter: 'I', + month: 'July', + value: 72, + }, + { + letter: 'J', + month: 'July', + value: 99, + }, + { + letter: 'A', + month: 'August', + value: 54, + }, + { + letter: 'B', + month: 'August', + value: 41, + }, + { + letter: 'C', + month: 'August', + value: 75, + }, + { + letter: 'D', + month: 'August', + value: 10, + }, + { + letter: 'E', + month: 'August', + value: 0, + }, + { + letter: 'F', + month: 'August', + value: 93, + }, + { + letter: 'G', + month: 'August', + value: 3, + }, + { + letter: 'H', + month: 'August', + value: 80, + }, + { + letter: 'I', + month: 'August', + value: 88, + }, + { + letter: 'J', + month: 'August', + value: 27, + }, + { + letter: 'A', + month: 'September', + value: 81, + }, + { + letter: 'B', + month: 'September', + value: 36, + }, + { + letter: 'C', + month: 'September', + value: 77, + }, + { + letter: 'D', + month: 'September', + value: 1, + }, + { + letter: 'E', + month: 'September', + value: 45, + }, + { + letter: 'F', + month: 'September', + value: 23, + }, + { + letter: 'G', + month: 'September', + value: 1, + }, + { + letter: 'H', + month: 'September', + value: 13, + }, + { + letter: 'I', + month: 'September', + value: 61, + }, + { + letter: 'J', + month: 'September', + value: 87, + }, + { + letter: 'A', + month: 'October', + value: 5, + }, + { + letter: 'B', + month: 'October', + value: 29, + }, + { + letter: 'C', + month: 'October', + value: 49, + }, + { + letter: 'D', + month: 'October', + value: 81, + }, + { + letter: 'E', + month: 'October', + value: 5, + }, + { + letter: 'F', + month: 'October', + value: 6, + }, + { + letter: 'G', + month: 'October', + value: 3, + }, + { + letter: 'H', + month: 'October', + value: 72, + }, + { + letter: 'I', + month: 'October', + value: 27, + }, + { + letter: 'J', + month: 'October', + value: 99, + }, + { + letter: 'A', + month: 'November', + value: 25, + }, + { + letter: 'B', + month: 'November', + value: 11, + }, + { + letter: 'C', + month: 'November', + value: 54, + }, + { + letter: 'D', + month: 'November', + value: 90, + }, + { + letter: 'E', + month: 'November', + value: 21, + }, + { + letter: 'F', + month: 'November', + value: 5, + }, + { + letter: 'G', + month: 'November', + value: 41, + }, + { + letter: 'H', + month: 'November', + value: 4, + }, + { + letter: 'I', + month: 'November', + value: 31, + }, + { + letter: 'J', + month: 'November', + value: 22, + }, + { + letter: 'A', + month: 'December', + value: 99, + }, + { + letter: 'B', + month: 'December', + value: 54, + }, + { + letter: 'C', + month: 'December', + value: 85, + }, + { + letter: 'D', + month: 'December', + value: 39, + }, + { + letter: 'E', + month: 'December', + value: 45, + }, + { + letter: 'F', + month: 'December', + value: 24, + }, + { + letter: 'G', + month: 'December', + value: 87, + }, + { + letter: 'H', + month: 'December', + value: 69, + }, + { + letter: 'I', + month: 'December', + value: 59, + }, + { + letter: 'J', + month: 'December', + value: 44, + }, +]; + +export const heatmapOptions = { + title: 'Heatmap', + axes: { + bottom: { + title: 'Letters', + mapsTo: 'letter', + scaleType: 'labels', + }, + left: { + title: 'Months', + mapsTo: 'month', + scaleType: 'labels', + }, + }, + heatmap: { + colorLegend: { title: 'Legend title' }, + }, + experimental: true, +}; + +export const heatmapQuantizeLegendOption = { + title: 'Heatmap (Quantize legend)', + axes: { + bottom: { + title: 'Letters', + mapsTo: 'letter', + scaleType: 'labels', + }, + left: { + title: 'Months', + mapsTo: 'month', + scaleType: 'labels', + }, + }, + heatmap: { + colorLegend: { title: 'Legend title', type: 'quantize' }, + }, + experimental: true, +}; + +export const heatmapDomainOptions = { + title: 'Heatmap (Axis order option)', + axes: { + bottom: { + title: 'Letters', + mapsTo: 'letter', + scaleType: 'labels', + }, + left: { + title: 'Months', + mapsTo: 'month', + scaleType: 'labels', + domain: [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ], + }, + }, + heatmap: { + colorLegend: { title: 'Legend title' }, + }, + experimental: true, +}; + +export const heatmapMissingData = [ + { + letter: 'A', + month: 'January', + value: 41, + }, + { + letter: 'B', + month: 'January', + value: 7, + }, + { + letter: 'C', + month: 'January', + value: 66, + }, + { + letter: 'D', + month: 'January', + value: 85, + }, + { + letter: 'E', + month: 'January', + value: 70, + }, + { + letter: 'F', + month: 'January', + value: 98, + }, + { + letter: 'G', + month: 'January', + value: 90, + }, + { + letter: 'H', + month: 'January', + value: 66, + }, + { + letter: 'I', + month: 'January', + value: 0, + }, + { + letter: 'J', + month: 'January', + value: 13, + }, + { + letter: 'A', + month: 'February', + value: 16, + }, + { + letter: 'B', + month: 'February', + value: 5, + }, + { + letter: 'C', + month: 'February', + value: 6, + }, + { + letter: 'D', + month: 'February', + value: 48, + }, + { + letter: 'J', + month: 'February', + value: 83, + }, + { + letter: 'A', + month: 'March', + value: 62, + }, + { + letter: 'B', + month: 'March', + value: 57, + }, + { + letter: 'C', + month: 'March', + value: 90, + }, + { + letter: 'D', + month: 'March', + value: 68, + }, + { + letter: 'E', + month: 'March', + value: 84, + }, + { + letter: 'F', + month: 'March', + value: 21, + }, + { + letter: 'I', + month: 'March', + value: 42, + }, + { + letter: 'A', + month: 'April', + value: 15, + }, + { + letter: 'B', + month: 'April', + value: 52, + }, + { + letter: 'D', + month: 'April', + value: 22, + }, + { + letter: 'E', + month: 'April', + value: 59, + }, + { + letter: 'G', + month: 'April', + value: 5, + }, + { + letter: 'I', + month: 'April', + value: 42, + }, + { + letter: 'J', + month: 'April', + value: 72, + }, + { + letter: 'B', + month: 'May', + value: 39, + }, + { + letter: 'C', + month: 'May', + value: 69, + }, + { + letter: 'E', + month: 'May', + value: 2, + }, + { + letter: 'F', + month: 'May', + value: 15, + }, + { + letter: 'H', + month: 'May', + value: 23, + }, + { + letter: 'I', + month: 'May', + value: 65, + }, + { + letter: 'A', + month: 'June', + value: 51, + }, + { + letter: 'B', + month: 'June', + value: 30, + }, + { + letter: 'I', + month: 'June', + value: 95, + }, + { + letter: 'J', + month: 'June', + value: 59, + }, + { + letter: 'A', + month: 'July', + value: 89, + }, + { + letter: 'B', + month: 'July', + value: 50, + }, + { + letter: 'C', + month: 'July', + value: 35, + }, + { + letter: 'D', + month: 'July', + value: 45, + }, + { + letter: 'E', + month: 'July', + value: 93, + }, + { + letter: 'F', + month: 'July', + value: 19, + }, + { + letter: 'G', + month: 'July', + value: 52, + }, + { + letter: 'H', + month: 'July', + value: 81, + }, + { + letter: 'I', + month: 'July', + value: 72, + }, + { + letter: 'J', + month: 'July', + value: 99, + }, + { + letter: 'A', + month: 'August', + value: 54, + }, + { + letter: 'D', + month: 'August', + value: 10, + }, + { + letter: 'E', + month: 'August', + value: 0, + }, + { + letter: 'F', + month: 'August', + value: 93, + }, + { + letter: 'G', + month: 'August', + value: 3, + }, + { + letter: 'H', + month: 'August', + value: 80, + }, + { + letter: 'I', + month: 'August', + value: 88, + }, + { + letter: 'J', + month: 'August', + value: 27, + }, + { + letter: 'B', + month: 'September', + value: 36, + }, + { + letter: 'C', + month: 'September', + value: 77, + }, + { + letter: 'D', + month: 'September', + value: 1, + }, + { + letter: 'E', + month: 'September', + value: 45, + }, + { + letter: 'F', + month: 'September', + value: 23, + }, + { + letter: 'G', + month: 'September', + value: 1, + }, + { + letter: 'H', + month: 'September', + value: 13, + }, + { + letter: 'I', + month: 'September', + value: 61, + }, + { + letter: 'J', + month: 'September', + value: 87, + }, + { + letter: 'A', + month: 'October', + value: 5, + }, + { + letter: 'B', + month: 'October', + value: 29, + }, + { + letter: 'C', + month: 'October', + value: 49, + }, + { + letter: 'D', + month: 'October', + value: 81, + }, + { + letter: 'E', + month: 'October', + value: 5, + }, + { + letter: 'F', + month: 'October', + value: 6, + }, + { + letter: 'J', + month: 'October', + value: 99, + }, + { + letter: 'A', + month: 'November', + value: 25, + }, + { + letter: 'B', + month: 'November', + value: 11, + }, + { + letter: 'C', + month: 'November', + value: 54, + }, + { + letter: 'F', + month: 'November', + value: 5, + }, + { + letter: 'G', + month: 'November', + value: 41, + }, + { + letter: 'H', + month: 'November', + value: 4, + }, + { + letter: 'I', + month: 'November', + value: 31, + }, + { + letter: 'J', + month: 'November', + value: 22, + }, + { + letter: 'A', + month: 'December', + value: 99, + }, + { + letter: 'B', + month: 'December', + value: 54, + }, + { + letter: 'C', + month: 'December', + value: 85, + }, + { + letter: 'D', + month: 'December', + value: 39, + }, + { + letter: 'E', + month: 'December', + value: 45, + }, + { + letter: 'F', + month: 'December', + value: 24, + }, + { + letter: 'G', + month: 'December', + value: 87, + }, +]; + +export const heatmapMissingDataOptions = { + title: 'Heatmap (Missing data)', + axes: { + bottom: { + title: 'Letters', + mapsTo: 'letter', + scaleType: 'labels', + }, + left: { + title: 'Months', + mapsTo: 'month', + scaleType: 'labels', + }, + }, + heatmap: { + colorLegend: { title: 'Legend title' }, + }, + experimental: true, +}; diff --git a/packages/core/demo/data/index.ts b/packages/core/demo/data/index.ts index 6826265363..6b2402e7ef 100644 --- a/packages/core/demo/data/index.ts +++ b/packages/core/demo/data/index.ts @@ -24,6 +24,7 @@ import * as zoomBarDemos from './zoom-bar'; import * as highScaleDemos from './high-scale'; import * as alluvialDemos from './alluvial'; import * as highlightDemos from './hightlight'; +import * as heatmapDemos from './heatmap'; export * from './area'; export * from './bar'; @@ -49,6 +50,7 @@ export * from './wordcloud'; export * from './zoom-bar'; export * from './high-scale'; export * from './alluvial'; +export * from './heatmap'; import { createChartSandbox, @@ -1130,6 +1132,34 @@ const complexChartDemos = [ }, ], }, + { + title: 'Heatmap', + configs: { + excludeColorPaletteControl: true, + }, + demos: [ + { + options: heatmapDemos.heatmapOptions, + data: heatmapDemos.heatmapData, + chartType: chartTypes.HeatmapChart, + }, + { + options: heatmapDemos.heatmapQuantizeLegendOption, + data: heatmapDemos.heatmapData, + chartType: chartTypes.HeatmapChart, + }, + { + options: heatmapDemos.heatmapMissingDataOptions, + data: heatmapDemos.heatmapMissingData, + chartType: chartTypes.HeatmapChart, + }, + { + options: heatmapDemos.heatmapDomainOptions, + data: heatmapDemos.heatmapData, + chartType: chartTypes.HeatmapChart, + }, + ], + }, { title: 'Tree', configs: { diff --git a/packages/core/src/charts/heatmap.ts b/packages/core/src/charts/heatmap.ts new file mode 100644 index 0000000000..d3fe009b40 --- /dev/null +++ b/packages/core/src/charts/heatmap.ts @@ -0,0 +1,191 @@ +// Internal Imports +import { HeatmapModel } from '../model/heatmap'; +import { AxisChart } from '../axis-chart'; +import * as Configuration from '../configuration'; +import { Tools } from '../tools'; + +import { + HeatmapChartOptions, + LayoutDirection, + LayoutGrowth, + ChartConfig, + RenderTypes, + LayoutAlignItems, +} from '../interfaces/index'; + +import { + Heatmap, + TwoDimensionalAxes, + Modal, + LayoutComponent, + ColorScaleLegend, + Title, + AxisChartsTooltip, + Spacer, + Toolbar, + // the imports below are needed because of typescript bug (error TS4029) + Tooltip, +} from '../components'; + +export class HeatmapChart extends AxisChart { + model = new HeatmapModel(this.services); + + constructor( + holder: Element, + chartConfigs: ChartConfig + ) { + super(holder, chartConfigs); + + // Merge the default options for this chart + // With the user provided options + this.model.setOptions( + Tools.mergeDefaultChartOptions( + Configuration.options.heatmapChart, + chartConfigs.options + ) + ); + + // Initialize data, services, components etc. + this.init(holder, chartConfigs); + } + + // Custom getChartComponents - Implements getChartComponents + // Removes zoombar support and additional `features` that are not supported in heatmap + protected getAxisChartComponents( + graphFrameComponents: any[], + configs?: any + ) { + const options = this.model.getOptions(); + const toolbarEnabled = Tools.getProperty(options, 'toolbar', 'enabled'); + + this.services.cartesianScales.determineAxisDuality(); + this.services.cartesianScales.findDomainAndRangeAxes(); // need to do this before getMainXAxisPosition() + this.services.cartesianScales.determineOrientation(); + + const titleAvailable = !!this.model.getOptions().title; + const titleComponent = { + id: 'title', + components: [new Title(this.model, this.services)], + growth: LayoutGrowth.STRETCH, + }; + + const toolbarComponent = { + id: 'toolbar', + components: [new Toolbar(this.model, this.services)], + growth: LayoutGrowth.PREFERRED, + }; + + const headerComponent = { + id: 'header', + components: [ + new LayoutComponent( + this.model, + this.services, + [ + // always add title to keep layout correct + titleComponent, + ...(toolbarEnabled ? [toolbarComponent] : []), + ], + { + direction: LayoutDirection.ROW, + alignItems: LayoutAlignItems.CENTER, + } + ), + ], + growth: LayoutGrowth.PREFERRED, + }; + + const legendComponent = { + id: 'legend', + components: [new ColorScaleLegend(this.model, this.services)], + growth: LayoutGrowth.PREFERRED, + renderType: RenderTypes.SVG, + }; + + const graphFrameComponent = { + id: 'graph-frame', + components: graphFrameComponents, + growth: LayoutGrowth.STRETCH, + renderType: RenderTypes.SVG, + }; + + const isLegendEnabled = + Tools.getProperty(configs, 'legend', 'enabled') !== false && + this.model.getOptions().legend.enabled !== false; + + // Decide the position of the legend in reference to the chart + const fullFrameComponentDirection = LayoutDirection.COLUMN_REVERSE; + + const legendSpacerComponent = { + id: 'spacer', + components: [new Spacer(this.model, this.services, { size: 15 })], + growth: LayoutGrowth.PREFERRED, + }; + + const fullFrameComponent = { + id: 'full-frame', + components: [ + new LayoutComponent( + this.model, + this.services, + [ + ...(isLegendEnabled ? [legendComponent] : []), + ...(isLegendEnabled ? [legendSpacerComponent] : []), + graphFrameComponent, + ], + { + direction: fullFrameComponentDirection, + } + ), + ], + growth: LayoutGrowth.STRETCH, + }; + + const topLevelLayoutComponents = []; + // header component is required for either title or toolbar + if (titleAvailable || toolbarEnabled) { + topLevelLayoutComponents.push(headerComponent); + + const titleSpacerComponent = { + id: 'spacer', + components: [ + new Spacer( + this.model, + this.services, + toolbarEnabled ? { size: 15 } : undefined + ), + ], + growth: LayoutGrowth.PREFERRED, + }; + + topLevelLayoutComponents.push(titleSpacerComponent); + } + topLevelLayoutComponents.push(fullFrameComponent); + + return [ + new AxisChartsTooltip(this.model, this.services), + new Modal(this.model, this.services), + new LayoutComponent( + this.model, + this.services, + topLevelLayoutComponents, + { + direction: LayoutDirection.COLUMN, + } + ), + ]; + } + + getComponents() { + // Specify what to render inside the graph-frame + const graphFrameComponents = [ + new TwoDimensionalAxes(this.model, this.services), + new Heatmap(this.model, this.services), + ]; + + const components: any[] = this.getAxisChartComponents( + graphFrameComponents + ); + return components; + } +} diff --git a/packages/core/src/charts/index.ts b/packages/core/src/charts/index.ts index dd37195335..9d85dd5084 100644 --- a/packages/core/src/charts/index.ts +++ b/packages/core/src/charts/index.ts @@ -21,3 +21,4 @@ export * from './treemap'; export * from './circle-pack'; export * from './wordcloud'; export * from './alluvial'; +export * from './heatmap'; diff --git a/packages/core/src/components/axes/axis.ts b/packages/core/src/components/axes/axis.ts index 0413c4f74d..153016fa01 100644 --- a/packages/core/src/components/axes/axis.ts +++ b/packages/core/src/components/axes/axis.ts @@ -31,6 +31,12 @@ export class Axis extends Component { renderType = RenderTypes.SVG; margins: any; + truncation = { + [AxisPositions.LEFT]: false, + [AxisPositions.RIGHT]: false, + [AxisPositions.TOP]: false, + [AxisPositions.BOTTOM]: false, + }; scale: any; scaleType: ScaleTypes; @@ -645,11 +651,13 @@ export class Axis extends Component { container.selectAll('g.ticks g.tick').html(tick_html); + const self = this; container .selectAll('g.tick text') .data(axisTickLabels) .text(function (d) { if (d.length > truncationThreshold) { + self.truncation[axisPosition] = true; return Tools.truncateLabel( d, truncationType, @@ -702,10 +710,6 @@ export class Axis extends Component { 'threshold' ); - const isTimeScaleType = - this.scaleType === ScaleTypes.TIME || - axisOptions.scaleType === ScaleTypes.TIME; - const self = this; container .selectAll('g.tick text') diff --git a/packages/core/src/components/axes/hover-axis.ts b/packages/core/src/components/axes/hover-axis.ts new file mode 100644 index 0000000000..93c2a4f8db --- /dev/null +++ b/packages/core/src/components/axes/hover-axis.ts @@ -0,0 +1,266 @@ +// Internal Imports +import { Axis } from './axis'; +import { AxisPositions, Events, ScaleTypes } from '../../interfaces'; +import { ChartModel } from '../../model/model'; +import { DOMUtils } from '../../services'; +import { Tools } from '../../tools'; +import * as Configuration from '../../configuration'; + +// D3 Imports +import { select } from 'd3-selection'; + +export class HoverAxis extends Axis { + constructor(model: ChartModel, services: any, configs?: any) { + super(model, services, configs); + } + + render(animate = true) { + super.render(animate); + + // Remove existing event listeners to avoid flashing behavior + super.destroy(); + + const { position: axisPosition } = this.configs; + const svg = this.getComponentContainer(); + const container = DOMUtils.appendOrSelect( + svg, + `g.axis.${axisPosition}` + ); + + const self = this; + container.selectAll('g.tick').each(function (_, index) { + const g = select(this); + g.classed('tick-hover', true).attr( + 'tabindex', + index === 0 ? 0 : -1 + ); + const textNode = g.select('text'); + const { width, height } = DOMUtils.getSVGElementSize(textNode, { + useBBox: true, + }); + + const rectangle = DOMUtils.appendOrSelect(g, 'rect.axis-holder'); + + let x = 0, + y = 0; + + // Depending on axis position, apply correct translation & rotation to align the rect + // with the text + switch (axisPosition) { + case AxisPositions.LEFT: + x = -width + Number(textNode.attr('x')); + y = -(height / 2); + break; + case AxisPositions.RIGHT: + x = Math.abs(Number(textNode.attr('x'))); + y = -(height / 2); + break; + case AxisPositions.TOP: + x = -(width / 2); + y = -height + Number(textNode.attr('y')) / 2; + + if (self.truncation[axisPosition]) { + x = 0; + rectangle.attr('transform', `rotate(-45)`); + } + break; + case AxisPositions.BOTTOM: + x = -(width / 2); + y = height / 2 - 2; + + if (self.truncation[axisPosition]) { + x = -width; + rectangle.attr('transform', `rotate(-45)`); + } + break; + } + + // Translates x position -4 left to keep center after padding + // Adds padding on left & right + rectangle + .attr('x', x - Configuration.axis.hover.rectanglePadding) + .attr('y', y) + .attr( + 'width', + width + Configuration.axis.hover.rectanglePadding * 2 + ) + .attr('height', height) + .lower(); + + // Add keyboard event listeners to each group element + g.on('keydown', function (event: KeyboardEvent) { + // Choose specific arrow key depending on the axis + if ( + axisPosition === AxisPositions.LEFT || + axisPosition === AxisPositions.RIGHT + ) { + if (event.key && event.key === 'ArrowUp') { + self.goNext(this as HTMLElement, event); + } else if (event.key && event.key === 'ArrowDown') { + self.goPrevious(this as HTMLElement, event); + } + } else { + if (event.key && event.key === 'ArrowLeft') { + self.goPrevious(this as HTMLElement, event); + } else if (event.key && event.key === 'ArrowRight') { + self.goNext(this as HTMLElement, event); + } + } + }); + }); + + // Add event listeners to element group + this.addEventListeners(); + } + + addEventListeners() { + const svg = this.getComponentContainer(); + const { position: axisPosition } = this.configs; + const container = DOMUtils.appendOrSelect( + svg, + `g.axis.${axisPosition}` + ); + const options = this.getOptions(); + const axisOptions = Tools.getProperty(options, 'axes', axisPosition); + const axisScaleType = Tools.getProperty(axisOptions, 'scaleType'); + const truncationThreshold = Tools.getProperty( + axisOptions, + 'truncation', + 'threshold' + ); + + const self = this; + container + .selectAll('g.tick.tick-hover') + .on('mouseover', function (event) { + const hoveredElement = select(this).select('text'); + const datum = hoveredElement.datum() as string; + + // Dispatch mouse event + self.services.events.dispatchEvent( + Events.Axis.LABEL_MOUSEOVER, + { + event, + element: hoveredElement, + datum, + } + ); + + if ( + axisScaleType === ScaleTypes.LABELS && + datum.length > truncationThreshold + ) { + self.services.events.dispatchEvent(Events.Tooltip.SHOW, { + event, + element: hoveredElement, + datum, + }); + } + }) + .on('mousemove', function (event) { + const hoveredElement = select(this).select('text'); + const datum = hoveredElement.datum() as string; + // Dispatch mouse event + self.services.events.dispatchEvent( + Events.Axis.LABEL_MOUSEMOVE, + { + event, + element: hoveredElement, + datum, + } + ); + + self.services.events.dispatchEvent(Events.Tooltip.MOVE, { + event, + }); + }) + .on('click', function (event) { + // Dispatch mouse event + self.services.events.dispatchEvent(Events.Axis.LABEL_CLICK, { + event, + element: select(this).select('text'), + datum: select(this).select('text').datum(), + }); + }) + .on('mouseout', function (event) { + // Dispatch mouse event + self.services.events.dispatchEvent(Events.Axis.LABEL_MOUSEOUT, { + event, + element: select(this).select('text'), + datum: select(this).select('text').datum(), + }); + + if (axisScaleType === ScaleTypes.LABELS) { + self.services.events.dispatchEvent(Events.Tooltip.HIDE); + } + }) + .on('focus', function (event) { + const coordinates = { clientX: 0, clientY: 0 }; + + if (event.target) { + // Focus element since we are using arrow keys + event.target.focus(); + const boundingRect = event.target.getBoundingClientRect(); + coordinates.clientX = boundingRect.x; + coordinates.clientY = boundingRect.y; + } + + // Dispatch focus event + self.services.events.dispatchEvent(Events.Axis.LABEL_FOCUS, { + event: { ...event, ...coordinates }, + element: select(this), + datum: select(this).select('text').datum(), + }); + }) + .on('blur', function (event) { + // Dispatch blur event + self.services.events.dispatchEvent(Events.Axis.LABEL_BLUR, { + event, + element: select(this), + datum: select(this).select('text').datum(), + }); + }); + } + + // Focus on the next HTML element sibling + private goNext(element: HTMLElement, event: Event) { + if ( + element.nextElementSibling && + element.nextElementSibling.tagName !== 'path' + ) { + element.nextElementSibling.dispatchEvent(new Event('focus')); + } + + event.preventDefault(); + } + + // Focus on the previous HTML element sibling + private goPrevious(element: HTMLElement, event: Event) { + if ( + element.previousElementSibling && + element.previousElementSibling.tagName !== 'path' + ) { + element.previousElementSibling.dispatchEvent(new Event('focus')); + } + + event.preventDefault(); + } + + destroy() { + const svg = this.getComponentContainer(); + const { position: axisPosition } = this.configs; + const container = DOMUtils.appendOrSelect( + svg, + `g.axis.${axisPosition}` + ); + + // Remove event listeners + container + .selectAll('g.tick.tick-hover') + .on('mouseover', null) + .on('mousemove', null) + .on('mouseout', null) + .on('focus', null) + .on('blur', null); + } +} diff --git a/packages/core/src/components/axes/two-dimensional-axes.ts b/packages/core/src/components/axes/two-dimensional-axes.ts index c42d9a9220..462b75bc0b 100644 --- a/packages/core/src/components/axes/two-dimensional-axes.ts +++ b/packages/core/src/components/axes/two-dimensional-axes.ts @@ -1,16 +1,12 @@ // Internal Imports import { Component } from '../component'; -import { - AxisPositions, - ScaleTypes, - AxesOptions, - RenderTypes, -} from '../../interfaces'; +import { AxisPositions, RenderTypes, AxisFlavor } from '../../interfaces'; import { Axis } from './axis'; import { Tools } from '../../tools'; import { DOMUtils } from '../../services'; import { Threshold } from '../essentials/threshold'; import { Events } from './../../interfaces'; +import { HoverAxis } from './hover-axis'; export class TwoDimensionalAxes extends Component { type = '2D-axes'; @@ -48,11 +44,16 @@ export class TwoDimensionalAxes extends Component { this.configs.axes[axisPosition] && !this.children[axisPosition] ) { - const axisComponent = new Axis(this.model, this.services, { + const configs = { position: axisPosition, axes: this.configs.axes, margins: this.margins, - }); + }; + + const axisComponent = + this.model.axisFlavor === AxisFlavor.DEFAULT + ? new Axis(this.model, this.services, configs) + : new HoverAxis(this.model, this.services, configs); // Set model, services & parent for the new axis component axisComponent.setModel(this.model); @@ -117,6 +118,8 @@ export class TwoDimensionalAxes extends Component { } }); + this.services.events.dispatchEvent(Events.Axis.RENDER_COMPLETE); + // If the new margins are different than the existing ones const isNotEqual = Object.keys(margins).some((marginKey) => { return this.margins[marginKey] !== margins[marginKey]; diff --git a/packages/core/src/components/essentials/color-scale-legend.ts b/packages/core/src/components/essentials/color-scale-legend.ts new file mode 100644 index 0000000000..c0d9c8a4ca --- /dev/null +++ b/packages/core/src/components/essentials/color-scale-legend.ts @@ -0,0 +1,354 @@ +// Internal Imports +import { Tools } from '../../tools'; +import { ColorLegendType, Events, RenderTypes, Roles } from '../../interfaces'; +import * as Configuration from '../../configuration'; +import { Legend } from '../'; +import { DOMUtils } from '../../services'; + +// D3 imports +import { axisBottom } from 'd3-axis'; +import { scaleBand, scaleLinear } from 'd3-scale'; +import { interpolateRound, quantize } from 'd3-interpolate'; + +export class ColorScaleLegend extends Legend { + type = 'color-legend'; + renderType = RenderTypes.SVG; + + private gradient_id = + 'gradient-id-' + Math.floor(Math.random() * 99999999999); + + init() { + const eventsFragment = this.services.events; + + // Highlight correct circle on legend item hovers + eventsFragment.addEventListener( + Events.Axis.RENDER_COMPLETE, + this.handleAxisComplete + ); + } + + handleAxisComplete = (event: CustomEvent) => { + const svg = this.getComponentContainer(); + + const { width } = DOMUtils.getSVGElementSize(svg, { + useAttrs: true, + }); + + const isDataLoading = Tools.getProperty( + this.getOptions(), + 'data', + 'loading' + ); + + if (width > Configuration.legend.color.barWidth && !isDataLoading) { + const title = Tools.getProperty( + this.getOptions(), + 'heatmap', + 'colorLegend', + 'title' + ); + + const { cartesianScales } = this.services; + // Get available chart area + const mainXScale = cartesianScales.getMainXScale(); + + const xDimensions = mainXScale.range(); + + // Align legend with the axis + if (xDimensions[0] > 1) { + svg.select('g.legend').attr( + 'transform', + `translate(${xDimensions[0]}, 0)` + ); + + if (title) { + const { + width: textWidth, + } = DOMUtils.getSVGElementSize( + svg.select('g.legend-title').select('text'), + { useBBox: true } + ); + + // -9 since LEFT y-axis labels are moved towards the left by 9 by d3 + const availableSpace = xDimensions[0] - textWidth - 9; + + // If space is available align the the label with the axis labels + if (availableSpace > 1) { + svg.select('g.legend-title').attr( + 'transform', + `translate(${availableSpace}, 0)` + ); + } else { + // Move the legend down by 16 pixels to display legend text on top + svg.select('g.legend').attr( + 'transform', + `translate(${xDimensions[0]}, 16)` + ); + + // Align legend title with start of axis + svg.select('g.legend-title').attr( + 'transform', + `translate(${xDimensions[0]}, 0)` + ); + } + } + } + } + }; + + render(animate = false) { + const options = this.getOptions(); + + const customColors = Tools.getProperty( + options, + 'color', + 'gradient', + 'colors' + ); + + const colorScaleType = Tools.getProperty( + options, + 'heatmap', + 'colorLegend', + 'type' + ); + + let colorPairingOption = Tools.getProperty( + options, + 'color', + 'pairing', + 'option' + ); + + const title = Tools.getProperty( + options, + 'heatmap', + 'colorLegend', + 'title' + ); + + const customColorsEnabled = !Tools.isEmpty(customColors); + const domain = this.model.getValueDomain(); + + const svg = this.getComponentContainer(); + + // Clear DOM if loading + const isDataLoading = Tools.getProperty( + this.getOptions(), + 'data', + 'loading' + ); + + if (isDataLoading) { + svg.html(''); + return; + } + + const legend = DOMUtils.appendOrSelect(svg, 'g.legend'); + const axis = DOMUtils.appendOrSelect(legend, 'g.legend-axis'); + + const { width } = DOMUtils.getSVGElementSize(svg, { + useAttrs: true, + }); + + let barWidth = Configuration.legend.color.barWidth; + if (width <= Configuration.legend.color.barWidth) { + barWidth = width; + } + + if (title) { + const legendTitleGroup = DOMUtils.appendOrSelect( + svg, + 'g.legend-title' + ); + const legendTitle = DOMUtils.appendOrSelect( + legendTitleGroup, + 'text' + ); + legendTitle.text(title).attr('dy', '0.7em'); + + // Move the legend down by 16 pixels to display legend text on top + legend.attr('transform', `translate(0, 16)`); + } + + // If domain consists of negative and positive values, use diverging palettes + const colorScheme = domain[0] < 0 && domain[1] > 0 ? 'diverge' : 'mono'; + + // Use default color pairing options if not in defined range + if ( + colorPairingOption < 1 && + colorPairingOption > 4 && + colorScheme === 'mono' + ) { + colorPairingOption = 1; + } else if ( + colorPairingOption < 1 && + colorPairingOption > 2 && + colorScheme === 'diverge' + ) { + colorPairingOption = 1; + } + + let colorPairing = []; + // Carbon charts has 11 colors for a single monochromatic palette & 17 for a divergent palette + let colorGroupingLength = colorScheme === 'diverge' ? 17 : 11; + + if (!customColorsEnabled) { + // Add class names to list and the amount based on the color scheme + for (let i = 1; i < colorGroupingLength + 1; i++) { + colorPairing.push( + colorScaleType === ColorLegendType.LINEAR + ? `stop-color-${colorScheme}-${colorPairingOption}-${i}` + : `fill-${colorScheme}-${colorPairingOption}-${i}` + ); + } + } else { + // Use custom colors + colorPairing = customColors; + } + + if (colorScaleType === ColorLegendType.LINEAR) { + const stopLengthPercentage = 100 / (colorPairing.length - 1); + + // Generate the gradient + const linearGradient = DOMUtils.appendOrSelect( + legend, + 'linearGradient' + ); + linearGradient + .attr('id', `${this.gradient_id}-legend`) + .selectAll('stop') + .data(colorPairing) + .enter() + .append('stop') + .attr('offset', (_, i) => `${i * stopLengthPercentage}%`) + .attr('class', (_, i) => colorPairing[i]) + .attr('stop-color', (d) => d); + + // Create the legend container + const rectangle = DOMUtils.appendOrSelect(legend, 'rect'); + rectangle + .attr('width', barWidth) + .attr('height', Configuration.legend.color.barHeight) + .style('fill', `url(#${this.gradient_id}-legend)`); + + // Create scale & ticks + const linearScale = scaleLinear() + .domain(domain) + .range([0, barWidth]); + domain.splice(1, 0, (domain[0] + domain[1]) / 2); + + const xAxis = axisBottom(linearScale) + .tickSize(0) + .tickValues(domain); + + // Align axes at the bottom of the rectangle and delete the domain line + axis.attr( + 'transform', + `translate(0,${Configuration.legend.color.axisYTranslation})` + ).call(xAxis); + + // Remove domain + axis.select('.domain').remove(); + + // Align text to fit in container + axis.style('text-anchor', 'start'); + } else if (colorScaleType === ColorLegendType.QUANTIZE) { + // Generate equal chunks between range to act as ticks + const interpolator = interpolateRound(domain[0], domain[1]); + const quant = quantize(interpolator, colorPairing.length); + + // If divergent && non-custom color, remove 0/white from being displayed + if (!customColorsEnabled && colorScheme === 'diverge') { + colorPairing.splice(colorPairing.length / 2, 1); + } + + const colorScaleBand = scaleBand() + .domain(colorPairing) + .range([0, barWidth]); + + // Render the quantized rectangles + const rectangle = DOMUtils.appendOrSelect( + legend, + 'g.quantized-rect' + ); + + rectangle + .selectAll('rect') + .data(colorScaleBand.domain()) + .join('rect') + .attr('x', (d) => colorScaleBand(d)) + .attr('y', 0) + .attr('width', Math.max(0, colorScaleBand.bandwidth()) - 1) + .attr('height', Configuration.legend.color.barHeight) + .attr('class', (d) => d) + .attr('fill', (d) => d); + + const xAxis = axisBottom(colorScaleBand) + .tickSize(0) + .tickValues(colorPairing) + .tickFormat((_, i) => { + // Display every other tick to create space + if ( + !customColorsEnabled && + ((i + 1) % 2 === 0 || i === colorPairing.length - 1) + ) { + return null; + } + + // Use the quant interpolators as ticks + return quant[i].toString(); + }); + + // Align axis to match bandwidth start after initial (white) + const axisTranslation = colorScaleBand.bandwidth() / 2; + axis.attr( + 'transform', + `translate(${ + !customColorsEnabled && colorScheme === 'diverge' ? '-' : '' + }${axisTranslation}, ${ + Configuration.legend.color.axisYTranslation + })` + ).call(xAxis); + + // Append the last tick + const firstTick = axis.select('g.tick').clone(true); + firstTick + .attr( + 'transform', + `translate(${ + barWidth + + (!customColorsEnabled && colorScheme === 'diverge' + ? axisTranslation + : -axisTranslation) + }, 0)` + ) + .classed('final-tick', true) + .select('text') + .text(quant[quant.length - 1]); + + axis.enter().append(firstTick.node()); + axis.select('.domain').remove(); + } else { + throw Error('Entered color legend type is not supported.'); + } + + // Translate last axis tick if barWidth equals chart width + if (width <= Configuration.legend.color.barWidth) { + const lastTick = axis.select('g.tick:last-of-type text'); + const { width } = DOMUtils.getSVGElementSize(lastTick, { + useBBox: true, + }); + lastTick.attr('x', `-${width}`); + } + } + + destroy() { + // Remove legend listeners + const eventsFragment = this.services.events; + eventsFragment.removeEventListener( + Events.Axis.RENDER_COMPLETE, + this.handleAxisComplete + ); + } +} diff --git a/packages/core/src/components/graphs/heatmap.ts b/packages/core/src/components/graphs/heatmap.ts new file mode 100644 index 0000000000..da0b233e77 --- /dev/null +++ b/packages/core/src/components/graphs/heatmap.ts @@ -0,0 +1,473 @@ +// Internal Imports +import { Component } from '../component'; +import * as Configuration from '../../configuration'; +import { Events, RenderTypes, DividerStatus } from '../../interfaces'; +import { Tools } from '../../tools'; +import { DOMUtils } from '../../services'; + +import { get } from 'lodash-es'; + +// D3 Imports +import { min } from 'd3-array'; +import { select } from 'd3-selection'; + +export class Heatmap extends Component { + type = 'heatmap'; + renderType = RenderTypes.SVG; + + private matrix = {}; + private xBandwidth = 0; + private yBandwidth = 0; + private translationUnits = { + x: 0, + y: 0, + }; + + init() { + const eventsFragment = this.services.events; + + // Highlight correct cells on Axis item hovers + eventsFragment.addEventListener( + Events.Axis.LABEL_MOUSEOVER, + this.handleAxisOnHover + ); + + // Highlight correct cells on Axis item mouseouts + eventsFragment.addEventListener( + Events.Axis.LABEL_MOUSEOUT, + this.handleAxisMouseOut + ); + + // Highlight correct cells on Axis item focus + eventsFragment.addEventListener( + Events.Axis.LABEL_FOCUS, + this.handleAxisOnHover + ); + + // Highlight correct cells on Axis item blur + eventsFragment.addEventListener( + Events.Axis.LABEL_BLUR, + this.handleAxisMouseOut + ); + } + + render(animate = true) { + const svg = this.getComponentContainer({ withinChartClip: true }); + // Lower the chart so the axes are always visible + svg.lower(); + + const { cartesianScales } = this.services; + this.matrix = this.model.getMatrix(); + + svg.html(''); + + if (Tools.getProperty(this.getOptions(), 'data', 'loading')) { + return; + } + + // determine x and y axis scale + const mainXScale = cartesianScales.getMainXScale(); + const mainYScale = cartesianScales.getMainYScale(); + const domainIdentifier = cartesianScales.getDomainIdentifier(); + const rangeIdentifier = cartesianScales.getRangeIdentifier(); + + // Get unique axis values & create a matrix + const uniqueDomain = this.model.getUniqueDomain(); + const uniqueRange = this.model.getUniqueRanges(); + + // Get matrix in the form of an array to create a single heatmap group + const matrixArray = this.model.getMatrixAsArray(); + + // Get available chart area + const xRange = mainXScale.range(); + const yRange = mainYScale.range(); + + // Determine rectangle dimensions based on the number of unique domain and range + this.xBandwidth = Math.abs( + (xRange[1] - xRange[0]) / uniqueDomain.length + ); + this.yBandwidth = Math.abs( + (yRange[1] - yRange[0]) / uniqueRange.length + ); + + const patternID = this.services.domUtils.generateElementIDString( + `heatmap-pattern-stripes` + ); + + // Create a striped pattern for missing data + svg.append('defs') + .append('pattern') + .attr('id', patternID) + .attr('width', 3) + .attr('height', 3) + .attr('patternUnits', 'userSpaceOnUse') + .attr('patternTransform', 'rotate(45)') + .append('rect') + .classed('pattern-fill', true) + .attr('width', 0.5) + .attr('height', 8); + + const rectangles = svg + .selectAll() + .data(matrixArray) + .enter() + .append('g') + .attr('class', (d) => `heat-${d.index}`) + .classed('cell', true) + .attr( + 'transform', + (d) => + `translate(${mainXScale(d[domainIdentifier])}, ${mainYScale( + d[rangeIdentifier] + )})` + ) + .append('rect') + .attr('class', (d) => { + return this.model.getColorClassName({ + value: d.value, + originalClassName: `heat-${d.index}`, + }); + }) + .classed('heat', true) + .classed('null-state', (d) => + d.index === -1 || d.value === null ? true : false + ) + .attr('width', this.xBandwidth) + .attr('height', this.yBandwidth) + .style('fill', (d) => { + // Check if a valid value exists + if (d.index === -1 || d.value === null) { + return `url(#${patternID})`; + } + return this.model.getFillColor(Number(d.value)); + }) + .attr('aria-label', (d) => d.value); + + // Cell highlight box + this.createOuterBox( + 'g.cell-highlight', + this.xBandwidth, + this.yBandwidth + ); + // Column highlight box + this.createOuterBox( + 'g.multi-cell.column-highlight', + this.xBandwidth, + Math.abs(yRange[1] - yRange[0]) + ); + // Row highlight box + this.createOuterBox( + 'g.multi-cell.row-highlight', + Math.abs(xRange[1] - xRange[0]), + this.yBandwidth + ); + + if (this.determineDividerStatus()) { + rectangles.style('stroke-width', '1px'); + this.parent.select('g.cell-highlight').classed('cell-2', true); + } + + this.addEventListener(); + } + + /** + * Generates a box using lines to create a hover effect + * The lines have drop shadow in their respective direction + * @param parentTag - tag name + * @param xBandwidth - X length + * @param yBandwidth - y length + */ + private createOuterBox(parentTag, xBandwidth, yBandwidth) { + // Create a highlighter in the parent component so the shadow and the lines do not get clipped + const highlight = DOMUtils.appendOrSelect(this.parent, parentTag) + .classed('shadows', true) + .classed('highlighter-hidden', true); + + DOMUtils.appendOrSelect(highlight, 'line.top') + .attr('x1', -1) + .attr('x2', xBandwidth + 1); + + DOMUtils.appendOrSelect(highlight, 'line.left') + .attr('x1', 0) + .attr('y1', -1) + .attr('x2', 0) + .attr('y2', yBandwidth + 1); + + DOMUtils.appendOrSelect(highlight, 'line.down') + .attr('x1', -1) + .attr('x2', xBandwidth + 1) + .attr('y1', yBandwidth) + .attr('y2', yBandwidth); + + DOMUtils.appendOrSelect(highlight, 'line.right') + .attr('x1', xBandwidth) + .attr('x2', xBandwidth) + .attr('y1', -1) + .attr('y2', yBandwidth + 1); + } + + private determineDividerStatus(): boolean { + // Add dividers if status is not off, will assume auto or on by default. + const dividerStatus = Tools.getProperty( + this.getOptions(), + 'heatmap', + 'divider', + 'state' + ); + + // Determine if cell divider should be displayed + if (dividerStatus !== DividerStatus.OFF) { + if ( + (dividerStatus === DividerStatus.AUTO && + Configuration.heatmap.minCellDividerDimension <= + this.xBandwidth && + Configuration.heatmap.minCellDividerDimension <= + this.yBandwidth) || + dividerStatus === DividerStatus.ON + ) { + return true; + } + } + + return false; + } + + addEventListener() { + const self = this; + const { cartesianScales } = this.services; + const options = this.getOptions(); + const totalLabel = get(options, 'tooltip.totalLabel'); + + const domainIdentifier = cartesianScales.getDomainIdentifier(); + const rangeIdentifier = cartesianScales.getRangeIdentifier(); + + const domainLabel = cartesianScales.getDomainLabel(); + const rangeLabel = cartesianScales.getRangeLabel(); + + this.parent + .selectAll('g.cell') + .on('mouseover', function (event, datum) { + const cell = select(this); + const hoveredElement = cell.select('rect.heat'); + const nullState = hoveredElement.classed('null-state'); + + // Dispatch event and tooltip only if value exists + if (!nullState) { + // Get transformation value of node + const transform = Tools.getTranformOffsets( + cell.attr('transform') + ); + + select('g.cell-highlight') + .attr( + 'transform', + `translate(${ + transform.x + self.translationUnits.x + }, ${transform.y + self.translationUnits.y})` + ) + .classed('highlighter-hidden', false); + + // Dispatch mouse over event + self.services.events.dispatchEvent( + Events.Heatmap.HEATMAP_MOUSEOVER, + { + event, + element: hoveredElement, + datum: datum, + } + ); + + // Dispatch tooltip show event + self.services.events.dispatchEvent(Events.Tooltip.SHOW, { + event, + items: [ + { + label: domainLabel, + value: datum[domainIdentifier], + }, + { + label: rangeLabel, + value: datum[rangeIdentifier], + }, + { + label: totalLabel || 'Total', + value: datum['value'], + color: hoveredElement.style('fill'), + }, + ], + }); + } + }) + .on('mousemove', function (event, datum) { + // Dispatch mouse move event + self.services.events.dispatchEvent( + Events.Heatmap.HEATMAP_MOUSEMOVE, + { + event, + element: select(this), + datum: datum, + } + ); + // Dispatch tooltip move event + self.services.events.dispatchEvent(Events.Tooltip.MOVE, { + event, + }); + }) + .on('click', function (event, datum) { + // Dispatch mouse click event + self.services.events.dispatchEvent( + Events.Heatmap.HEATMAP_CLICK, + { + event, + element: select(this), + datum: datum, + } + ); + }) + .on('mouseout', function (event, datum) { + const cell = select(this); + const hoveredElement = cell.select('rect.heat'); + const nullState = hoveredElement.classed('null-state'); + + select('g.cell-highlight').classed('highlighter-hidden', true); + + // Dispatch event and tooltip only if value exists + if (!nullState) { + // Dispatch mouse out event + self.services.events.dispatchEvent( + Events.Heatmap.HEATMAP_MOUSEOUT, + { + event, + element: hoveredElement, + datum: datum, + } + ); + + // Dispatch hide tooltip event + self.services.events.dispatchEvent(Events.Tooltip.HIDE, { + event, + hoveredElement, + }); + } + }); + } + + // Highlight elements that match the hovered axis item + handleAxisOnHover = (event: CustomEvent) => { + const { detail } = event; + const { datum } = detail; + // Unique ranges and domains + const ranges = this.model.getUniqueRanges(); + const domains = this.model.getUniqueDomain(); + // Labels + const domainLabel = this.services.cartesianScales.getDomainLabel(); + const rangeLabel = this.services.cartesianScales.getRangeLabel(); + // Scales + const mainXScale = this.services.cartesianScales.getMainXScale(); + const mainYScale = this.services.cartesianScales.getMainYScale(); + + let label = '', + sum = 0, + minimum = 0, + maximum = 0; + + // Check to see where datum belongs + if (this.matrix[datum] !== undefined) { + label = domainLabel; + // Iterate through Object and get sum, min, and max + ranges.forEach((element) => { + let value = this.matrix[datum][element].value || 0; + sum += value; + minimum = value < minimum ? value : minimum; + maximum = value > maximum ? value : maximum; + }); + } else { + label = rangeLabel; + domains.forEach((element) => { + let value = this.matrix[element][datum].value || 0; + sum += value; + minimum = value < minimum ? value : minimum; + maximum = value > maximum ? value : maximum; + }); + } + + if (mainXScale(datum) !== undefined) { + this.parent + .select('g.multi-cell.column-highlight') + .classed('highlighter-hidden', false) + .attr( + 'transform', + `translate(${mainXScale(datum)}, ${min( + mainYScale.range() + )})` + ); + } else if (mainYScale(datum) !== undefined) { + this.parent + .select('g.multi-cell.row-highlight') + .classed('highlighter-hidden', false) + .attr( + 'transform', + `translate(${min(mainXScale.range())},${mainYScale(datum)})` + ); + } + + // Dispatch tooltip show event + this.services.events.dispatchEvent(Events.Tooltip.SHOW, { + event: detail.event, + hoveredElement: select(event.detail.element), + items: [ + { + label: label, + value: datum, + bold: true, + }, + { + label: 'Min', + value: minimum, + }, + { + label: 'Max', + value: maximum, + }, + { + label: 'Average', + value: sum / domains.length, + }, + ], + }); + }; + + // Un-highlight all elements + handleAxisMouseOut = (event: CustomEvent) => { + // Hide column/row + this.parent + .selectAll('g.multi-cell') + .classed('highlighter-hidden', true); + + // Dispatch hide tooltip event + this.services.events.dispatchEvent(Events.Tooltip.HIDE, { + event, + }); + }; + + // Remove event listeners + destroy() { + this.parent + .selectAll('rect.heat') + .on('mouseover', null) + .on('mousemove', null) + .on('click', null) + .on('mouseout', null); + + // Remove legend listeners + const eventsFragment = this.services.events; + eventsFragment.removeEventListener( + Events.Legend.ITEM_HOVER, + this.handleAxisOnHover + ); + eventsFragment.removeEventListener( + Events.Legend.ITEM_MOUSEOUT, + this.handleAxisMouseOut + ); + } +} diff --git a/packages/core/src/components/index.ts b/packages/core/src/components/index.ts index 976e673608..c7e1b238f3 100644 --- a/packages/core/src/components/index.ts +++ b/packages/core/src/components/index.ts @@ -2,6 +2,7 @@ export * from './component'; // ESSENTIALS export * from './essentials/legend'; +export * from './essentials/color-scale-legend'; export * from './essentials/modal'; export * from './essentials/threshold'; export * from './essentials/title'; @@ -36,6 +37,7 @@ export * from './graphs/radar'; export * from './graphs/circle-pack'; export * from './graphs/wordcloud'; export * from './graphs/alluvial'; +export * from './graphs/heatmap'; // Layout export * from './layout/spacer'; diff --git a/packages/core/src/configuration-non-customizable.ts b/packages/core/src/configuration-non-customizable.ts index e1ccfa6e3c..800c398050 100644 --- a/packages/core/src/configuration-non-customizable.ts +++ b/packages/core/src/configuration-non-customizable.ts @@ -20,6 +20,9 @@ export const axis = { compareTo: 'marker', }, paddingRatio: 0.1, + hover: { + rectanglePadding: 4, + }, }; export const canvasZoomSettings = { @@ -130,6 +133,11 @@ export const legend = { iconData: [{ x: 0, y: 0, width: 12, height: 12 }], color: '#8D8D8D', }, + color: { + barWidth: 300, + barHeight: 8, + axisYTranslation: 10, + }, }; export const lines = { @@ -205,6 +213,12 @@ export const alluvial = { }, }; +export const heatmap = { + minCellDividerDimension: 16, + // Ensures axes lines are displayed with or without stroke disabled + chartPadding: 0.5, +}; + export const spacers = { default: { size: 24, diff --git a/packages/core/src/configuration.ts b/packages/core/src/configuration.ts index 2acfd82592..8eaf31d102 100644 --- a/packages/core/src/configuration.ts +++ b/packages/core/src/configuration.ts @@ -45,6 +45,8 @@ import { ZoomBarTypes, LegendItemType, TreeTypes, + HeatmapChartOptions, + DividerStatus, } from './interfaces'; import enUSLocaleObject from 'date-fns/locale/en-US/index'; import { circlePack } from './configuration-non-customizable'; @@ -590,6 +592,18 @@ const alluvialChart: AlluvialChartOptions = Tools.merge({}, chart, { }, } as AlluvialChartOptions); +const heatmapChart: HeatmapChartOptions = Tools.merge({}, chart, { + axes, + heatmap: { + divider: { + state: DividerStatus.AUTO, + }, + colorLegend: { + type: 'linear', + }, + }, +} as HeatmapChartOptions); + export const options = { chart, axisChart, @@ -617,6 +631,7 @@ export const options = { circlePackChart, wordCloudChart, alluvialChart, + heatmapChart, }; export * from './configuration-non-customizable'; diff --git a/packages/core/src/interfaces/charts.ts b/packages/core/src/interfaces/charts.ts index 04fb130381..d2028926b5 100644 --- a/packages/core/src/interfaces/charts.ts +++ b/packages/core/src/interfaces/charts.ts @@ -5,6 +5,8 @@ import { Alignments, ChartTypes, TreeTypes, + DividerStatus, + ColorLegendType, } from './enums'; import { LegendOptions, @@ -137,7 +139,14 @@ export interface BaseChartOptions { * options related to gradient * e.g. { enabled: true } */ - gradient?: object; + gradient?: { + enabled?: boolean; + /** + * hex color array + * e.g. ['#fff', '#000', ...] + */ + colors?: Array; + }; }; } @@ -510,3 +519,30 @@ export interface AlluvialChartOptions extends BaseChartOptions { monochrome?: boolean; }; } + +/** + * options specific to Heatmap charts + */ +export interface HeatmapChartOptions extends BaseChartOptions { + heatmap: { + /** + * Divider width state - will default to auto + * No cell divider for cell dimensions less than 16 + */ + divider?: { + state?: DividerStatus; + }; + /** + * customize color legend + * enabled by default on select charts + */ + colorLegend?: { + /** + * Text to display beside or on top of the legend + * Position is determined by text length + */ + title?: string; + type: ColorLegendType; + }; + }; +} diff --git a/packages/core/src/interfaces/components.ts b/packages/core/src/interfaces/components.ts index a7c1e15f86..d8a303b1b0 100644 --- a/packages/core/src/interfaces/components.ts +++ b/packages/core/src/interfaces/components.ts @@ -4,6 +4,7 @@ import { Alignments, ToolbarControlTypes, ZoomBarTypes, + ColorLegendType, } from './enums'; import { Component } from '../components/component'; import { TruncationOptions } from './truncation'; diff --git a/packages/core/src/interfaces/enums.ts b/packages/core/src/interfaces/enums.ts index 3bf3ade715..9b9e85ac74 100644 --- a/packages/core/src/interfaces/enums.ts +++ b/packages/core/src/interfaces/enums.ts @@ -250,3 +250,28 @@ export enum LegendItemType { QUARTILE = 'quartile', ZOOM = 'zoom', } + +/** + * enum of color legend types + */ +export enum ColorLegendType { + LINEAR = 'linear', + QUANTIZE = 'quantize', +} + +/** + * enum of divider status for heatmap + */ +export enum DividerStatus { + ON = 'on', + AUTO = 'auto', + OFF = 'off', +} + +/** + * enum of axis flavor + */ +export enum AxisFlavor { + DEFAULT = 'default', + HOVERABLE = 'hoverable', +} diff --git a/packages/core/src/interfaces/events.ts b/packages/core/src/interfaces/events.ts index 6ae9559da2..4776e7b376 100644 --- a/packages/core/src/interfaces/events.ts +++ b/packages/core/src/interfaces/events.ts @@ -65,6 +65,9 @@ export enum Axis { LABEL_MOUSEMOVE = 'axis-label-mousemove', LABEL_CLICK = 'axis-label-click', LABEL_MOUSEOUT = 'axis-label-mouseout', + LABEL_FOCUS = 'axis-label-focus', + LABEL_BLUR = 'axis-label-blur', + RENDER_COMPLETE = 'axis-render-complete', } /** @@ -167,7 +170,7 @@ export enum Radar { export enum Tree { NODE_MOUSEOVER = 'tree-node-mouseover', NODE_CLICK = 'tree-node-click', - NODE_MOUSEOUT = 'tree-node-mouseout' + NODE_MOUSEOUT = 'tree-node-mouseout', } /** @@ -240,3 +243,13 @@ export enum Meter { METER_MOUSEOUT = 'meter-mouseout', METER_MOUSEMOVE = 'meter-mousemove', } + +/** + * enum of all heatmap related events + */ +export enum Heatmap { + HEATMAP_MOUSEOVER = 'heatmap-mouseover', + HEATMAP_CLICK = 'heatmap-click', + HEATMAP_MOUSEOUT = 'heatmap-mouseout', + HEATMAP_MOUSEMOVE = 'hetmap-mousemove', +} diff --git a/packages/core/src/model/cartesian-charts.ts b/packages/core/src/model/cartesian-charts.ts index 67b41b5689..873b1b310c 100644 --- a/packages/core/src/model/cartesian-charts.ts +++ b/packages/core/src/model/cartesian-charts.ts @@ -1,7 +1,7 @@ // Internal Imports import { ChartModel } from './model'; import { Tools } from '../tools'; -import { ScaleTypes, AxisPositions } from '../interfaces'; +import { ScaleTypes, AxisPositions, AxisFlavor } from '../interfaces'; // date formatting import { format } from 'date-fns'; @@ -10,13 +10,15 @@ import { format } from 'date-fns'; * This supports adding X and Y Cartesian[2D] zoom data to a ChartModel * */ export class ChartModelCartesian extends ChartModel { + protected axisFlavor = AxisFlavor.DEFAULT; + constructor(services: any) { super(services); } // get the scales information // needed for getTabularArray() - private assignRangeAndDomains() { + protected assignRangeAndDomains() { const { cartesianScales } = this.services; const options = this.getOptions(); const isDualAxes = cartesianScales.isDualAxes(); diff --git a/packages/core/src/model/heatmap.ts b/packages/core/src/model/heatmap.ts new file mode 100644 index 0000000000..1ed7030df9 --- /dev/null +++ b/packages/core/src/model/heatmap.ts @@ -0,0 +1,348 @@ +// Internal Imports +import { AxisFlavor, ScaleTypes } from '../interfaces'; +import { ChartModelCartesian } from './cartesian-charts'; +import { Tools } from '../tools'; + +// d3 imports +import { extent } from 'd3-array'; +import { scaleQuantize } from 'd3-scale'; + +/** The gauge chart model layer */ +export class HeatmapModel extends ChartModelCartesian { + protected axisFlavor = AxisFlavor.HOVERABLE; + private _colorScale: any = undefined; + + // List of unique ranges and domains + private _domains = []; + private _ranges = []; + + private _matrix = {}; + + constructor(services: any) { + super(services); + + // Check which scale types are being used + const axis = Tools.getProperty(this.getOptions(), 'axes'); + + // Need to check options since scale service hasn't been instantiated + if ( + (!!Tools.getProperty(axis, 'left', 'scaleType') && + Tools.getProperty(axis, 'left', 'scaleType') !== + ScaleTypes.LABELS) || + (!!Tools.getProperty(axis, 'right', 'scaleType') && + Tools.getProperty(axis, 'right', 'scaleType') !== + ScaleTypes.LABELS) || + (!!Tools.getProperty(axis, 'top', 'scaleType') && + Tools.getProperty(axis, 'top', 'scaleType') !== + ScaleTypes.LABELS) || + (!!Tools.getProperty(axis, 'bottom', 'scaleType') && + Tools.getProperty(axis, 'bottom', 'scaleType') !== + ScaleTypes.LABELS) + ) { + throw Error('Heatmap only supports label scaletypes.'); + } + } + + /** + * Get min and maximum value of the display data + * @returns Array consisting of smallest and largest values in data + */ + getValueDomain() { + const data = this.getDisplayData().map((element) => element.value); + const limits = extent(data); + const domain = []; + + // Round extent values to the nearest multiple of 10 + // Axis rounds values to multiples of 2, 5, and 10s. + limits.forEach((number, index) => { + let value = Number(number); + + if (index === 0 && value >= 0) { + value = 0; + } else if (value % 10 === 0 || value === 0) { + value; + } else if (value < 0) { + value = Math.floor(value / 10) * 10; + } else { + value = Math.ceil(value / 10) * 10; + } + + domain.push(value); + }); + + // Ensure the median of the range is 0 + if (domain[0] < 0 && domain[1] > 0) { + if (Math.abs(domain[0]) > domain[1]) { + domain[1] = Math.abs(domain[0]); + } else { + domain[0] = -domain[1]; + } + } + + return domain; + } + + /** + * @override + * @param value + * @returns + */ + getFillColor(value: number) { + return this._colorScale(value); + } + + /** + * Generate a list of all unique domains + * @returns String[] + */ + getUniqueDomain(): string[] { + if (Tools.isEmpty(this._domains)) { + const displayData = this.getDisplayData(); + const { cartesianScales } = this.services; + + const domainIdentifier = cartesianScales.getDomainIdentifier(); + const mainXAxisPosition = cartesianScales.getMainXAxisPosition(); + const customDomain = cartesianScales.getCustomDomainValuesByposition( + mainXAxisPosition + ); + + // Use user defined domain if specified + if (!!customDomain) { + return customDomain; + } + + // Get unique axis values & create a matrix + this._domains = Array.from( + new Set( + displayData.map((d) => { + return d[domainIdentifier]; + }) + ) + ); + } + + return this._domains; + } + + /** + * Generates a list of all unique ranges + * @returns String[] + */ + getUniqueRanges(): string[] { + if (Tools.isEmpty(this._ranges)) { + const displayData = this.getDisplayData(); + const { cartesianScales } = this.services; + + const rangeIdentifier = cartesianScales.getRangeIdentifier(); + const mainYAxisPosition = cartesianScales.getMainYAxisPosition(); + const customDomain = cartesianScales.getCustomDomainValuesByposition( + mainYAxisPosition + ); + + // Use user defined domain if specified + if (!!customDomain) { + return customDomain; + } + + // Get unique axis values & create a matrix + this._ranges = Array.from( + new Set( + displayData.map((d) => { + return d[rangeIdentifier]; + }) + ) + ); + } + + return this._ranges; + } + + /** + * Generates a matrix (If doesn't exist) and returns it + * @returns Object + */ + getMatrix() { + if (Tools.isEmpty(this._matrix)) { + const uniqueDomain = this.getUniqueDomain(); + const uniqueRange = this.getUniqueRanges(); + + const domainIdentifier = this.services.cartesianScales.getDomainIdentifier(); + const rangeIdentifier = this.services.cartesianScales.getRangeIdentifier(); + + // Create a column + const range = {}; + uniqueRange.forEach((ran: any) => { + // Initialize matrix to empty state + range[ran] = { + value: null, + index: -1, + }; + }); + + // Complete the matrix by cloning the column to all domains + uniqueDomain.forEach((dom: any) => { + this._matrix[dom] = Tools.clone(range); + }); + + // Fill in user passed data + this.getDisplayData().forEach((d, i) => { + this._matrix[d[domainIdentifier]][d[rangeIdentifier]] = { + value: d['value'], + index: i, + }; + }); + } + + return this._matrix; + } + + /** + * + * @param newData The new raw data to be set + */ + setData(newData) { + const sanitizedData = this.sanitize(Tools.clone(newData)); + const dataGroups = this.generateDataGroups(sanitizedData); + + this.set({ + data: sanitizedData, + dataGroups, + }); + + // Set attributes to empty + this._domains = []; + this._ranges = []; + this._matrix = {}; + + return sanitizedData; + } + + /** + * Converts Object matrix into a single array + * @returns Object[] + */ + getMatrixAsArray(): Object[] { + if (Tools.isEmpty(this._matrix)) { + this.getMatrix(); + } + + const uniqueDomain = this.getUniqueDomain(); + const uniqueRange = this.getUniqueRanges(); + + const domainIdentifier = this.services.cartesianScales.getDomainIdentifier(); + const rangeIdentifier = this.services.cartesianScales.getRangeIdentifier(); + + const arr = []; + uniqueDomain.forEach((domain) => { + uniqueRange.forEach((range) => { + const element = { + value: this._matrix[domain][range].value, + index: this._matrix[domain][range].index, + }; + element[domainIdentifier] = domain; + element[rangeIdentifier] = range; + arr.push(element); + }); + }); + + return arr; + } + + /** + * Generate tabular data from display data + * @returns Array + */ + getTabularDataArray() { + const displayData = this.getDisplayData(); + + const { + primaryDomain, + primaryRange, + secondaryDomain, + secondaryRange, + } = this.assignRangeAndDomains(); + + let domainValueFormatter; + + const result = [ + [primaryDomain.label, primaryRange.label, 'Value'], + ...displayData.map((datum) => [ + datum[primaryDomain.identifier] === null + ? '–' + : domainValueFormatter + ? domainValueFormatter(datum[primaryDomain.identifier]) + : datum[primaryDomain.identifier], + datum[primaryRange.identifier] === null + ? '–' + : datum[primaryRange.identifier].toLocaleString(), + datum['value'], + ]), + ]; + + return result; + } + + // Uses quantize scale to return class names + getColorClassName(configs: { value?: number; originalClassName?: string }) { + return `${configs.originalClassName} ${this._colorScale( + configs.value as number + )}`; + } + + protected setColorClassNames() { + const options = this.getOptions(); + + const customColors = Tools.getProperty( + options, + 'color', + 'gradient', + 'colors' + ); + const customColorsEnabled = !Tools.isEmpty(customColors); + + let colorPairingOption = Tools.getProperty( + options, + 'color', + 'pairing', + 'option' + ); + + // If domain consists of negative and positive values, use diverging palettes + const domain = this.getValueDomain(); + const colorScheme = domain[0] < 0 && domain[1] > 0 ? 'diverge' : 'mono'; + + // Use default color pairing options if not in defined range + if ( + colorPairingOption < 1 && + colorPairingOption > 4 && + colorScheme === 'mono' + ) { + colorPairingOption = 1; + } else if ( + colorPairingOption < 1 && + colorPairingOption > 2 && + colorScheme === 'diverge' + ) { + colorPairingOption = 1; + } + + // Uses css classes for fill + const colorPairing = customColorsEnabled ? customColors : []; + + if (!customColorsEnabled) { + // Add class names to list and the amount based on the color scheme + // Carbon charts has 11 colors for a single monochromatic palette & 17 for a divergent palette + const colorGroupingLength = colorScheme === 'diverge' ? 17 : 11; + for (let i = 1; i < colorGroupingLength + 1; i++) { + colorPairing.push( + `fill-${colorScheme}-${colorPairingOption}-${i}` + ); + } + } + + // Save scale type + this._colorScale = scaleQuantize() + .domain(this.getValueDomain() as [number, number]) + .range(colorPairing); + } +} diff --git a/packages/core/src/services/scales-cartesian.ts b/packages/core/src/services/scales-cartesian.ts index e6836bafba..256a6f9d2f 100644 --- a/packages/core/src/services/scales-cartesian.ts +++ b/packages/core/src/services/scales-cartesian.ts @@ -214,6 +214,37 @@ export class CartesianScales extends Service { } } + getCustomDomainValuesByposition(axisPosition: AxisPositions) { + const domain = Tools.getProperty( + this.model.getOptions(), + 'axes', + axisPosition, + 'domain' + ); + + // Check if domain is an array + if (domain && !Array.isArray(domain)) { + throw new Error( + `Domain in ${axisPosition} axis is not a valid array` + ); + } + + // Determine number of elements passed in domain depending on scale types + if (Array.isArray(domain)) { + if ( + (this.scaleTypes[axisPosition] === ScaleTypes.LINEAR || + this.scaleTypes[axisPosition] === ScaleTypes.TIME) && + domain.length !== 2 + ) { + throw new Error( + `There can only be 2 elements in domain for scale type: ${this.scaleTypes[axisPosition]}` + ); + } + } + + return domain; + } + getOrientation() { return this.orientation; } diff --git a/packages/core/src/styles/color-palatte.scss b/packages/core/src/styles/color-palatte.scss index 50a9cddab5..6096b7e330 100644 --- a/packages/core/src/styles/color-palatte.scss +++ b/packages/core/src/styles/color-palatte.scss @@ -261,3 +261,99 @@ $white-theme-legend-area-item-colors: ( ); $white-theme-legend-area-item-stroke: getColorValue('gray', 50); + +$monochrome-quantize-colors: ( + 'mono-1': ( + '1': #ffffff, + '2': getColorValue('purple', 10), + '3': getColorValue('purple', 20), + '4': getColorValue('purple', 30), + '5': getColorValue('purple', 40), + '6': getColorValue('purple', 50), + '7': getColorValue('purple', 60), + '8': getColorValue('purple', 70), + '9': getColorValue('purple', 80), + '10': getColorValue('purple', 90), + '11': getColorValue('purple', 100), + ), + 'mono-2': ( + '1': #ffffff, + '2': getColorValue('blue', 10), + '3': getColorValue('blue', 20), + '4': getColorValue('blue', 30), + '5': getColorValue('blue', 40), + '6': getColorValue('blue', 50), + '7': getColorValue('blue', 60), + '8': getColorValue('blue', 70), + '9': getColorValue('blue', 80), + '10': getColorValue('blue', 90), + '11': getColorValue('blue', 100), + ), + 'mono-3': ( + '1': #ffffff, + '2': getColorValue('cyan', 10), + '3': getColorValue('cyan', 20), + '4': getColorValue('cyan', 30), + '5': getColorValue('cyan', 40), + '6': getColorValue('cyan', 50), + '7': getColorValue('cyan', 60), + '8': getColorValue('cyan', 70), + '9': getColorValue('cyan', 80), + '10': getColorValue('cyan', 90), + '11': getColorValue('cyan', 100), + ), + 'mono-4': ( + '1': #ffffff, + '2': getColorValue('teal', 10), + '3': getColorValue('teal', 20), + '4': getColorValue('teal', 30), + '5': getColorValue('teal', 40), + '6': getColorValue('teal', 50), + '7': getColorValue('teal', 60), + '8': getColorValue('teal', 70), + '9': getColorValue('teal', 80), + '10': getColorValue('teal', 90), + '11': getColorValue('teal', 100), + ), +); + +$divergent-quantize-colors: ( + 'diverge-1': ( + '1': getColorValue('red', 80), + '2': getColorValue('red', 70), + '3': getColorValue('red', 60), + '4': getColorValue('red', 50), + '5': getColorValue('red', 40), + '6': getColorValue('red', 30), + '7': getColorValue('red', 20), + '8': getColorValue('red', 10), + '9': #ffffff, + '10': getColorValue('cyan', 10), + '11': getColorValue('cyan', 20), + '12': getColorValue('cyan', 30), + '13': getColorValue('cyan', 40), + '14': getColorValue('cyan', 50), + '15': getColorValue('cyan', 60), + '16': getColorValue('cyan', 70), + '17': getColorValue('cyan', 80), + ), + 'diverge-2': ( + '1': getColorValue('purple', 80), + '2': getColorValue('purple', 70), + '3': getColorValue('purple', 60), + '4': getColorValue('purple', 50), + '5': getColorValue('purple', 40), + '6': getColorValue('purple', 30), + '7': getColorValue('purple', 20), + '8': getColorValue('purple', 10), + '9': #ffffff, + '10': getColorValue('teal', 10), + '11': getColorValue('teal', 20), + '12': getColorValue('teal', 30), + '13': getColorValue('teal', 40), + '14': getColorValue('teal', 50), + '15': getColorValue('teal', 60), + '16': getColorValue('teal', 70), + '17': getColorValue('teal', 80), + ), +); diff --git a/packages/core/src/styles/colors.scss b/packages/core/src/styles/colors.scss index 2b094932ef..7f8be8b856 100644 --- a/packages/core/src/styles/colors.scss +++ b/packages/core/src/styles/colors.scss @@ -12,6 +12,13 @@ } } +@function getGradientColors() { + $monochrome: color-property(null, $monochrome-quantize-colors); + $divergent: color-property(null, $divergent-quantize-colors); + + @return map-merge($monochrome, $divergent); +} + @function getLegendAreaItemColors() { @if $carbon--theme == $carbon--theme--g100 or @@ -47,8 +54,31 @@ } } +@function gradient-color-property($name, $theme-colors) { + $color-items: (); + + @if type-of($theme-colors) == map { + @each $category, $value in $theme-colors { + @if $name == null { + $color-items: map-merge( + $color-items, + color-property('#{$category}', $value) + ); + } @else { + $color-items: map-merge( + $color-items, + color-property('#{$name}-#{$category}', $value) + ); + } + } + @return $color-items; + } @else { + @return (#{$name}: $theme-colors); + } +} + .#{$prefix}--#{$charts-prefix}--chart-wrapper { - $color-map: getThemeColors(); + $color-map: map-merge(getThemeColors(), getGradientColors()); @each $token, $color in $color-map { .fill-#{$token} { @@ -72,6 +102,10 @@ .stroke-#{$token} { stroke: $color; } + + .stop-color-#{$token} { + stop-color: $color; + } } } diff --git a/packages/core/src/styles/components/_axis.scss b/packages/core/src/styles/components/_axis.scss index 85a8e58dd1..cc16dd4ebd 100644 --- a/packages/core/src/styles/components/_axis.scss +++ b/packages/core/src/styles/components/_axis.scss @@ -10,6 +10,47 @@ visibility: hidden; } + g.tick-hover rect.axis-holder { + fill: transparent; + stroke: transparent; + stroke-width: 2px; + } + + g.tick-hover:hover, + g.tick-hover:focus { + @if $carbon--theme == + $carbon--theme--g90 or + $carbon--theme == + $carbon--theme--g100 + { + rect.axis-holder { + fill: white; + stroke: white; + stroke-width: 2px; + } + + text { + fill: invert($text-02); + } + } + + @if $carbon--theme == + $carbon--theme--g10 or + $carbon--theme == + $carbon--theme--white + { + rect.axis-holder { + fill: black; + stroke: black; + stroke-width: 2px; + } + + text { + fill: white; + } + } + } + g.tick text { fill: $text-02; font-family: carbon--font-family('sans-condensed'); diff --git a/packages/core/src/styles/components/_color-legend.scss b/packages/core/src/styles/components/_color-legend.scss new file mode 100644 index 0000000000..5f9168dcb6 --- /dev/null +++ b/packages/core/src/styles/components/_color-legend.scss @@ -0,0 +1,24 @@ +svg.#{$prefix}--#{$charts-prefix}--color-legend { + display: flex; + user-select: none; + + @if $carbon--theme == + $carbon--theme--g90 or + $carbon--theme == + $carbon--theme--g100 + { + g.legend-title text { + color: white; + } + } + + @if $carbon--theme == + $carbon--theme--g10 or + $carbon--theme == + $carbon--theme--white + { + g.legend-title text { + fill: black; + } + } +} diff --git a/packages/core/src/styles/components/index.scss b/packages/core/src/styles/components/index.scss index 404ccc6cc3..dd6e53ac7e 100644 --- a/packages/core/src/styles/components/index.scss +++ b/packages/core/src/styles/components/index.scss @@ -17,3 +17,4 @@ @import './zoom-bar'; @import './highlights'; @import './diagrams/index.scss'; +@import './color-legend'; diff --git a/packages/core/src/styles/graphs/_heatmap.scss b/packages/core/src/styles/graphs/_heatmap.scss new file mode 100644 index 0000000000..50512e6190 --- /dev/null +++ b/packages/core/src/styles/graphs/_heatmap.scss @@ -0,0 +1,70 @@ +.#{$prefix}--#{$charts-prefix}--heatmap { + g.highlighter-hidden { + visibility: hidden; + } + + g.cell-highlight { + line { + stroke: white; + stroke-width: 1px; + } + } + + g.cell-2 { + line { + stroke: white; + stroke-width: 2px !important; + } + } + + g.multi-cell { + line { + stroke: white; + stroke-width: 2px; + } + } + + rect.pattern-fill { + fill: $ui-04; + } + + g.shadows { + line.top { + filter: drop-shadow(0px -3px 2px black); + } + + line.down { + filter: drop-shadow(0px 3px 2px black); + } + + line.left { + filter: drop-shadow(-3px 0px 2px black); + } + + line.right { + filter: drop-shadow(3px 0px 2px black); + } + } + + rect.null-state { + stroke: transparent !important; + } + + rect.heat { + stroke-width: 0px; + } + + rect.heat { + stroke: $ui-background; + } + + rect.null-state { + fill: $ui-01; + } + + @if $carbon--theme == $carbon--theme--g90 { + rect.null-state { + fill: $inverse-01; + } + } +} diff --git a/packages/core/src/styles/graphs/index.scss b/packages/core/src/styles/graphs/index.scss index f3d12bc177..7aba4891e6 100644 --- a/packages/core/src/styles/graphs/index.scss +++ b/packages/core/src/styles/graphs/index.scss @@ -15,3 +15,4 @@ @import './circle-pack'; @import './wordcloud'; @import './alluvial'; +@import './heatmap'; diff --git a/packages/react/src/heatmap-chart.tsx b/packages/react/src/heatmap-chart.tsx new file mode 100644 index 0000000000..e2b90d8a9e --- /dev/null +++ b/packages/react/src/heatmap-chart.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { HeatmapChart as HMC } from '@carbon/charts'; +import BaseChart from './base-chart'; +import { ChartConfig, HeatmapChartOptions } from '@carbon/charts/interfaces'; + +type HeatmapChartProps = ChartConfig; + +export default class HeatmapChart extends BaseChart { + chartRef!: HTMLDivElement; + props!: HeatmapChartProps; + chart!: HMC; + + componentDidMount() { + this.chart = new HMC(this.chartRef, { + data: this.props.data, + options: this.props.options, + }); + } + + render() { + return ( +
(this.chartRef = chartRef!)} + className="chart-holder">
+ ); + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 000c8b8379..670b981e05 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -21,6 +21,7 @@ import TreemapChart from './treemap-chart'; import CirclePackChart from './circle-pack-chart'; import WordCloudChart from './wordcloud-chart'; import AlluvialChart from './alluvial-chart'; +import HeatmapChart from './heatmap-chart'; export { AreaChart, @@ -46,4 +47,5 @@ export { CirclePackChart, WordCloudChart, AlluvialChart, + HeatmapChart, }; diff --git a/packages/svelte/src/HeatmapChart.svelte b/packages/svelte/src/HeatmapChart.svelte new file mode 100644 index 0000000000..9e9be0d9e7 --- /dev/null +++ b/packages/svelte/src/HeatmapChart.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/svelte/src/index.js b/packages/svelte/src/index.js index cd7f9b91f1..eedc618760 100644 --- a/packages/svelte/src/index.js +++ b/packages/svelte/src/index.js @@ -21,6 +21,7 @@ import TreemapChart from './TreemapChart.svelte'; import CirclePackChart from './CirclePackChart.svelte'; import WordCloudChart from './WordCloudChart.svelte'; import AlluvialChart from './AlluvialChart.svelte'; +import HeatmapChart from './HeatmapChart.svelte'; export { AreaChart, @@ -46,4 +47,5 @@ export { CirclePackChart, WordCloudChart, AlluvialChart, + HeatmapChart, }; diff --git a/packages/vue/src/ccv-heatmap-chart.vue b/packages/vue/src/ccv-heatmap-chart.vue new file mode 100644 index 0000000000..f2b3c96d5a --- /dev/null +++ b/packages/vue/src/ccv-heatmap-chart.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/vue/src/index.js b/packages/vue/src/index.js index c3304a6a23..e279340051 100644 --- a/packages/vue/src/index.js +++ b/packages/vue/src/index.js @@ -21,6 +21,7 @@ import CcvTreemapChart from './ccv-treemap-chart.vue'; import CcvCirclePackChart from './ccv-circle-pack-chart.vue'; import CcvWordCloudChart from './ccv-wordcloud-chart.vue'; import CcvAlluvialChart from './ccv-alluvial-chart.vue'; +import CcvHeatmapChart from './ccv-heatmap-chart.vue'; const components = [ CcvAreaChart, @@ -46,6 +47,7 @@ const components = [ CcvCirclePackChart, CcvWordCloudChart, CcvAlluvialChart, + CcvHeatmapChart, ]; /* @@ -104,4 +106,5 @@ export { CcvCirclePackChart, CcvWordCloudChart, CcvAlluvialChart, + CcvHeatmapChart, };