@eclipse-scout/chart
Version:
Eclipse Scout chart
466 lines (416 loc) • 17.9 kB
text/typescript
/*
* Copyright (c) 2010, 2025 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {arrays, objects, strings} from '@eclipse-scout/core';
import {AbstractSvgChartRenderer, Chart} from '../index';
import $ from 'jquery';
import {ChartValueGroup, ClickObject} from './Chart';
import {LegendPositions} from './AbstractSvgChartRenderer';
export class SalesfunnelChartRenderer extends AbstractSvgChartRenderer {
segmentSelectorForAnimation: string;
widthThresholdMedium: number;
widthThresholdSmall: number;
/**
* Constants for "normalized mode"
* Width of a bar is calculated by multiplying this value with the previous bar's width
*/
barDeltaPercentage: number;
/**
* Factor to be multiplied with the last bar's width. This will create a funnel effect, because
* the last bar is considerably smaller than the other bars.
*/
lastBarAdditionalPercentage: number;
/** Percentage of the total width the last bar always has (delta might get smaller due to this constraint). */
lastBarMinWidthPercentage: number;
paddingBetweenLabel: number;
dataAnalyzeResult: AnalyzedData;
barHeight: number;
barAreaWidth: number;
centerX: number;
animationTriggered: boolean;
constructor(chart: Chart) {
super(chart);
this.segmentSelectorForAnimation = '.salesfunnel-chart-bar';
this.widthThresholdMedium = 400;
this.widthThresholdSmall = 200;
this.barDeltaPercentage = 0.95;
this.lastBarAdditionalPercentage = 0.7;
this.lastBarMinWidthPercentage = 0.4;
this.suppressLegendBox = true;
let defaultConfig = {
options: {
salesfunnel: {
normalized: undefined,
calcConversionRate: undefined
}
}
};
chart.config = $.extend(true, {}, defaultConfig, chart.config);
}
protected override _validate(): boolean {
let chartData = this.chart.data;
let chartConfig = this.chart.config;
if (!chartData ||
!chartConfig ||
chartData.axes.length !== chartData.chartValueGroups.length ||
chartData.chartValueGroups.length === 0 ||
chartData.chartValueGroups[0].values.length === 0 ||
chartConfig.options.salesfunnel.normalized === undefined ||
chartConfig.options.salesfunnel.calcConversionRate === undefined) {
return false;
}
return true;
}
protected override _renderInternal() {
let chartData = this.chart.data,
bars = chartData.chartValueGroups.length;
let conversionRateWidth = this._dynamicConversionRateWidth();
this.paddingBetweenLabel = 20;
this.dataAnalyzeResult = this._analyzeData(chartData.chartValueGroups);
this.barHeight = this.chartBox.height / bars;
this.barAreaWidth = this.chartBox.width -
this.dataAnalyzeResult.maxLengthFirstValueRow -
(this.paddingBetweenLabel * this.dataAnalyzeResult.labelCount) -
this.dataAnalyzeResult.maxLengthSecondValueRow -
conversionRateWidth;
this.centerX = this.barAreaWidth / 2;
if (this.chart.config.options.salesfunnel.normalized) {
this._renderBarsNormalized(chartData.chartValueGroups);
} else {
this._renderBarsAccordingToValues(chartData.chartValueGroups);
}
this._addClipping('salesfunnel-chart-bar');
}
protected _renderBarsNormalized(chartValueGroups: ChartValueGroup[]) {
let barCount = chartValueGroups.length;
let startPointX = this.barAreaWidth +
this.dataAnalyzeResult.maxLengthFirstValueRow +
this.dataAnalyzeResult.maxLengthSecondValueRow +
(this.paddingBetweenLabel * (this.dataAnalyzeResult.labelCount + 1));
let delta = this.barAreaWidth * (1 - this.barDeltaPercentage);
let minLastWidth = this.barAreaWidth * this.lastBarMinWidthPercentage;
let secondLastWidth = minLastWidth / this.lastBarAdditionalPercentage;
delta = Math.min(delta, (this.barAreaWidth - secondLastWidth) / (barCount - 1));
for (let i = 0; i < barCount; i++) {
let width = this.barAreaWidth - (i * delta),
barLabel = chartValueGroups[i].groupName,
widthBottom = this.barAreaWidth - ((i + 1) * delta),
yCoord = i * this.barHeight;
let renderPolyOptions = {
xStart: this.centerX,
yStart: yCoord,
rect: true,
width: width,
widthBottom: widthBottom,
cssClass: 'salesfunnel-chart-bar',
fill: arrays.ensure(chartValueGroups[i].colorHexValue)[0],
label: chartValueGroups[i].groupName,
clickObject: this._createClickObject(null, i)
};
if (this.chart.config.options.autoColor) {
renderPolyOptions.cssClass += ' auto-color color0';
} else if (this.chart.data.chartValueGroups[i].cssClass) {
renderPolyOptions.cssClass += ' ' + this.chart.data.chartValueGroups[i].cssClass;
}
// Adjust last widths to look like funnel.
if (i === barCount - 1) {
renderPolyOptions.width = renderPolyOptions.width * this.lastBarAdditionalPercentage;
renderPolyOptions.widthBottom = renderPolyOptions.widthBottom * this.lastBarAdditionalPercentage;
}
this._renderPolygon(renderPolyOptions);
this._renderLabel(chartValueGroups[i].values[0] as number, false, i);
this._renderBarLabel(barLabel, i, renderPolyOptions.widthBottom);
let labelLineWidth = this.dataAnalyzeResult.maxLengthFirstValueRow + this.paddingBetweenLabel;
if (chartValueGroups[i].values.length > 1) {
this._renderLabel(chartValueGroups[i].values[1] as number, true, i);
labelLineWidth += this.dataAnalyzeResult.maxLengthSecondValueRow + this.paddingBetweenLabel;
}
if (i > 0) {
this._renderLabelSeparatorLine(yCoord, labelLineWidth);
if (this.chart.config.options.salesfunnel.calcConversionRate) {
this._renderConversionRate(i, startPointX, this._calcConversionRate(chartValueGroups[i - 1].values[0] as number, chartValueGroups[i].values[0] as number));
}
}
}
}
protected _renderLabel(label: number, secondLabel: boolean, barIndexFromTop: number) {
if (label === null) {
return;
}
let y = (barIndexFromTop * this.barHeight) + (this.barHeight / 2),
labelOffset = this.dataAnalyzeResult.maxLengthFirstValueRow + (secondLabel ? this.dataAnalyzeResult.maxLengthSecondValueRow : 0),
labelIndex = secondLabel ? 2 : 1,
x = this.barAreaWidth + labelOffset + (labelIndex * this.paddingBetweenLabel),
labelClass = this._dynamicCssClass('salesfunnel-label');
let $label = this.$svg.appendSVG('text', labelClass)
.attr('x', x)
.attr('y', y)
.text(this.session.locale.decimalFormat.format(label));
if (this.animationDuration) {
$label
.attr('opacity', 0)
.animateSVG('opacity', 1, this.animationDuration, null, true);
}
if (this.chart.config.options.plugins.tooltip.enabled && this.chart.data.axes.length > 0) {
let desc = this.chart.data.axes[barIndexFromTop][secondLabel ? 1 : 0].label,
textBounds = this._measureText('' + label, labelClass);
this._renderWireLabels(desc, $label, x - textBounds.width / 2, y - textBounds.height);
}
}
protected _renderWireLabels(label: string, $text: JQuery<SVGElement>, x1: number, y1: number) {
let legendPositions = {
x1: x1,
x2: x1 - 10,
y1: y1,
y2: y1 - 10,
v: -1,
h: -1
} as LegendPositions;
// calculate opening direction
let labelPositionFunc = (labelWidth, labelHeight) => {
if (legendPositions.y2 - labelHeight < 0) {
legendPositions.v = 1;
legendPositions.y1 = legendPositions.y1 + labelHeight * 1.5;
legendPositions.y2 = legendPositions.y1 + 10;
}
return legendPositions;
};
legendPositions.autoPosition = true;
legendPositions.posFunc = labelPositionFunc;
let
legend = this._renderWireLegend(label, legendPositions, 'line-chart-wire-label', true),
mouseIn = legend.attachFunc.bind(legend),
mouseOut = legend.detachFunc.bind(legend);
legend.detachFunc();
$text.on('mouseenter', mouseIn)
.on('mouseleave', mouseOut);
}
protected _renderBarLabel(label: string, barIndexFromTop: number, barWidth: number) {
let y = (barIndexFromTop * this.barHeight) + (this.barHeight / 2),
x = this.centerX,
labelClass = this._dynamicCssClass('salesfunnel-bar-label');
this._renderLineLabel(x, y, label, labelClass, true);
}
protected _renderConversionRate(barIndexFromTop: number, startPointX: number, conversionRate: number) {
if (conversionRate === undefined) {
return;
}
let ctrlY = barIndexFromTop * this.barHeight,
labelClass = this._dynamicCssClass('salesfunnel-conversionrate-label');
let $label = this.$svg.appendSVG('text', labelClass)
.attr('x', startPointX)
.attr('y', ctrlY)
.text('↓ ' + conversionRate + '%');
if (this.animationDuration) {
$label
.attr('opacity', 0)
.animateSVG('opacity', 1, this.animationDuration, null, true);
}
}
protected _renderPolygon(renderPolyOptions: PolyOptions): JQuery<SVGElement> {
let that = this,
points = this._calcPolygonPoints(true, this.animationDuration ? 0 : 1, renderPolyOptions.xStart, renderPolyOptions.yStart, renderPolyOptions.width, renderPolyOptions.widthBottom, this.barHeight - 1);
let $poly = this.$svg.appendSVG('polygon', renderPolyOptions.cssClass, '', renderPolyOptions.id)
.attr('points', points)
.data('xStart', renderPolyOptions.xStart)
.data('yStart', renderPolyOptions.yStart)
.data('widthBar', renderPolyOptions.width)
.data('widthBottom', renderPolyOptions.widthBottom)
.data('heightBar', this.barHeight);
if (!this.chart.config.options.autoColor && renderPolyOptions.fill) {
$poly.attr('fill', renderPolyOptions.fill);
}
$poly.css('stroke', this.firstOpaqueBackgroundColor);
let expandFunc = function(now, fx) {
let $this = $(this);
let xStart = $this.data('xStart'),
yStart = $this.data('yStart'),
width = $this.data('widthBar'),
height = $this.data('heightBar'),
widthBottom = $this.data('widthBottom');
$this.attr('points', that._calcPolygonPoints(true, fx.pos, xStart, yStart, width, widthBottom, height));
};
if (this.animationDuration) {
$poly.animate({
tabIndex: 0
}, this._createAnimationObjectWithTabIndexRemoval(expandFunc, this.animationDuration));
}
if (this.chart.config.options.clickable) {
$poly.on('click', renderPolyOptions.clickObject, this._onChartValueClick.bind(this));
}
if (renderPolyOptions.fill) {
$poly.attr('fill', renderPolyOptions.fill);
}
return $poly;
}
protected _calcPolygonPoints(expand: boolean, fxPos: number, xStart: number, yStart: number, width: number, widthBottom: number, height: number): string {
let xOffsetTop = 0,
xOffsetBottom = 0;
if (expand) {
xOffsetTop = width / 2 * fxPos;
xOffsetBottom = widthBottom / 2 * fxPos;
} else {
xOffsetTop = (width / 2) - (width / 2 * fxPos);
xOffsetBottom = (widthBottom / 2) - (widthBottom / 2 * fxPos);
}
return strings.join(' ',
(xStart - xOffsetTop) + ',' + yStart, // x1, y1
(xStart + xOffsetTop) + ',' + yStart, // x2, y2
(xStart + xOffsetBottom) + ',' + (yStart + height), // x3, y3
(xStart - xOffsetBottom) + ',' + (yStart + height) // x4, y4
);
}
protected _renderBarsAccordingToValues(chartValueGroups: ChartValueGroup[]) {
let widthPerN = (this.dataAnalyzeResult.maxValue ? this.barAreaWidth * 0.8 / this.dataAnalyzeResult.maxValue : 0),
startPointX = this.barAreaWidth + this.dataAnalyzeResult.maxLengthFirstValueRow + this.dataAnalyzeResult.maxLengthSecondValueRow + this.paddingBetweenLabel * this.dataAnalyzeResult.labelCount + 2 * this.paddingBetweenLabel,
barCount = chartValueGroups.length;
for (let i = 0; i < barCount; i++) {
let width = (chartValueGroups[i].values[0] as number) * widthPerN + this.barAreaWidth * 0.2,
barLabel = chartValueGroups[i].groupName,
yCoord = i * this.barHeight;
let renderPolyOptions: PolyOptions = {
xStart: this.centerX,
yStart: yCoord,
rect: true,
width: width,
widthBottom: width,
cssClass: 'salesfunnel-chart-bar',
fill: arrays.ensure(chartValueGroups[i].colorHexValue)[0],
label: chartValueGroups[i].groupName,
clickObject: this._createClickObject(null, i)
};
if (this.chart.config.options.autoColor) {
renderPolyOptions.cssClass += ' auto-color color0';
} else if (this.chart.data.chartValueGroups[i].cssClass) {
renderPolyOptions.cssClass += ' ' + this.chart.data.chartValueGroups[i].cssClass;
}
this._renderPolygon(renderPolyOptions);
this._renderLabel(chartValueGroups[i].values[0] as number, false, i);
this._renderBarLabel(barLabel, i, renderPolyOptions.widthBottom);
let labelLineWidth = this.dataAnalyzeResult.maxLengthFirstValueRow + this.paddingBetweenLabel;
if (chartValueGroups[i].values.length > 1) {
this._renderLabel(chartValueGroups[i].values[1] as number, true, i);
labelLineWidth += this.dataAnalyzeResult.maxLengthSecondValueRow + this.paddingBetweenLabel;
}
if (i > 0) {
this._renderLabelSeparatorLine(yCoord, labelLineWidth);
if (this.chart.config.options.salesfunnel.calcConversionRate) {
this._renderConversionRate(i, startPointX, this._calcConversionRate(chartValueGroups[i - 1].values[0] as number, chartValueGroups[i].values[0] as number));
}
}
}
}
protected _renderLabelSeparatorLine(yCoord: number, labelLineWidth: number) {
let $line = this.$svg.appendSVG('line', 'label-separator')
.attr('x1', this.barAreaWidth + this.paddingBetweenLabel)
.attr('y1', yCoord)
.attr('x2', this.barAreaWidth + labelLineWidth)
.attr('y2', yCoord);
if (this.animationDuration) {
$line
.attr('opacity', 0)
.animateSVG('opacity', 1, this.animationDuration, null, true);
}
}
protected _calcConversionRate(valueBefore: number, value: number): number {
if (objects.isNullOrUndefined(valueBefore) || objects.isNullOrUndefined(value) || valueBefore === 0) {
return undefined;
}
return Math.round(value / valueBefore * 100);
}
protected _analyzeData(valueGroups: ChartValueGroup[]): AnalyzedData {
let result: AnalyzedData = {
labelCount: 0,
maxValue: null,
maxLengthFirstValueRow: 0,
maxLengthSecondValueRow: 0
},
labelClass = this._dynamicCssClass('salesfunnel-label');
for (let i = 0; i < valueGroups.length; i++) {
let valueGroup = valueGroups[i];
result.labelCount = Math.max(result.labelCount, valueGroup.values.length);
// only first value is relevant for bar
if (valueGroup.values.length > 0 && valueGroup.values[0]) {
if (result.maxValue === null) {
result.maxValue = valueGroup.values[0] as number;
} else {
result.maxValue = Math.max(result.maxValue, valueGroup.values[0] as number);
}
result.maxLengthFirstValueRow = Math.max(result.maxLengthFirstValueRow, this._measureText(this.session.locale.decimalFormat.format(valueGroup.values[0] as number), labelClass).width);
}
if (valueGroup.values.length > 1 && valueGroup.values[1]) {
result.maxLengthSecondValueRow = Math.max(result.maxLengthSecondValueRow, this._measureText(this.session.locale.decimalFormat.format(valueGroup.values[1] as number), labelClass).width);
}
}
return result;
}
protected override _removeAnimated(afterRemoveFunc: (chartAnimationStopping?: boolean) => void) {
if (this.animationTriggered) {
return;
}
this.animationTriggered = true;
let that = this,
shrink = function(now, fx) {
let $this = $(this);
let xStart = $this.data('xStart'),
yStart = $this.data('yStart'),
width = $this.data('widthBar'),
height = $this.data('heightBar'),
widthBottom = $this.data('widthBottom');
$this.attr('points', that._calcPolygonPoints(false, fx.pos, xStart, yStart, width, widthBottom, height));
};
this.$svg.children(this.segmentSelectorForAnimation).animate({
tabIndex: 0
}, this._createAnimationObjectWithTabIndexRemoval(shrink))
.promise()
.done(() => {
this._remove(afterRemoveFunc);
this.animationTriggered = false;
});
}
protected _dynamicCssClass(cssClass: string): string {
let small = '';
if (this.chartBox.width <= this.widthThresholdSmall) {
small = 'small';
} else if (this.chartBox.width <= this.widthThresholdMedium) {
small = 'medium';
}
return strings.join(' ', cssClass, small);
}
protected _dynamicConversionRateWidth(): number {
if (!this.chart.config.options.salesfunnel.calcConversionRate) {
return 0; // don't show conversion rate
}
if (this.chartBox.width <= this.widthThresholdSmall) {
return 60;
}
if (this.chartBox.width <= this.widthThresholdMedium) {
return 80;
}
return 100;
}
}
export type AnalyzedData = {
labelCount: number;
maxValue: number;
maxLengthFirstValueRow: number;
maxLengthSecondValueRow: number;
};
export type PolyOptions = {
id?: string;
xStart: number;
yStart: number;
rect: boolean;
width: number;
widthBottom: number;
cssClass: string;
fill: string;
label: string;
clickObject: ClickObject;
};