UNPKG

@eclipse-scout/chart

Version:
490 lines (438 loc) 15.6 kB
/* * 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; };