diff --git a/package-lock.json b/package-lock.json index 9e6134791..98c01d8ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "d3-format": "^3.1.0", "d3-hierarchy": "^3.1.0", "d3-interpolate": "^3.0.1", + "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", @@ -6713,6 +6714,41 @@ "node": ">=12" } }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -21738,6 +21774,43 @@ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==" }, + "d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "requires": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + }, + "dependencies": { + "d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "requires": { + "internmap": "^1.0.0" + } + }, + "d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "requires": { + "d3-path": "1" + } + }, + "internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + } + } + }, "d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", diff --git a/package.json b/package.json index 140b8ff56..0e195056e 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "d3-format": "^3.1.0", "d3-hierarchy": "^3.1.0", "d3-interpolate": "^3.0.1", + "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", diff --git a/projects/swimlane/ngx-charts/package.json b/projects/swimlane/ngx-charts/package.json index 0de094176..087a3f1c0 100644 --- a/projects/swimlane/ngx-charts/package.json +++ b/projects/swimlane/ngx-charts/package.json @@ -52,6 +52,7 @@ "d3-format": "^3.1.0", "d3-hierarchy": "^3.1.0", "d3-interpolate": "^3.0.1", + "d3-sankey": "^0.12.3", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.2.0", diff --git a/projects/swimlane/ngx-charts/src/lib/common/base-chart.component.scss b/projects/swimlane/ngx-charts/src/lib/common/base-chart.component.scss index 372e65197..36f81dc64 100644 --- a/projects/swimlane/ngx-charts/src/lib/common/base-chart.component.scss +++ b/projects/swimlane/ngx-charts/src/lib/common/base-chart.component.scss @@ -21,6 +21,8 @@ .circle, .cell, .bar, + .node, + .link, .arc { cursor: pointer; } @@ -28,6 +30,8 @@ .bar, .cell, .arc, + .node, + .link, .card { &.active, &:hover { 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 9b192809c..1fb86981b 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 @@ -184,9 +184,11 @@ export class BaseChartComponent implements OnChanges, AfterViewInit, OnDestroy, const results = []; for (const item of data) { - const copy = { - name: item['name'] - }; + const copy = {}; + + if (item['name'] !== undefined) { + copy['name'] = item['name']; + } if (item['value'] !== undefined) { copy['value'] = item['value']; @@ -204,6 +206,14 @@ export class BaseChartComponent implements OnChanges, AfterViewInit, OnDestroy, copy['extra'] = JSON.parse(JSON.stringify(item['extra'])); } + if (item['source'] !== undefined) { + copy['source'] = item['source']; + } + + if (item['target'] !== undefined) { + copy['target'] = item['target']; + } + results.push(copy); } 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 d0e7a0df1..0b84fd626 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 @@ -70,6 +70,14 @@ export interface TreeMapDataItem { export interface TreeMapData extends Array {} +export interface SankeyObject { + source: string; + target: string; + value: number; +} + +export interface SankeyData extends Array {} + export interface BoxChartSeries { name: StringOrNumberOrDate; series: DataItem[]; 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 db3d21be7..32543706c 100644 --- a/projects/swimlane/ngx-charts/src/lib/ngx-charts.module.ts +++ b/projects/swimlane/ngx-charts/src/lib/ngx-charts.module.ts @@ -12,6 +12,7 @@ import { PieChartModule } from './pie-chart/pie-chart.module'; import { TreeMapModule } from './tree-map/tree-map.module'; import { GaugeModule } from './gauge/gauge.module'; import { ngxChartsPolyfills } from './polyfills'; +import { SankeyModule } from './sankey/sankey.module'; @NgModule({ exports: [ @@ -21,6 +22,7 @@ import { ngxChartsPolyfills } from './polyfills'; BoxChartModule, BubbleChartModule, HeatMapModule, + SankeyModule, LineChartModule, PolarChartModule, NumberCardModule, diff --git a/projects/swimlane/ngx-charts/src/lib/sankey/sankey.component.spec.ts b/projects/swimlane/ngx-charts/src/lib/sankey/sankey.component.spec.ts new file mode 100644 index 000000000..3a92a7c73 --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/sankey/sankey.component.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { APP_BASE_HREF } from '@angular/common'; + +import { SankeyModule } from './sankey.module'; + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; + +@Component({ + selector: 'test-component', + template: '' +}) +class TestComponent { + colorScheme = { + domain: ['#5AA454', '#A10A28', '#C7B42C', '#AAAAAA'] + }; +} + +describe('', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestComponent], + imports: [NoopAnimationsModule, SankeyModule], + providers: [{ provide: APP_BASE_HREF, useValue: '/' }] + }); + }); + + describe('basic setup', () => { + beforeEach(() => { + TestBed.overrideComponent(TestComponent, { + set: { + template: ` + + ` + } + }).compileComponents(); + }); + + it('should set the svg width and height', () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + const svg = fixture.debugElement.nativeElement.querySelector('svg'); + + expect(svg.getAttribute('width')).toBe('400'); + expect(svg.getAttribute('height')).toBe('800'); + }); + }); +}); diff --git a/projects/swimlane/ngx-charts/src/lib/sankey/sankey.component.ts b/projects/swimlane/ngx-charts/src/lib/sankey/sankey.component.ts new file mode 100644 index 000000000..11f15d2a1 --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/sankey/sankey.component.ts @@ -0,0 +1,256 @@ +import { + Component, + Input, + ViewEncapsulation, + ChangeDetectionStrategy, + ContentChild, + TemplateRef, + Output, + EventEmitter +} from '@angular/core'; +import { sankey, sankeyLeft, sankeyLinkHorizontal } from 'd3-sankey'; + +import { BaseChartComponent } from '../common/base-chart.component'; +import { calculateViewDimensions } from '../common/view-dimensions.helper'; +import { ColorHelper } from '../common/color.helper'; +import { ViewDimensions } from '../common/types/view-dimension.interface'; +import { ScaleType } from '../common/types/scale-type.enum'; +import { StyleTypes } from '../common/tooltip/style.type'; +import { escapeLabel } from '../common/label.helper'; +import { id } from '../utils/id'; +import { TextAnchor } from '../common/types/text-anchor.enum'; + +interface RectItem { + fill: string; + height: number; + rx: number; + width: number; + x: number; + y: number; + label: string; + labelAnchor: string; + tooltip: string; + transform: string; + data: any; +} + +@Component({ + selector: 'ngx-charts-sankey', + template: ` + + + + + + + + + + + + + + + + + + + {{ rect.label }} + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['../common/base-chart.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SankeyComponent extends BaseChartComponent { + @Input() showLabels: boolean = true; + @Input() gradient: boolean; + @Input() tooltipDisabled: boolean = false; + @Input() activeEntries: any[] = []; + @Input() labelFormatting: any; + + @Output() activate: EventEmitter = new EventEmitter(); + @Output() deactivate: EventEmitter = new EventEmitter(); + + @ContentChild('tooltipTemplate') tooltipTemplate: TemplateRef; + + dims: ViewDimensions; + colors: ColorHelper; + colorScale: any; + transform: string; + margin: number[] = [10, 10, 10, 10]; + scaleType: ScaleType = ScaleType.Ordinal; + valueDomain: any[]; + styleTypes = StyleTypes; + + nodeRects: RectItem[]; + linkPaths: any[]; + + update(): void { + super.update(); + + this.dims = calculateViewDimensions({ + width: this.width, + height: this.height, + margins: this.margin, + legendType: this.scaleType as any + }); + + const linkDefs = this.results; + const nodeDefs = Array.from(new Set(linkDefs.flatMap(l => [l.source, l.target])), (name: string) => ({ + name, + value: linkDefs.filter(l => l.source === name).reduce((acc, l) => acc + l.value, 0) + })); + + // Configure generator + const sankeyGenerator = sankey() + .nodeId(d => d.name) + .nodeAlign(sankeyLeft) + .nodeWidth(15) + .nodePadding(10) + .extent([ + [1, 5], + [this.dims.width - 1, this.dims.height - 5] + ]); + + // Generate links and nodes + const data = sankeyGenerator({ + nodes: nodeDefs.map(d => Object.assign({}, d)), + links: linkDefs.map(d => Object.assign({}, d)) + }); + + this.valueDomain = this.getValueDomain(data.nodes); + this.setColors(); + + this.nodeRects = data.nodes.map(node => { + const rect: RectItem = { + x: node.x0, + y: node.y0, + height: node.y1 - node.y0, + width: node.x1 - node.x0, + fill: this.colors.getColor(node.name), + tooltip: this.getNodeTooltipText(node), + rx: 5, + data: { + name: node.name, + value: node.value + }, + transform: '', + label: this.labelFormatting ? this.labelFormatting(node.name) : node.name, + labelAnchor: TextAnchor.Start + }; + rect.labelAnchor = this.getTextAnchor(node); + rect.transform = `translate(${rect.x},${rect.y})`; + return rect; + }); + + this.linkPaths = data.links.map(link => { + const gradientId = 'mask' + id().toString(); + const linkPath = { + path: sankeyLinkHorizontal()(link), + strokeWidth: Math.max(1, link.width), + tooltip: this.getLinkTooltipText(link.source, link.target, link.value), + id: gradientId, + gradientFill: `url(#${gradientId})`, + source: link.source, + target: link.target, + startColor: this.colors.getColor(link.source.name), + endColor: this.colors.getColor(link.target.name), + data: { + source: link.source.name, + target: link.target.name, + value: link.value + } + }; + return linkPath; + }); + + this.transform = `translate(${this.dims.xOffset} , ${this.margin[0]})`; + } + + getNodeTooltipText(node): string { + return ` + ${escapeLabel(node.name)} + ${node.value.toLocaleString()} + `; + } + + getLinkTooltipText(sourceNode, targetNode, value: number): string { + return ` + ${escapeLabel(sourceNode.name)} • ${escapeLabel(targetNode.name)} + ${value.toLocaleString()} (${(value / sourceNode.value).toLocaleString(undefined, { + style: 'percent', + maximumFractionDigits: 2 + })}) + `; + } + + getTextAnchor(node): TextAnchor { + if (node.layer === 0) { + return TextAnchor.Start; + } else { + return TextAnchor.End; + } + } + + onClick(data): void { + this.select.emit(data); + } + + setColors(): void { + this.colors = new ColorHelper(this.scheme, this.scaleType, this.valueDomain); + } + + getValueDomain(nodes): any[] { + return nodes.map(n => n.name); + } +} diff --git a/projects/swimlane/ngx-charts/src/lib/sankey/sankey.module.ts b/projects/swimlane/ngx-charts/src/lib/sankey/sankey.module.ts new file mode 100644 index 000000000..d67faab2b --- /dev/null +++ b/projects/swimlane/ngx-charts/src/lib/sankey/sankey.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core'; +import { ChartCommonModule } from '../common/chart-common.module'; +import { SankeyComponent } from './sankey.component'; + +@NgModule({ + imports: [ChartCommonModule], + declarations: [SankeyComponent], + exports: [SankeyComponent] +}) +export class SankeyModule {} diff --git a/projects/swimlane/ngx-charts/src/public-api.ts b/projects/swimlane/ngx-charts/src/public-api.ts index b9b4d359a..f310d82d9 100644 --- a/projects/swimlane/ngx-charts/src/public-api.ts +++ b/projects/swimlane/ngx-charts/src/public-api.ts @@ -100,6 +100,9 @@ export * from './lib/heat-map/heat-map.component'; export * from './lib/heat-map/heat-map-cell.component'; export * from './lib/heat-map/heat-map-cell-series.component'; +export * from './lib/sankey/sankey.module'; +export * from './lib/sankey/sankey.component'; + export * from './lib/line-chart/line-chart.module'; export * from './lib/line-chart/line-chart.component'; export * from './lib/line-chart/line.component'; diff --git a/src/app/app.component.html b/src/app/app.component.html index 6dd4b53cc..a68efd1e8 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -712,6 +712,22 @@ (deactivate)="deactivate($event)" > + +