@eclipse-scout/chart
Version:
Eclipse Scout chart
490 lines (438 loc) • 15.6 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 {aria, ObjectIdProvider, strings, styles} from '@eclipse-scout/core';
import $ from 'jquery';
import {AbstractChartRenderer, Chart} from '../index';
import {ClickObject} from './Chart';
export class AbstractSvgChartRenderer extends AbstractChartRenderer {
chartBox: ChartBox;
/** Clipping and masking */
clipId: string;
maskId: string;
suppressLegendBox: boolean;
height: number;
width: number;
chartAnimationStopping: boolean;
$svg: JQuery<SVGElement>;
protected _nonValueClickHandler: (event: JQuery.ClickEvent) => void;
constructor(chart: Chart) {
super(chart);
this.chartBox = null;
this.clipId = 'Clip-' + ObjectIdProvider.get().createUiSeqId();
this.maskId = 'Mask-' + ObjectIdProvider.get().createUiSeqId();
this.suppressLegendBox = false;
this._nonValueClickHandler = this._onNonValueClick.bind(this);
}
static FONT_SIZE_SMALLEST = 'smallestFont';
static FONT_SIZE_SMALL = 'smallFont';
static FONT_SIZE_MIDDLE = 'middleFont';
static FONT_SIZE_BIG = 'bigFont';
protected override _render() {
if (!this.$svg) {
this.$svg = this.chart.$container.appendSVG('svg', 'chart-svg');
aria.role(this.$svg, 'img');
// labeling has to be done here because otherwise the svg is ignored
this.linkChartWithFieldLabel(this.$svg);
this.$svg.on('click', this._nonValueClickHandler);
}
this.firstOpaqueBackgroundColor = styles.getFirstOpaqueBackgroundColor(this.$svg);
// This works, because CSS specifies 100% width/height
this.height = this.$svg.height();
this.width = this.$svg.width();
this._initChartBox();
if (this._useFontSizeBig()) {
this.$svg.addClass(AbstractSvgChartRenderer.FONT_SIZE_BIG);
} else if (this._useFontSizeMiddle()) {
this.$svg.addClass(AbstractSvgChartRenderer.FONT_SIZE_MIDDLE);
} else if (this._useFontSizeSmall()) {
this.$svg.addClass(AbstractSvgChartRenderer.FONT_SIZE_SMALL);
} else if (this._useFontSizeSmallest()) {
this.$svg.addClass(AbstractSvgChartRenderer.FONT_SIZE_SMALLEST);
}
if (!this.$svg.isAttached()) {
// user navigated away. do not try to render->error
return;
}
this._renderInternal();
}
/**
* Links chart svg with its field label so the field name is read when entering the chart
*
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby">ARIA: aria-labelledby</a>
*/
linkChartWithFieldLabel($chartSvg: JQuery<Element>) {
if (!$chartSvg) {
return;
}
let $field = $chartSvg.parents('.chart-field');
if ($field.length > 0) {
let $fieldLabel = $field.eq(0).children('label');
if ($fieldLabel.length > 0) {
aria.linkElementWithLabel($chartSvg, $fieldLabel.eq(0));
}
}
}
protected _renderInternal() {
// Override in subclasses
}
protected _useFontSizeBig(): boolean {
return false;
}
protected _useFontSizeMiddle(): boolean {
return false;
}
protected _useFontSizeSmall(): boolean {
return false;
}
protected _useFontSizeSmallest(): boolean {
return false;
}
override remove(requestAnimation = false, afterRemoveFunc?: (chartAnimationStopping?: boolean) => void) {
if (this.rendered && !this.chartAnimationStopping) {
this.chartAnimationStopping = true;
this.$svg.children().stop(true, false);
this.chartAnimationStopping = false;
}
super.remove(requestAnimation, afterRemoveFunc);
}
protected override _remove(afterRemoveFunc: (chartAnimationStopping?: boolean) => void) {
// this function is called directly from renderers after all removal animations are done
// however, other animations may have been queued in the meantime (e.g. in case the chart was removed, then added (+animation queued), and then removed again)
if (this.rendered) {
this.$svg.children().stop(true, false);
// need to check again, as stop() may have triggered a chart removal and this may not be rendered anymore
if (this.rendered) {
this.$svg.remove();
this.$svg = null;
}
}
this.rendered = false;
afterRemoveFunc && afterRemoveFunc(this.chartAnimationStopping);
}
/**
* For all parameters: use null when parameter is not used or set by a chart type.
*/
protected _createClickObject(xIndex: number, datasetIndex: number): ClickObject {
return {
xIndex: xIndex,
dataIndex: xIndex,
datasetIndex: datasetIndex
};
}
protected _onChartValueClick(event: JQuery.ClickEvent) {
this.chart.handleValueClick(event.data, event.originalEvent);
event.stopPropagation();
}
protected _onNonValueClick(event: JQuery.ClickEvent) {
this.chart.handleNonValueClick(event.originalEvent);
event.stopPropagation();
}
protected _measureText(text: string, legendLabelClass?: string): { height: number; width: number } {
let $label = this.$svg.appendSVG('text', legendLabelClass)
.attr('x', 0)
.attr('y', 0)
.attr('visibility', 'hidden')
.text(text) as JQuery<SVGGraphicsElement>;
let textBounds;
try {
// Firefox throws error when node is not in dom(already removed by navigating away). all other browser returns a bounding box with 0
textBounds = $label[0].getBBox();
} catch (e) {
return {
height: 0,
width: 0
};
}
$label.remove();
return textBounds;
}
protected _renderLine(x1: number, y1: number, x2: number, y2: number, lineClass: string): JQuery<SVGElement> {
let $line = this.$svg.appendSVG('line', lineClass)
.attr('x1', x1).attr('y1', y1)
.attr('x2', x2).attr('y2', y2);
if (this.animationDuration) {
$line
.attr('opacity', 0)
.animateSVG('opacity', 1, this.animationDuration, null, true);
}
return $line;
}
protected _renderLineLabel(x: number, y: number, label: string, labelClass: string, drawBackground: boolean): JQuery<SVGElement> {
let $label = this.$svg.appendSVG('text', labelClass ? labelClass : 'line-label')
.attr('x', x).attr('y', y)
.text(label);
if (drawBackground) {
$label.attr('mask', 'url(#' + this.maskId + ')');
let $background = this.$svg.appendSVG('text', labelClass ? labelClass + ' background' : 'line-label-background')
.attr('x', x).attr('y', y)
.attr('clip-path', 'url(#' + this.clipId + ')')
.text(label);
$label.data('$background', $background);
if (this.animationDuration) {
$background
.attr('opacity', 0)
.animateSVG('opacity', 1, this.animationDuration, null, true);
}
}
if (this.animationDuration) {
$label
.attr('opacity', 0)
.animateSVG('opacity', 1, this.animationDuration, null, true);
}
return $label;
}
protected _initChartBox() {
this.chartBox = {
width: this.width,
height: this.height,
xOffset: 0,
yOffset: 0,
mX: function() {
return this.xOffset + (this.width / 2);
},
mY: function() {
return this.yOffset + (this.height / 2);
}
};
}
protected _createAnimationObjectWithTabIndexRemoval<T>(animationFunc: (now: number, tween: JQuery.Tween<T>) => void, duration?: number): JQuery.EffectsOptions<T> {
return {
step: function(now, fx) {
try {
animationFunc.bind(this)(now, fx);
} catch (e) {
// prevent logging thousands of exceptions (1 per animation step) by stopping and clearing the queue
$(this).stop(true, false);
throw e;
}
},
duration: duration ? duration : Chart.DEFAULT_ANIMATION_DURATION,
complete: function() {
$(this).removeAttr('tabindex');
}
};
}
protected _addClipping(cssClass: string) {
// add clip and mask paths for all relevant objects
let $clip = this.$svg
.appendSVG('clipPath');
$clip[0].id = this.clipId;
let $mask = this.$svg.appendSVG('mask');
$mask.appendSVG('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', '100%')
.attr('height', '100%')
.attr('fill', 'white');
$mask[0].id = this.maskId;
this.chart.$container.find('.' + cssClass).each(function(i) {
this.id = 'ClipMask-' + ObjectIdProvider.get().createUiSeqId();
$clip.appendSVG('use').attrXLINK('href', '#' + this.id);
$mask.appendSVG('use').attrXLINK('href', '#' + this.id);
});
}
protected _renderWireLegend(text: string, legendPositions: LegendPositions, className: string, drawBackgroundBox?: boolean): Legend {
if (!this.chart.config.options.plugins.tooltip.enabled) {
return {
detachFunc: () => {
// nop
},
attachFunc: () => {
// nop
},
removeFunc: () => {
// nop
}
};
}
let legend = {} as Legend,
padding = 5,
$background,
backgroundWidth = 0,
lineHeight = 17,
backgroundHeight = lineHeight;
if (drawBackgroundBox) {
$background = this.$svg.appendSVG('rect', 'wire-legend-background-box')
.attr('opacity', '1');
}
let positions = legendPositions;
// draw and measure label
let $legend,
lengthLegend = 0,
horizontalSpace = 0;
if (positions.h === -1) {
horizontalSpace = positions.x2 - 2 * padding;
} else {
horizontalSpace = this.width - positions.x2 - 2 * padding;
}
if (Array.isArray(text)) {
for (let i = 0; i < text.length; i++) {
let posIndex = text.length - i - 1;
let yPos = positions.y2 + positions.v * padding - lineHeight * posIndex - padding * posIndex;
let $line = this._renderLineLabel(positions.x2 + padding, yPos, strings.truncateText(text[i], horizontalSpace, this._measureText.bind(this)), '', drawBackgroundBox) as JQuery<SVGTextContentElement>;
$line.addClass(className);
lengthLegend = Math.max(lengthLegend, $line[0].getComputedTextLength());
if (i === 0) {
$legend = $line;
} else {
if ($legend.data('lines')) {
$legend.data('lines').push($line);
} else {
$legend.data('lines', [$line]);
}
}
}
} else {
$legend = this._renderLineLabel(positions.x2 + padding, positions.y2 + positions.v * padding, strings.truncateText(text, horizontalSpace, this._measureText.bind(this)), '', drawBackgroundBox);
$legend.addClass(className);
lengthLegend = $legend[0].getComputedTextLength();
}
backgroundWidth = lengthLegend + 2 * padding;
if (legendPositions.autoPosition) {
positions = legendPositions.posFunc.call(this, backgroundWidth, backgroundHeight);
// adjust legend
$legend.attr('x', positions.x2 + padding);
$legend.attr('y', positions.y2 + positions.v * padding);
}
// fix layout depending on orientation of legend
if (positions.h === -1) {
$legend.attr('x', positions.x2 - padding - lengthLegend);
$legend.css('text-anchor', 'left');
if ($legend.data('lines')) {
$legend.data('lines').forEach($line => {
$line.attr('x', positions.x2 - padding - lengthLegend);
$line.css('text-anchor', 'left');
});
}
} else {
$legend.attr('x', positions.x2 + padding);
$legend.css('text-anchor', 'right');
if ($legend.data('lines')) {
$legend.data('lines').forEach($line => {
$line.attr('x', positions.x2 + padding);
$line.css('text-anchor', 'right');
});
}
}
if (positions.v === 1) {
if ($legend.data('lines')) {
$legend.data('lines').forEach(($line, i) => {
$line.attr('y', positions.x2 - padding - lengthLegend);
let index = 1 + i;
$line.attr('y', positions.y2 + positions.v * padding + lineHeight * index + padding * (index + 1));
});
}
$legend.attr('dy', '0.7em');
} else {
if ($legend.data('lines')) {
let index = $legend.data('lines').length;
$legend.attr('y', positions.y2 + positions.v * padding - lineHeight * index - padding * index);
$legend.data('lines').forEach(($line, i) => {
index = $legend.data('lines').length - 1 - i;
$line.attr('y', positions.y2 + positions.v * padding - lineHeight * index - padding * index);
});
}
}
// align background text
$legend.add($legend.data('lines')).each((i, line) => {
let $line = $(line),
$background = $line.data('$background');
if ($background) {
$background.attr('x', $line.attr('x'));
$background.attr('y', $line.attr('y'));
$background.css('text-anchor', $line.css('text-anchor'));
$background.attr('dy', $line.attr('dy'));
}
});
// draw lines, if wished
let wires = [];
if (positions.x1 > 0 && positions.y1 > 0) {
wires.push(this._renderLine(positions.x1, positions.y1, positions.x2, positions.y2, 'label-line'));
wires.push(this._renderLine(positions.x2, positions.y2, positions.x2 + positions.h * (lengthLegend + 2 * padding), positions.y2, 'label-line'));
}
$legend.data('wires', wires);
let $svg = this.$svg;
legend.detachFunc = () => {
$legend.data('wires').forEach($wire => {
$wire.detach();
});
if ($legend.data('lines')) {
$legend.data('lines').forEach($line => {
if ($line.data('$background')) {
$svg.append($line.data('$background'));
}
$line.detach();
});
}
if ($legend.data('$background')) {
$legend.data('$background').remove();
}
$legend.detach();
};
legend.attachFunc = () => {
$svg.append($legend);
if ($legend.data('$background')) {
$svg.append($legend.data('$background'));
}
$svg.append($legend.data('wires'));
if ($legend.data('lines')) {
$legend.data('lines').forEach($line => {
$svg.append($line);
if ($line.data('$background')) {
$svg.append($line.data('$background'));
}
});
}
};
legend.removeFunc = () => {
$legend.data('wires').forEach($wire => {
$wire.remove();
});
if ($legend.data('lines')) {
$legend.data('lines').forEach($line => {
if ($line.data('$background')) {
$line.data('$background').remove();
}
$line().remove();
});
}
if ($legend.data('$background')) {
$legend.data('$background').remove();
}
$legend.remove();
};
legend.$field = $legend;
return legend;
}
}
export type ChartBox = {
width: number;
height: number;
xOffset: number;
yOffset: number;
mX: () => number;
mY: () => number;
};
export type Legend = {
$field?: JQuery;
detachFunc: () => void;
attachFunc: () => void;
removeFunc: () => void;
};
export type LegendPositions = {
x1: number;
x2: number;
y1: number;
y2: number;
v: number;
h: number;
autoPosition: boolean;
posFunc: (labelWidth: number, labelHeight: number) => LegendPositions;
};