UNPKG

mushonkey

Version:

Simple central node Sankey designed by @mushon. openbudget.github.io/mushonkey

346 lines (340 loc) 15.2 kB
import * as i0 from '@angular/core'; import { EventEmitter, Component, Input, Output, NgModule } from '@angular/core'; import { select } from 'd3-selection'; import { max } from 'd3-array'; import { scaleLinear } from 'd3-scale'; import { path } from 'd3-path'; const debug = false; class MushonKeyFlow { constructor(size, label, context) { this.size = size; this.label = label; this.context = context; } } class MushonKeyFlowGroup { constructor(leftSide, flows, klass, offset, width, slope, roundness, headLength, tailLength, labelTextSize) { this.leftSide = leftSide; this.flows = flows; this.klass = klass; this.labelTextSize = labelTextSize || 20; this.offset = offset || 200; this.width = width || 1.0; this.slope = slope || 1.0; this.roundness = roundness || 9; this.headLength = headLength || 0.25; this.tailLength = tailLength || 0.25; for (let flow of flows) { flow.group = this; } } } class MushonKeyChart { constructor(groups, centerText, centerWidth, centerHeight, centerDirectionLeft, margin, centerVerticalOffset) { this.groups = groups; this.centerText = centerText || ''; this.centerHeight = centerHeight || 150; this.centerWidth = centerWidth || 200; this.centerDirectionLeft = centerDirectionLeft == null ? true : centerDirectionLeft; this.margin = margin || { top: 20, bottom: 20, left: 20, right: 20 }; this.centerVerticalOffset = centerVerticalOffset || 0; } } class MushonkeyComponent { constructor(el) { this.el = el; this.onSelected = new EventEmitter(); this.layout = []; this.textIndent = 30; this.minSideHeight = 20; this.sideSeparation = 30; this.centerSeparation = 1; } update() { if (!this.el?.nativeElement) { return; } if (this.chart) { if (!this.d3Chart) { this.createChart(); } if (this.d3Chart) { this.updateChart(); } } } ngOnInit() { this.update(); } ngOnChanges() { this.update(); } ngAfterViewInit() { this.update(); } createChart() { const element = this.el.nativeElement; this.width = element.offsetWidth - this.chart.margin.left - this.chart.margin.right; this.height = element.offsetHeight - this.chart.margin.top - this.chart.margin.bottom; this.connectorWidth = this.width / 2; const svg = select(element).append('svg') .attr('width', element.offsetWidth) .attr('height', element.offsetHeight); // chart plot area this.d3Chart = svg.append('g') .attr('class', 'mushonkey') .attr('transform', `translate(${this.chart.margin.left}, ${this.chart.margin.top})`); this.d3Chart.append('g') .attr('class', 'connectors'); this.d3Chart.append('g') .attr('class', 'centerpiece-container'); this.d3Chart.append('g') .attr('class', 'labels'); } processData() { let leftSum = 0, rightSum = 0, leftCount = 0, rightCount = 0; let groups = this.chart.groups; for (let group of groups) { group.widthPx = group.width * this.connectorWidth; group.widthPx -= this.chart.centerWidth / 2; group.headLengthPx = group.headLength * group.widthPx; group.tailLengthPx = group.tailLength * group.widthPx; for (let flow of group.flows) { if (group.leftSide) { leftCount += 1; leftSum += flow.size; } else { rightCount += 1; rightSum += flow.size; } } } if (leftSum == 0) { leftSum = 1; } if (rightSum == 0) { rightSum = 1; } if (leftCount == 0) { leftCount = 1; } if (rightCount == 0) { rightCount = 1; } this.centerScaleLeft = scaleLinear().domain([0, leftSum]).range([0, this.chart.centerHeight - this.centerSeparation * (leftCount - 1)]); this.centerScaleRight = scaleLinear().domain([0, rightSum]).range([0, this.chart.centerHeight - this.centerSeparation * (rightCount - 1)]); if (this.chart.centerVerticalOffset) { this.centerOfs = this.chart.centerVerticalOffset; } else { if (this.chart.groups.length > 0) { this.centerOfs = max(this.chart.groups, g => -g.offset); } else { this.centerOfs = 0; } } this.centerOfs = this.centerOfs < 0 ? 0 : this.centerOfs; this.center = this.centerOfs + this.chart.centerHeight / 2; let leftOfs = { centerOfs: this.centerOfs, sideOfs: 0 }; let rightOfs = { centerOfs: this.centerOfs, sideOfs: 0 }; this.layout = []; for (let group of groups) { let ofs = group.leftSide ? leftOfs : rightOfs; ofs.sideOfs = this.center + group.offset; for (let flow of group.flows) { let scaledWidth; if (group.leftSide) { scaledWidth = this.centerScaleLeft(flow.size); } else { scaledWidth = this.centerScaleRight(flow.size); } flow.scaledSize = scaledWidth; flow.centerOfs = ofs.centerOfs + scaledWidth / 2; flow.sideOfs = ofs.sideOfs + scaledWidth / 2; this.layout.push(flow); ofs.centerOfs += scaledWidth + this.centerSeparation; ofs.sideOfs += max([scaledWidth + this.sideSeparation, this.minSideHeight]); } } } generatePath(d) { let p = path(); let diffY = d.sideOfs - d.centerOfs, down = diffY > 0 ? 1 : -1, r = d.group.headLengthPx / Math.tan(Math.atan(1 / d.group.slope) / 2), refy = down == 1 ? this.centerOfs : this.centerOfs + this.chart.centerHeight, ry = refy + down * r, diffXtop = Math.abs(d.group.slope * diffY), head = d.group.headLengthPx * Math.abs(ry - d.centerOfs) / r; if (diffXtop > d.group.widthPx - d.group.tailLengthPx - head) { diffXtop = d.group.widthPx - d.group.tailLengthPx - head; } let right = d.group.leftSide ? -1 : 1, slope = Math.abs(diffXtop / diffY), x0 = this.connectorWidth, x1 = x0 + right * this.chart.centerWidth / 2, x2 = x1 + right * head, x3 = x2 + right * diffXtop, x4 = x1 + right * d.group.widthPx, y1 = d.centerOfs, y2 = d.sideOfs, ta = (Math.atan(-down / slope) - Math.PI / 2), c1y = down == 1 ? ((this.centerOfs + this.chart.centerHeight) * d.group.roundness + ry) / (d.group.roundness + 1) : ((this.centerOfs * d.group.roundness) + ry) / (d.group.roundness + 1), c1x = x1 + right * Math.abs(c1y - ry) / r * d.group.headLengthPx, r1 = Math.abs(c1y - y1), r2 = r1, c2x = x3 - (c1x - x2), c2y = y2 - down * r2; const lw = 0; //.5; p.moveTo(x0, y1); if (debug) { p.lineTo(x2, y1); // p.lineTo(x1, ry); // p.lineTo(x2, y1); p.lineTo(c1x, c1y); p.lineTo(x2, y1); p.lineTo(x1, y1); } if (right == 1) { if (down == 1) { p.arc(c1x, c1y, r1, 3 * Math.PI / 2, -ta + Math.PI, false); p.arc(c2x, c2y, r2, -ta, Math.PI / 2, true); } else { p.arc(c1x, c1y, r1, Math.PI / 2, -ta, true); p.arc(c2x, c2y, r2, -ta - Math.PI, -Math.PI / 2, false); } } else { if (down == 1) { p.arc(c1x, c1y, r1, -Math.PI / 2, ta, true); p.arc(c2x, c2y, r2, ta + Math.PI, Math.PI / 2, false); } else { p.arc(c1x, c1y, r1, Math.PI / 2, ta + Math.PI, false); p.arc(c2x, c2y, r2, ta, -Math.PI / 2, true); } } if (debug) { p.lineTo(x3, y2); p.lineTo(c2x, c2y); p.lineTo(x3, y2); } p.lineTo(x4, y2); return p.toString(); } updateChart(chart) { if (chart) { this.chart = chart; } this.processData(); let connectorsUpdate = this.d3Chart.select('g.connectors').selectAll('.connector') .data(this.layout, (d) => d.label); let labelsUpdate = this.d3Chart.select('g.labels').selectAll('.text') .data(this.layout, (d) => d.label); // remove exiting connectors connectorsUpdate.exit().remove(); labelsUpdate.exit().remove(); // add new bars connectorsUpdate.enter() .append('path') .attr('class', 'connector') .style('fill', 'none'); labelsUpdate.enter() .append('text') .attr('class', 'text') .style('paint-order', 'stroke') .style('stroke-linejoin', 'round') .style('alignment-baseline', 'middle') .on('click', (event, d) => { // console.log('MushonKey clicked', d.context); if (d.context) { this.onSelected.emit(d.context); } }); connectorsUpdate = this.d3Chart.select('g.connectors').selectAll('.connector') .data(this.layout, (d) => d.label); labelsUpdate = this.d3Chart.select('g.labels').selectAll('.text') .data(this.layout, (d) => d.label); connectorsUpdate .attr('d', (d) => this.generatePath(d)) .attr('class', (d) => 'connector ' + d.group.klass) .style('stroke-width', (d) => debug ? 1 : d.scaledSize + 0.5); labelsUpdate .attr('y', (d) => d.sideOfs) .attr('x', (d) => d.group.leftSide ? this.connectorWidth - this.chart.centerWidth / 2 - d.group.widthPx + this.textIndent : this.connectorWidth + this.chart.centerWidth / 2 + d.group.widthPx - this.textIndent) .attr('class', (d) => 'text ' + d.group.klass) .style('cursor', (d) => d.context ? 'pointer' : 'inherit') .style('text-anchor', (d) => d.group.leftSide ? 'start' : 'end') .style('fill', (d) => d.group.textColor) .style('font-size', (d) => d.group.labelTextSize) .style('stroke', (d) => d.scaledSize < d.group.labelTextSize ? 'white' : 'none') .style('stroke-width', (d) => d.group.labelTextSize / 2) .text((d) => { return d.label; }); this.drawCenter(); } drawCenter() { let arrowOfs = this.chart.centerDirectionLeft ? -this.chart.centerHeight / 3 : this.chart.centerHeight / 3; let centerPath = path(); centerPath.moveTo(this.connectorWidth - this.chart.centerWidth / 2, this.centerOfs); centerPath.lineTo(this.connectorWidth + this.chart.centerWidth / 2, this.centerOfs); centerPath.lineTo(this.connectorWidth + this.chart.centerWidth / 2 + arrowOfs, this.centerOfs + this.chart.centerHeight / 2); centerPath.lineTo(this.connectorWidth + this.chart.centerWidth / 2, this.centerOfs + this.chart.centerHeight); centerPath.lineTo(this.connectorWidth - this.chart.centerWidth / 2, this.centerOfs + this.chart.centerHeight); centerPath.lineTo(this.connectorWidth - this.chart.centerWidth / 2 + arrowOfs, this.centerOfs + this.chart.centerHeight / 2); centerPath.closePath(); let center = this.d3Chart.select('g.centerpiece-container'); this.d3Chart.select('g.centerpiece-container') .selectAll('path') .data([1]) .enter() .append('path') .attr('class', 'centerpiece'); this.d3Chart.select('g.centerpiece-container') .selectAll('path') .data([1]) .attr('d', centerPath.toString()); this.d3Chart.select('g.centerpiece-container') .selectAll('text') .data([1]) .enter() .append('text') .attr('class', 'centerpiece-text') .style('alignment-baseline', 'middle') .style('text-anchor', 'middle'); this.d3Chart.select('g.centerpiece-container') .selectAll('text') .data([1]) .text(this.chart.centerText) .attr('x', this.connectorWidth + arrowOfs) .attr('y', this.centerOfs + this.chart.centerHeight / 2); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.1", ngImport: i0, type: MushonkeyComponent, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "19.1.1", type: MushonkeyComponent, isStandalone: false, selector: "mushonkey", inputs: { chart: "chart" }, outputs: { onSelected: "onSelected" }, usesOnChanges: true, ngImport: i0, template: ``, isInline: true, styles: [":host{width:100%;height:100%;display:block}\n"] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.1", ngImport: i0, type: MushonkeyComponent, decorators: [{ type: Component, args: [{ selector: 'mushonkey', template: ``, standalone: false, styles: [":host{width:100%;height:100%;display:block}\n"] }] }], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { chart: [{ type: Input, args: ['chart'] }], onSelected: [{ type: Output }] } }); class MushonkeyModule { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.1", ngImport: i0, type: MushonkeyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.1.1", ngImport: i0, type: MushonkeyModule, declarations: [MushonkeyComponent], exports: [MushonkeyComponent] }); } static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.1.1", ngImport: i0, type: MushonkeyModule }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.1", ngImport: i0, type: MushonkeyModule, decorators: [{ type: NgModule, args: [{ declarations: [ MushonkeyComponent ], imports: [], exports: [ MushonkeyComponent ] }] }] }); /* * Public API Surface of mushonkey */ /** * Generated bundle index. Do not edit. */ export { MushonKeyChart, MushonKeyFlow, MushonKeyFlowGroup, MushonkeyComponent, MushonkeyModule }; //# sourceMappingURL=mushonkey.mjs.map