diff --git a/projects/swimlane/ngx-charts/src/lib/bar-chart/bar.component.ts b/projects/swimlane/ngx-charts/src/lib/bar-chart/bar.component.ts index 4330632df..adff0a6af 100644 --- a/projects/swimlane/ngx-charts/src/lib/bar-chart/bar.component.ts +++ b/projects/swimlane/ngx-charts/src/lib/bar-chart/bar.component.ts @@ -53,6 +53,7 @@ export class BarComponent implements OnChanges { @Input() animations: boolean = true; @Input() ariaLabel: string; @Input() noBarWhenZero: boolean = true; + @Input() timelineChart: boolean = false; @Output() select: EventEmitter = new EventEmitter(); @Output() activate: EventEmitter = new EventEmitter(); @@ -193,7 +194,9 @@ export class BarComponent implements OnChanges { get edges(): boolean[] { let edges = [false, false, false, false]; if (this.roundEdges) { - if (this.orientation === BarOrientation.Vertical) { + if (this.timelineChart) { + edges = [true, true, true, true]; + } else if (this.orientation === BarOrientation.Vertical) { if (this.data.value > 0) { edges = [true, true, false, false]; } else { diff --git a/projects/swimlane/ngx-charts/src/lib/common/base-chart.component.ts b/projects/swimlane/ngx-charts/src/lib/common/base-chart.component.ts index 1fb86981b..76c42435c 100644 --- a/projects/swimlane/ngx-charts/src/lib/common/base-chart.component.ts +++ b/projects/swimlane/ngx-charts/src/lib/common/base-chart.component.ts @@ -214,6 +214,14 @@ export class BaseChartComponent implements OnChanges, AfterViewInit, OnDestroy, copy['target'] = item['target']; } + if (item['startTime'] !== undefined) { + copy['startTime'] = item['startTime']; + } + + if (item['endTime'] !== undefined) { + copy['endTime'] = item['endTime']; + } + results.push(copy); } diff --git a/projects/swimlane/ngx-charts/src/lib/common/timeline/timeline.component.ts b/projects/swimlane/ngx-charts/src/lib/common/timeline/timeline.component.ts index 7613a2184..0d75cb496 100644 --- a/projects/swimlane/ngx-charts/src/lib/common/timeline/timeline.component.ts +++ b/projects/swimlane/ngx-charts/src/lib/common/timeline/timeline.component.ts @@ -49,6 +49,7 @@ export class Timeline implements OnChanges { @Input() autoScale: boolean; @Input() scaleType: ScaleType; @Input() height: number = 50; + @Input() xScale: any; @Output() select = new EventEmitter(); @Output() onDomainChange = new EventEmitter(); @@ -56,7 +57,6 @@ export class Timeline implements OnChanges { element: HTMLElement; dims: ViewDimensions; xDomain: any[]; - xScale: any; brush: any; transform: string; initialized: boolean = false; @@ -81,9 +81,6 @@ export class Timeline implements OnChanges { this.height = this.dims.height; const offsetY = this.view[1] - this.height; - this.xDomain = this.getXDomain(); - this.xScale = this.getXScale(); - if (this.brush) { this.updateBrush(); } diff --git a/projects/swimlane/ngx-charts/src/lib/line-chart/line-chart.component.ts b/projects/swimlane/ngx-charts/src/lib/line-chart/line-chart.component.ts index 3d714c233..a397507fe 100644 --- a/projects/swimlane/ngx-charts/src/lib/line-chart/line-chart.component.ts +++ b/projects/swimlane/ngx-charts/src/lib/line-chart/line-chart.component.ts @@ -162,6 +162,7 @@ import { isPlatformServer } from '@angular/common'; [customColors]="customColors" [scaleType]="scaleType" [legend]="legend" + [xScale]="timelineXScale" (onDomainChange)="updateDomain($event)" > diff --git a/projects/swimlane/ngx-charts/src/lib/models/chart-data.model.ts b/projects/swimlane/ngx-charts/src/lib/models/chart-data.model.ts index 0b84fd626..226a03310 100644 --- a/projects/swimlane/ngx-charts/src/lib/models/chart-data.model.ts +++ b/projects/swimlane/ngx-charts/src/lib/models/chart-data.model.ts @@ -85,6 +85,21 @@ export interface BoxChartSeries { export interface BoxChartMultiSeries extends Array {} +export interface TimelineStandardDataItem { + name: StringOrNumberOrDate; + startTime: Date; + endTime: Date; +} + +export interface TimelineStandardData extends Array {} + +export interface TimelineStackedDataItem { + name: StringOrNumberOrDate; + series: TimelineStandardDataItem[]; +} + +export interface TimelineStackedData extends Array {} + export interface IBoxModel { value: number | Date; label: StringOrNumberOrDate; diff --git a/projects/swimlane/ngx-charts/src/lib/ngx-charts.module.ts b/projects/swimlane/ngx-charts/src/lib/ngx-charts.module.ts index 32543706c..9699f6007 100644 --- a/projects/swimlane/ngx-charts/src/lib/ngx-charts.module.ts +++ b/projects/swimlane/ngx-charts/src/lib/ngx-charts.module.ts @@ -13,6 +13,7 @@ import { TreeMapModule } from './tree-map/tree-map.module'; import { GaugeModule } from './gauge/gauge.module'; import { ngxChartsPolyfills } from './polyfills'; import { SankeyModule } from './sankey/sankey.module'; +import { TimelineChartModule } from './timeline-chart/timeline-chart.module'; @NgModule({ exports: [ @@ -28,7 +29,8 @@ import { SankeyModule } from './sankey/sankey.module'; NumberCardModule, PieChartModule, TreeMapModule, - GaugeModule + GaugeModule, + TimelineChartModule ] }) export class NgxChartsModule { diff --git a/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-chart.component.ts b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-chart.component.ts new file mode 100644 index 000000000..65c0eaf96 --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-chart.component.ts @@ -0,0 +1,399 @@ +import { + Component, + Input, + Output, + EventEmitter, + ViewEncapsulation, + ChangeDetectionStrategy, + ContentChild, + TemplateRef +} from '@angular/core'; +import { scaleBand, scaleLinear, scaleTime } from 'd3-scale'; + +import { calculateViewDimensions } from '../common/view-dimensions.helper'; +import { ColorHelper } from '../common/color.helper'; +import { BaseChartComponent } from '../common/base-chart.component'; +import { id } from '../utils/id'; +import { TimelineChartType } from './types/timeline-chart-type.enum'; +import { LegendOptions, LegendPosition } from '../common/types/legend.model'; +import { ScaleType } from '../common/types/scale-type.enum'; +import { ViewDimensions } from '../common/types/view-dimension.interface'; +import { getScaleType } from '../common/domain.helper'; + +@Component({ + selector: 'ngx-charts-timeline-chart', + template: ` + + + + + + + + + + + + + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['../common/base-chart.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class TimelineChartComponent extends BaseChartComponent { + @Input() legend = false; + @Input() legendTitle: string = 'Legend'; + @Input() legendPosition: LegendPosition = LegendPosition.Right; + @Input() xAxis; + @Input() yAxis; + @Input() showXAxisLabel: boolean; + @Input() showYAxisLabel: boolean; + @Input() xAxisLabel: string; + @Input() yAxisLabel: string; + @Input() tooltipDisabled: boolean = false; + @Input() tooltipType: string; + @Input() gradient: boolean; + @Input() showGridLines: boolean = true; + @Input() activeEntries: any[] = []; + @Input() schemeType: ScaleType; + @Input() trimXAxisTicks: boolean = true; + @Input() trimYAxisTicks: boolean = true; + @Input() rotateXAxisTicks: boolean = true; + @Input() maxXAxisTickLength: number = 16; + @Input() maxYAxisTickLength: number = 16; + @Input() xAxisTickFormatting: any; + @Input() yAxisTickFormatting: any; + @Input() xAxisTicks: any[]; + @Input() yAxisTicks: any[]; + @Input() barPadding: number = 8; + @Input() roundDomains: boolean = false; + @Input() roundEdges: boolean = true; + @Input() xScaleMax: number; + @Input() xScaleMin: number; + @Input() dataLabelFormatting: any; + @Input() noBarWhenZero: boolean = true; + @Input() wrapTicks = false; + @Input() timelineFilter: any[]; + + @Output() activate: EventEmitter = new EventEmitter(); + @Output() deactivate: EventEmitter = new EventEmitter(); + + @ContentChild('tooltipTemplate') tooltipTemplate: TemplateRef; + @ContentChild('seriesTooltipTemplate') seriesTooltipTemplate: TemplateRef; + + dims: ViewDimensions; + yScale: any; + xScale: any; + xDomain: any[]; + yDomain: string[]; + transform: string; + clipPath: string; + clipPathId: string; + colors: ColorHelper; + margin: number[] = [10, 20, 10, 20]; + xAxisHeight: number = 0; + yAxisWidth: number = 0; + legendOptions: LegendOptions; + dataLabelMaxWidth: any = { negative: 0, positive: 0 }; + scaleType: ScaleType; + timelineWidth: any; + timelineHeight: number = 50; + timelineXScale: any; + timelineYScale: any; + timelineXDomain: any; + timelineTransform: any; + timelinePadding: number = 10; + timelineBarPadding: number = 1; + filteredDomain: any; + + TimelineChartType = TimelineChartType; + + update(): void { + super.update(); + + this.margin = [10, 20 + this.dataLabelMaxWidth.positive, 10, 20 + this.dataLabelMaxWidth.negative]; + + this.dims = calculateViewDimensions({ + width: this.width, + height: this.height, + margins: this.margin, + showXAxis: this.xAxis, + showYAxis: this.yAxis, + xAxisHeight: this.xAxisHeight, + yAxisWidth: this.yAxisWidth, + showXLabel: this.showXAxisLabel, + showYLabel: this.showYAxisLabel, + showLegend: this.legend, + legendType: this.schemeType, + legendPosition: this.legendPosition + }); + + if (this.timelineFilter) { + this.dims.height -= this.timelineHeight + this.margin[2] + this.timelinePadding; + } + + this.formatDates(); + + this.xDomain = this.getXDomain(); + if (this.filteredDomain) { + this.xDomain = this.filteredDomain; + } + this.yDomain = this.getYDomain(); + + this.xScale = this.getXScale(this.xDomain, this.dims.width); + this.yScale = this.getYScale(this.yDomain, this.dims.height, this.barPadding); + + this.updateTimeline(); + + this.setColors(); + this.legendOptions = this.getLegendOptions(); + + this.transform = `translate(${this.dims.xOffset} , ${this.margin[0]})`; + + this.clipPathId = 'clip' + id().toString(); + this.clipPath = `url(#${this.clipPathId})`; + } + + getXScale(domain: any[], width: number): any { + let scale; + if (this.scaleType == ScaleType.Time) { + scale = scaleTime().range([0, width]).domain(domain); + } else { + scale = scaleLinear().range([0, width]).domain(domain); + } + + return this.roundDomains ? scale.nice() : scale; + } + + getYScale(domain: any[], height: number, padding: number): any { + const spacing = domain.length / (height / padding + 1); + + return scaleBand().rangeRound([0, height]).paddingInner(spacing).domain(domain); + } + + getXDomain(): any[] { + const values = []; + for (const d of this.results) { + values.push(d.startTime); + values.push(d.endTime); + } + + this.scaleType = getScaleType(values); + + const min = this.xScaleMin ? Math.min(this.xScaleMin, ...values) : Math.min(...values); + const max = this.xScaleMax ? Math.max(this.xScaleMax, ...values) : Math.max(...values); + + if (this.scaleType == ScaleType.Time) { + return [new Date(min), new Date(max)]; + } else { + return [min, max]; + } + } + + getYDomain(): string[] { + return this.results.map(d => d.name); + } + + onClick(data): void { + this.select.emit(data); + } + + setColors(): void { + let domain; + if (this.schemeType === ScaleType.Ordinal) { + domain = this.yDomain; + } else { + domain = this.xDomain; + } + + this.colors = new ColorHelper(this.scheme, this.schemeType, domain, this.customColors); + } + + getLegendOptions(): LegendOptions { + const opts = { + scaleType: this.schemeType as any, + colors: undefined, + domain: [], + title: undefined, + position: this.legendPosition + }; + if (opts.scaleType === 'ordinal') { + opts.domain = this.yDomain; + opts.colors = this.colors; + opts.title = this.legendTitle; + } else { + opts.domain = this.xDomain; + opts.colors = this.colors.scale; + } + + return opts; + } + + updateYAxisWidth({ width }: { width: number }): void { + this.yAxisWidth = width; + this.update(); + } + + updateXAxisHeight({ height }: { height: number }): void { + this.xAxisHeight = height; + this.update(); + } + + updateTimeline(): void { + if (this.timelineFilter) { + this.timelineWidth = this.dims.width; + this.timelineXDomain = this.getXDomain(); + this.timelineXScale = this.getXScale(this.timelineXDomain, this.timelineWidth); + this.timelineYScale = this.getYScale(this.yDomain, this.timelineHeight, this.timelineBarPadding); + this.timelineTransform = `translate(${this.dims.xOffset}, ${0})`; + } + } + + updateDomain(domain): void { + this.filteredDomain = domain; + this.xDomain = this.filteredDomain; + this.xScale = this.getXScale(this.xDomain, this.dims.width); + } + + onActivate(item, fromLegend: boolean = false) { + item = this.results.find(d => { + if (fromLegend) { + return d.label === item.name; + } else { + return d.name === item.name; + } + }); + + const idx = this.activeEntries.findIndex(d => { + return d.name === item.name && d.value === item.value && d.series === item.series; + }); + if (idx > -1) { + return; + } + + this.activeEntries = [item, ...this.activeEntries]; + this.activate.emit({ value: item, entries: this.activeEntries }); + } + + onDeactivate(item, fromLegend: boolean = false) { + item = this.results.find(d => { + if (fromLegend) { + return d.label === item.name; + } else { + return d.name === item.name; + } + }); + + const idx = this.activeEntries.findIndex(d => { + return d.name === item.name && d.value === item.value && d.series === item.series; + }); + + this.activeEntries.splice(idx, 1); + this.activeEntries = [...this.activeEntries]; + + this.deactivate.emit({ value: item, entries: this.activeEntries }); + } +} diff --git a/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-chart.module.ts b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-chart.module.ts new file mode 100644 index 000000000..35bbee750 --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-chart.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { ChartCommonModule } from '../common/chart-common.module'; +import { TimelineChartComponent } from './timeline-chart.component'; +import { TimelineSeriesComponent } from './timeline-series.component'; +import { BarChartModule } from '../bar-chart/bar-chart.module'; +import { TimelineStackedComponent } from './timeline-stacked-chart.component'; +import { TimelineTooltip } from './timeline-tooltip.component'; + +@NgModule({ + imports: [ChartCommonModule, BarChartModule], + declarations: [ + TimelineChartComponent, + TimelineStackedComponent, + TimelineSeriesComponent, + TimelineTooltip + ], + exports: [ + TimelineChartComponent, + TimelineStackedComponent, + TimelineSeriesComponent + ] +}) +export class TimelineChartModule {} diff --git a/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-series.component.ts b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-series.component.ts new file mode 100644 index 000000000..a4d30ee00 --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-series.component.ts @@ -0,0 +1,201 @@ +import { + Component, + Input, + Output, + EventEmitter, + OnChanges, + SimpleChanges, + ChangeDetectionStrategy, + TemplateRef +} from '@angular/core'; +import { trigger, style, animate, transition } from '@angular/animations'; + +import { formatLabel, escapeLabel } from '../common/label.helper'; +import { DataItem, StringOrNumberOrDate } from '../models/chart-data.model'; +import { ColorHelper } from '../common/color.helper'; +import { PlacementTypes } from '../common/tooltip/position'; +import { StyleTypes } from '../common/tooltip/style.type'; +import { TimelineChartType } from './types/timeline-chart-type.enum'; +import { Bar } from '../bar-chart/types/bar.model'; +import { BarOrientation } from '../common/types/bar-orientation.enum'; +import { ScaleType } from '../common/types/scale-type.enum'; + +@Component({ + selector: 'g[ngx-charts-timeline-series]', + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('animationState', [ + transition(':leave', [ + style({ + opacity: 1 + }), + animate(500, style({ opacity: 0 })) + ]) + ]) + ] +}) +export class TimelineSeriesComponent implements OnChanges { + @Input() type: TimelineChartType = TimelineChartType.Standard; + @Input() series: any[]; + @Input() xScale; + @Input() yScale; + @Input() colors: ColorHelper; + @Input() tooltipDisabled: boolean = false; + @Input() gradient: boolean; + @Input() activeEntries: any[]; + @Input() seriesName: StringOrNumberOrDate; + @Input() tooltipTemplate: TemplateRef; + @Input() roundEdges: boolean; + @Input() animations: boolean = true; + @Input() dataLabelFormatting: any; + @Input() noBarWhenZero: boolean = true; + + @Output() select: EventEmitter = new EventEmitter(); + @Output() activate = new EventEmitter(); + @Output() deactivate = new EventEmitter(); + + tooltipPlacement: PlacementTypes; + tooltipType: StyleTypes; + bars: Bar[]; + barsForDataLabels: Array<{ x: number; y: number; width: number; height: number; total: number; series: string }> = []; + + barOrientation = BarOrientation; + + ngOnChanges(changes: SimpleChanges): void { + this.update(); + } + + update(): void { + this.updateTooltipSettings(); + + this.bars = this.series.map(d => { + const label = this.getLabel(d); + const formattedLabel = formatLabel(label); + const roundEdges = this.roundEdges; + const bar: any = { + label, + roundEdges, + data: d, + formattedLabel + }; + + bar.height = this.yScale.bandwidth(); + bar.width = this.xScale(d.endTime) - this.xScale(d.startTime); + bar.x = this.xScale(d.startTime); + if (this.type === TimelineChartType.Standard) { + bar.y = this.yScale(label); + } else { + bar.y = 0 + } + + if (this.colors.scaleType === ScaleType.Ordinal) { + bar.color = this.colors.getColor(label); + } + + let tooltipLabel = formattedLabel; + bar.ariaLabel = formattedLabel + ' ' + d.startTime.toLocaleString() + d.endTime.toLocaleString(); + if (this.seriesName !== null && this.seriesName !== undefined) { + tooltipLabel = `${this.seriesName} • ${formattedLabel}`; + bar.data.series = this.seriesName; + bar.ariaLabel = this.seriesName + ' ' + bar.ariaLabel; + } + + bar.tooltipText = this.tooltipDisabled + ? undefined + : ` + ${escapeLabel(tooltipLabel)} + ${this.dataLabelFormatting ? this.dataLabelFormatting(d.startTime) : formatLabel(d.startTime)} - + ${this.dataLabelFormatting ? this.dataLabelFormatting(d.endTime) : formatLabel(d.endTime)} + `; + + return bar; + }); + + this.updateDataLabels(); + } + + updateDataLabels(): void { + this.barsForDataLabels = this.series.map(d => { + const section: any = {}; + section.series = this.seriesName ?? d.label; + section.total = d.endTime; + section.x = this.xScale(0); + section.y = this.yScale(d.label); + section.width = this.xScale(section.total) - this.xScale(0); + section.height = this.yScale.bandwidth(); + return section; + }); + } + + updateTooltipSettings(): void { + this.tooltipPlacement = this.tooltipDisabled ? undefined : PlacementTypes.Top; + this.tooltipType = this.tooltipDisabled ? undefined : StyleTypes.tooltip; + } + + isActive(entry: any): boolean { + if (!this.activeEntries) return false; + + let item; + if (this.type === TimelineChartType.Standard) { + item = this.activeEntries.find(active => { + return entry.name === active.name && entry.value === active.value; + }); + } else { + item = this.activeEntries.find(active => { + return entry.name === active.name && entry.series === active.series; + }); + } + return item !== undefined; + } + + getLabel(dataItem: DataItem): StringOrNumberOrDate { + if (dataItem.label) { + return dataItem.label; + } + return dataItem.name; + } + + trackBy(index: number, bar: Bar): string { + return bar.label; + } + + trackDataLabelBy(index: number, barLabel: any): string { + return index + '#' + barLabel.series + '#' + barLabel.total; + } + + click(data: DataItem): void { + this.select.emit(data); + } +} diff --git a/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-stacked-chart.component.ts b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-stacked-chart.component.ts new file mode 100644 index 000000000..b3a9eed7b --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-stacked-chart.component.ts @@ -0,0 +1,471 @@ +import { + Component, + Input, + Output, + EventEmitter, + ViewEncapsulation, + ChangeDetectionStrategy, + ContentChild, + TemplateRef, + TrackByFunction +} from '@angular/core'; +import { trigger, style, animate, transition } from '@angular/animations'; + +import { scaleBand, scaleLinear, scaleTime } from 'd3-scale'; + +import { calculateViewDimensions } from '../common/view-dimensions.helper'; +import { ColorHelper } from '../common/color.helper'; +import { Series } from '../models/chart-data.model'; +import { BaseChartComponent } from '../common/base-chart.component'; +import { id } from '../utils/id'; +import { TimelineChartType } from './types/timeline-chart-type.enum'; +import { LegendOptions, LegendPosition } from '../common/types/legend.model'; +import { ScaleType } from '../common/types/scale-type.enum'; +import { ViewDimensions } from '../common/types/view-dimension.interface'; +import { getScaleType } from '../common/domain.helper'; + +@Component({ + selector: 'ngx-charts-timeline-stacked', + template: ` + + + + + + + + + + + + + + + + + + + + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['../common/base-chart.component.scss'], + encapsulation: ViewEncapsulation.None, + animations: [ + trigger('animationState', [ + transition(':leave', [ + style({ + opacity: 1, + transform: '*' + }), + animate(500, style({ opacity: 0, transform: 'scale(0)' })) + ]) + ]) + ] +}) +export class TimelineStackedComponent extends BaseChartComponent { + @Input() legend: boolean = false; + @Input() legendTitle: string = 'Legend'; + @Input() legendPosition: LegendPosition = LegendPosition.Right; + @Input() xAxis; + @Input() yAxis; + @Input() showXAxisLabel: boolean; + @Input() showYAxisLabel: boolean; + @Input() xAxisLabel: string; + @Input() yAxisLabel: string; + @Input() tooltipDisabled: boolean = false; + @Input() tooltipType: string; + @Input() gradient: boolean; + @Input() showGridLines: boolean = true; + @Input() activeEntries: any[] = []; + @Input() schemeType: ScaleType; + @Input() trimXAxisTicks: boolean = true; + @Input() trimYAxisTicks: boolean = true; + @Input() rotateXAxisTicks: boolean = true; + @Input() maxXAxisTickLength: number = 16; + @Input() maxYAxisTickLength: number = 16; + @Input() xAxisTickFormatting: any; + @Input() yAxisTickFormatting: any; + @Input() xAxisTicks: any[]; + @Input() yAxisTicks: any[]; + @Input() barPadding: number = 8; + @Input() roundDomains: boolean = false; + @Input() roundEdges: boolean = true; + @Input() xScaleMax: number; + @Input() dataLabelFormatting: any; + @Input() noBarWhenZero: boolean = true; + @Input() wrapTicks = false; + @Input() timelineFilter: any[]; + + @Output() activate: EventEmitter = new EventEmitter(); + @Output() deactivate: EventEmitter = new EventEmitter(); + + @ContentChild('tooltipTemplate') tooltipTemplate: TemplateRef; + @ContentChild('seriesTooltipTemplate') seriesTooltipTemplate: TemplateRef; + + dims: ViewDimensions; + groupDomain: string[]; + innerDomain: string[]; + valueDomain: any[]; + xScale: any; + yScale: any; + transform: string; + clipPath: string; + clipPathId: string; + colors: ColorHelper; + margin = [10, 20, 10, 20]; + xAxisHeight: number = 0; + yAxisWidth: number = 0; + legendOptions: LegendOptions; + dataLabelMaxWidth: any = { negative: 0, positive: 0 }; + scaleType: ScaleType; + timelineWidth: any; + timelineHeight: number = 50; + timelineXScale: any; + timelineYScale: any; + timelineXDomain: any; + timelineTransform: any; + timelinePadding: number = 10; + timelineBarPadding: number = 1; + filteredDomain: any; + + TimelineChartType = TimelineChartType; + + update(): void { + super.update(); + + this.margin = [10, 20 + this.dataLabelMaxWidth.positive, 10, 20 + this.dataLabelMaxWidth.negative]; + + this.dims = calculateViewDimensions({ + width: this.width, + height: this.height, + margins: this.margin, + showXAxis: this.xAxis, + showYAxis: this.yAxis, + xAxisHeight: this.xAxisHeight, + yAxisWidth: this.yAxisWidth, + showXLabel: this.showXAxisLabel, + showYLabel: this.showYAxisLabel, + showLegend: this.legend, + legendType: this.schemeType, + legendPosition: this.legendPosition + }); + + if (this.timelineFilter) { + this.dims.height -= this.timelineHeight + this.margin[2] + this.timelinePadding; + } + + this.formatDates(); + + this.groupDomain = this.getGroupDomain(); + this.innerDomain = this.getInnerDomain(); + this.valueDomain = this.getValueDomain(); + if (this.filteredDomain) { + this.valueDomain = this.filteredDomain; + } + + this.xScale = this.getXScale(this.valueDomain, this.dims.width); + this.yScale = this.getYScale(this.groupDomain, this.dims.height, this.barPadding); + + this.updateTimeline(); + + this.setColors(); + this.legendOptions = this.getLegendOptions(); + + this.transform = `translate(${this.dims.xOffset} , ${this.margin[0]})`; + + this.clipPathId = 'clip' + id().toString(); + this.clipPath = `url(#${this.clipPathId})`; + } + + getGroupDomain(): string[] { + const domain = []; + + for (const group of this.results) { + if (!domain.includes(group.name)) { + domain.push(group.name); + } + } + + return domain; + } + + getInnerDomain(): string[] { + const domain = []; + + for (const group of this.results) { + for (const d of group.series) { + if (!domain.includes(d.name)) { + domain.push(d.name); + } + } + } + + return domain; + } + + getValueDomain(): any[] { + const values = []; + for (const group of this.results) { + for (const d of group.series) { + values.push(d.startTime); + values.push(d.endTime); + } + } + + this.scaleType = getScaleType(values); + + const min = Math.min(...values); + const max = this.xScaleMax ? Math.max(this.xScaleMax, ...values) : Math.max(...values); + + if (this.scaleType == ScaleType.Time) { + return [new Date(min), new Date(max)]; + } else { + return [min, max]; + } + } + + getYScale(domain: any[], height: number, padding: number): any { + const spacing = domain.length / (height / padding + 1); + + return scaleBand().rangeRound([0, height]).paddingInner(spacing).domain(domain); + } + + getXScale(domain: any[], width: number): any { + let scale; + if (this.scaleType == ScaleType.Time) { + scale = scaleTime().range([0, width]).domain(domain); + } else { + scale = scaleLinear().range([0, width]).domain(domain); + } + + return this.roundDomains ? scale.nice() : scale; + } + + groupTransform(group: Series): string { + return `translate(0, ${this.yScale(group.name)})`; + } + + timelineGroupTransform(group: Series): string { + return `translate(0, ${this.timelineYScale(group.name)})`; + } + + onClick(data, group?: Series): void { + if (group) { + data.series = group.name; + } + + this.select.emit(data); + } + + trackBy: TrackByFunction = (index: number, item: Series) => { + return item.name; + }; + + setColors(): void { + let domain; + if (this.schemeType === ScaleType.Ordinal) { + domain = this.innerDomain; + } else { + domain = this.valueDomain; + } + + this.colors = new ColorHelper(this.scheme, this.schemeType, domain, this.customColors); + } + + getLegendOptions(): LegendOptions { + const opts = { + scaleType: this.schemeType as any, + colors: undefined, + domain: [], + title: undefined, + position: this.legendPosition + }; + if (opts.scaleType === ScaleType.Ordinal) { + opts.domain = this.innerDomain; + opts.colors = this.colors; + opts.title = this.legendTitle; + } else { + opts.domain = this.valueDomain; + opts.colors = this.colors.scale; + } + + return opts; + } + + updateYAxisWidth({ width }: { width: number }): void { + this.yAxisWidth = width; + this.update(); + } + + updateXAxisHeight({ height }: { height: number }): void { + this.xAxisHeight = height; + this.update(); + } + + updateTimeline(): void { + if (this.timelineFilter) { + this.timelineWidth = this.dims.width; + this.timelineXDomain = this.getValueDomain(); + this.timelineXScale = this.getXScale(this.timelineXDomain, this.timelineWidth); + this.timelineYScale = this.getYScale(this.groupDomain, this.timelineHeight, this.timelineBarPadding); + this.timelineTransform = `translate(${this.dims.xOffset}, ${0})`; + } + } + + updateDomain(domain): void { + this.filteredDomain = domain; + this.valueDomain = this.filteredDomain; + this.xScale = this.getXScale(this.valueDomain, this.dims.width); + } + + onActivate(event, group: Series, fromLegend: boolean = false) { + const item = Object.assign({}, event); + if (group) { + item.series = group.name; + } + + const items = this.results + .map(g => g.series) + .flat() + .filter(i => { + if (fromLegend) { + return i.label === item.name; + } else { + return i.name === item.name && i.series === item.series; + } + }); + + this.activeEntries = [...items]; + this.activate.emit({ value: item, entries: this.activeEntries }); + } + + onDeactivate(event, group: Series, fromLegend: boolean = false) { + const item = Object.assign({}, event); + if (group) { + item.series = group.name; + } + + this.activeEntries = this.activeEntries.filter(i => { + if (fromLegend) { + return i.label !== item.name; + } else { + return !(i.name === item.name && i.series === item.series); + } + }); + + this.deactivate.emit({ value: item, entries: this.activeEntries }); + } +} diff --git a/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-tooltip.component.ts b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-tooltip.component.ts new file mode 100644 index 000000000..aaaf29806 --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/timeline-chart/timeline-tooltip.component.ts @@ -0,0 +1,247 @@ +import { + Component, + Input, + Output, + EventEmitter, + ViewChild, + ChangeDetectionStrategy, + TemplateRef, + PLATFORM_ID, + Inject +} from '@angular/core'; +import { trigger, style, animate, transition } from '@angular/animations'; +import { isPlatformBrowser } from '@angular/common'; + +import { createMouseEvent } from '../events'; +import { formatLabel } from '../common/label.helper'; +import { ColorHelper } from '../common/color.helper'; +import { PlacementTypes } from '../common/tooltip/position'; +import { StyleTypes } from '../common/tooltip/style.type'; +import { ViewDimensions } from '../common/types/view-dimension.interface'; +import { TimelineChartType } from './types/timeline-chart-type.enum'; + +export interface Tooltip { + color: string; + d0: number; + d1: number; + max: number; + min: number; + name: any; + series: any; + value: any; +} + +@Component({ + selector: 'g[ngx-charts-timeline-tooltip]', + template: ` + + + + + + + {{ getToolTipText(tooltipItem) }} + + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [ + trigger('animationState', [ + transition('inactive => active', [ + style({ + opacity: 0 + }), + animate(250, style({ opacity: 0.7 })) + ]), + transition('active => inactive', [ + style({ + opacity: 0.7 + }), + animate(250, style({ opacity: 0 })) + ]) + ]) + ] +}) +export class TimelineTooltip { + anchorOpacity: number = 0; + anchorPos: number = -1; + anchorValues: Tooltip[] = []; + lastAnchorPos: number; + + placementTypes = PlacementTypes; + styleTypes = StyleTypes; + tooltip = [ + { + 'series': 'tooltip' + } + ]; + + @Input() type: TimelineChartType = TimelineChartType.Standard; + @Input() dims: ViewDimensions; + @Input() xScale; + @Input() yScale; + @Input() results: any[]; + @Input() colors: ColorHelper; + @Input() tooltipDisabled: boolean = false; + @Input() tooltipTemplate: TemplateRef; + + @Output() hover: EventEmitter<{ value: any }> = new EventEmitter(); + + @ViewChild('tooltipAnchor', { static: false }) tooltipAnchor; + + constructor(@Inject(PLATFORM_ID) private platformId: any) {} + + getValues(xPos): Tooltip[] { + const results = []; + + if (this.type === TimelineChartType.Standard) { + for (const d of this.results) { + const xPosStart = this.xScale(d.startTime); + const xPosEnd = this.xScale(d.endTime); + + let dName = d.name; + if (dName instanceof Date) { + dName = dName.toLocaleDateString(); + } + + if (xPos >= xPosStart && xPos <= xPosEnd) { + let val = formatLabel(d.startTime) + ' - ' + formatLabel(d.endTime); + + const data = Object.assign({}, d, { + value: val, + series: dName, + color: this.colors.getColor(d.name) + }); + + results.push(data); + } + } + } else { + for (const group of this.results) { + let groupName = group.name; + if (groupName instanceof Date) { + groupName = groupName.toLocaleDateString(); + } + + for (const d of group.series) { + const xPosStart = this.xScale(d.startTime); + const xPosEnd = this.xScale(d.endTime); + + let dName = d.name; + if (dName instanceof Date) { + dName = dName.toLocaleDateString(); + } + + if (xPos >= xPosStart && xPos <= xPosEnd) { + let val = formatLabel(d.startTime) + ' - ' + formatLabel(d.endTime); + + const data = Object.assign({}, d, { + value: val, + series: groupName + ' • ' + dName, + color: this.colors.getColor(d.name) + }); + + results.push(data); + } + } + } + } + + return results; + } + + mouseMove(event) { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const xPos = event.pageX - event.target.getBoundingClientRect().left; + this.anchorPos = xPos + this.anchorPos = Math.max(0, this.anchorPos); + this.anchorPos = Math.min(this.dims.width, this.anchorPos); + + this.anchorValues = this.getValues(xPos); + if (this.anchorPos !== this.lastAnchorPos) { + const ev = createMouseEvent('mouseleave'); + this.tooltipAnchor.nativeElement.dispatchEvent(ev); + this.anchorOpacity = 0.7; + this.showTooltip(); + + this.lastAnchorPos = this.anchorPos; + } + } + + showTooltip(): void { + const event = createMouseEvent('mouseenter'); + this.tooltipAnchor.nativeElement.dispatchEvent(event); + } + + hideTooltip(): void { + const event = createMouseEvent('mouseleave'); + this.tooltipAnchor.nativeElement.dispatchEvent(event); + this.anchorOpacity = 0; + this.lastAnchorPos = -1; + } + + getToolTipText(tooltipItem: Tooltip): string { + let result: string = ''; + if (tooltipItem.series !== undefined) { + result += tooltipItem.series; + } else { + result += '???'; + } + result += ': '; + if (tooltipItem.value !== undefined) { + result += tooltipItem.value.toLocaleString(); + } + if (tooltipItem.min !== undefined || tooltipItem.max !== undefined) { + result += ' ('; + if (tooltipItem.min !== undefined) { + if (tooltipItem.max === undefined) { + result += '≥'; + } + result += tooltipItem.min.toLocaleString(); + if (tooltipItem.max !== undefined) { + result += ' - '; + } + } else if (tooltipItem.max !== undefined) { + result += '≤'; + } + if (tooltipItem.max !== undefined) { + result += tooltipItem.max.toLocaleString(); + } + result += ')'; + } + return result; + } +} diff --git a/projects/swimlane/ngx-charts/src/lib/timeline-chart/types/timeline-chart-type.enum.ts b/projects/swimlane/ngx-charts/src/lib/timeline-chart/types/timeline-chart-type.enum.ts new file mode 100644 index 000000000..ffab59bdf --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/timeline-chart/types/timeline-chart-type.enum.ts @@ -0,0 +1,4 @@ +export enum TimelineChartType { + Standard = 'standard', + Stacked = 'stacked' +} diff --git a/src/app/app.component.html b/src/app/app.component.html index a68efd1e8..1a80a920e 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -417,6 +417,83 @@ (deactivate)="deactivate($event)" > + + + +
{{ barChart | json }}
{{ lineChartSeries | json }}
{{ timelineFilterBarData | json }}
+
{{ timelineStandardData | json }}
+
{{ timelineStackedData | json }}
+
+
+ +