import { GaugeTimeSerie, GaugeOptions, Stat, Stop, IconConfig, IconPosition} from './types'; import { ChartwerkPod, VueChartwerkPodMixin, ZoomType } from '@chartwerk/core'; import { findClosest } from './utils'; import * as d3 from 'd3'; import * as _ from 'lodash'; const SPACE_BETWEEN_CIRCLES = 2; const CIRCLES_ROUNDING = 0.15; //radians const BACKGROUND_COLOR = 'rgba(38, 38, 38, 0.1)'; const DEFAULT_INNER_RADIUS = 48; const DEFAULT_OUTER_RADIUS = 72; const STOPS_CIRCLE_WIDTH = 4; const VALUE_TEXT_FONT_SIZE = 16; const DEFAULT_VALUE_TEXT_Decimals = 2; const VALUE_TEXT_MARGIN = 10; const DEFAULT_ICON_SIZE = 20; //px const DEFAULT_GAUGE_OPTIONS: GaugeOptions = { usePanning: false, renderLegend: false, renderYaxis: false, renderXaxis: false, renderGrid: false, zoom: { type: ZoomType.NONE }, margin: { top: 0, bottom: 0, left: 0, right: 0 }, stops: [ { color: 'green', value: 10 }, { color: 'yellow', value: 20 } ], defaultColor: 'red', stat: Stat.CURRENT, innerRadius: DEFAULT_INNER_RADIUS, outerRadius: DEFAULT_OUTER_RADIUS, icons: [] }; export class ChartwerkGaugePod extends ChartwerkPod { constructor(el: HTMLElement, _series: GaugeTimeSerie[] = [], _options: GaugeOptions = {}) { super( d3, el, _series, _.defaults(_options, DEFAULT_GAUGE_OPTIONS) ); } renderMetrics(): void { if(this.series.length === 0 || this.series[0].datapoints.length === 0) { this.renderNoDataPointsMessage(); return; } this._renderValueArc(); this._renderThresholdArc(); this._renderValue(); this._renderIcons(); } get _gaugeTransform(): string { return `translate(${this.width / 2},${0.8 * this.height})`; } get _gaugeCenterTranform(): string { return `translate(${this._gaugeCenterCoordinate.x},${0.8 * this._gaugeCenterCoordinate.y})`; } get _gaugeCenterCoordinate(): { x: number, y: number} { // TODO: 0.8 is the hardcoded value. It can be calculated return { x: this.width / 2 + this.margin.left, y: this.height } } get _minWH(): number { // TODO: 0.6 is the hardcoded value. It can be calculated return _.min([0.6 * this.width, this.height]); } private _renderIcons(): void { if(this.options.icons === undefined || this.options.icons.length === 0) { return; } this.options.icons.map(icon => { this._renderIcon(icon); }); } private _renderIcon(icon: IconConfig): void { if(icon.src === undefined || icon.src.length === 0) { return; } this.svg .append('image') .attr('xlink:href', icon.src) .attr('x', this._getIconPosition(icon).x) .attr('y', this._getIconPosition(icon).y) .attr('width', `${this._getIconSize(icon)}px`) .attr('height', `${this._getIconSize(icon)}px`); } private _getIconPosition(icon: IconConfig): { x: number, y: number } { const iconXCenter = this._gaugeCenterCoordinate.x - this._getIconSize(icon) / 2; const iconYCenter = 0.8 * this.height - this._getIconSize(icon) / 2; switch(icon.position) { case IconPosition.LEFT: // TOOD: refactor, it can be calculated by Math.sin, Math.cos const leftX = iconXCenter - this._innerRadius; const leftY = iconYCenter - 0.8 * this._outerRadius; return { x: leftX, y: leftY } case IconPosition.MIDDLE: const middleX = iconXCenter; const middleY = iconYCenter - 0.6 * this._innerRadius; return { x: middleX, y: middleY } case IconPosition.RIGHT: const rightX = iconXCenter + this._innerRadius; const rightY = iconYCenter - 0.8 * this._outerRadius; return { x: rightX, y: rightY } default: throw new Error(`Unknown type of icon position: ${icon.position}`); } } private _getIconSize(icon: IconConfig): number { if(icon.size === undefined) { return this.rescaleWith(DEFAULT_ICON_SIZE); } return this.rescaleWith(icon.size); } private _renderValue(): void { this.svg .append('text') .attr('x', 0) .attr('y', 0) .text(this._valueText) .classed('value-text', true) .attr('font-family', 'Roboto, "Helvetica Neue", Arial, sans-serif') .attr('font-size', `${this._valueTextFontSize}px`) .attr('transform', this._gaugeCenterTranform) .attr('text-anchor', 'middle') .attr('alignment-baseline', 'central') .attr('fill', this._mainCircleColor); } private _renderValueArc(): void { const arc = d3.arc() .innerRadius(this._innerRadius) .outerRadius(this._outerRadius) .padAngle(0); const valueArcs = this._d3Pie(this._valueRange); this.chartContainer.selectAll(null) .data(valueArcs) .enter() .append('path') .attr('class', (d: object, i: number) => { if(i === 0) { return 'value-arc'; } else { return 'backgroung-arc' } }) .style('fill', (d: object, i: number) => { return this._valueArcColors[i]; }) .attr('d', arc as any) .attr('transform', this._gaugeTransform); } private _renderThresholdArc(): void { if(this._sortedStops.length === 0) { return; } const spaceBetweenCircles = this.rescaleSpace(SPACE_BETWEEN_CIRCLES); const thresholdInnerRadius = this._outerRadius + spaceBetweenCircles; const stopCircleWidth = this.rescaleWith(STOPS_CIRCLE_WIDTH); // TODO: move to options const thresholdOuterRadius = thresholdInnerRadius + stopCircleWidth; const thresholdArc = d3.arc() .innerRadius(thresholdInnerRadius) .outerRadius(thresholdOuterRadius) .padAngle(0); const stopArcs = this._d3Pie(this._stopsRange); this.chartContainer.selectAll(null) .data(stopArcs) .enter() .append('path') .attr('class', (d: object, i: number) => { return `stop-arc-${i}`; }) .style('fill', (d: object, i: number) => { return this._colors[i]; }) .attr('d', thresholdArc as any) .attr('transform', this._gaugeTransform); } private get _d3Pie(): d3.Pie { return d3.pie() .startAngle((-1 * Math.PI) / 2 - CIRCLES_ROUNDING) .endAngle(Math.PI / 2 + CIRCLES_ROUNDING) .sort(null); } private get _valueArcColors(): [string, string] { return [this._mainCircleColor, BACKGROUND_COLOR]; } private get _mainCircleColor(): string { if(this.aggregatedValue > _.max(this._stopsValues) || this.aggregatedValue < 0 || this._sortedStops.length === 0) { // TODO: aggregatedValue can be less than 0 return this.options.defaultColor; } // TODO: refactor const closestIdx = findClosest(this._stopsValues, this.aggregatedValue); const closestStop = this._sortedStops[closestIdx]; if(this.aggregatedValue > closestStop.value) { return this._sortedStops[closestIdx + 1].color; } else { return closestStop.color; } } // TODO: better name private get _stopsRange(): number[] { // TODO: refactor // TODO: max value might be less than the latest stop let stopValues = [...this._stopsValues, this._maxValue]; if(stopValues.length < 2) { return this.getUpdatedRangeWithMinValue(stopValues); } let range = [stopValues[0]]; for(let i = 1; i < stopValues.length; i++) { range.push(stopValues[i] - stopValues[i-1]); } return this.getUpdatedRangeWithMinValue(range); } getUpdatedRangeWithMinValue(range: number[]): number[] { let updatedRange = range; updatedRange[0] = range[0] - this._minValue; return updatedRange; } private get _valueRange(): [number, number] { const startValue = this.aggregatedValue - this._minValue; const endValue = this._maxValue - startValue; return [startValue, endValue]; } private get _sortedStops(): Stop[] { return _.sortBy(this.options.stops); } private get _stopsValues(): number[] { return this._sortedStops.map(stop => stop.value); } private get _colors(): string[] { // TODO: refactor return [...this._sortedStops.map(stop => stop.color), this.options.defaultColor]; } private get _valueText(): string { if(this.options.valueFormatter === undefined) { console.log('valueFormatter function is not specified, rendering raw value'); return this.aggregatedValue.toString(); } return this.options.valueFormatter(this.aggregatedValue); } private get _valueTextFontSize(): number { let font; if(this._valueText.length <= 6) { font = VALUE_TEXT_FONT_SIZE; } else if(this._valueText.length > 6 && this._valueText.length <= 10) { font = VALUE_TEXT_FONT_SIZE - 2; } else if(this._valueText.length > 10 && this._valueText.length <= 12) { font = VALUE_TEXT_FONT_SIZE - 4; } else { font = VALUE_TEXT_FONT_SIZE - 6; } return this.rescaleValueFont(font); } private get _stat(): Stat { return this.options.stat; } private get _innerRadius(): number { // TODO: scale shouldn't be here return this.rescaleArcRadius(this.options.innerRadius); } private get _outerRadius(): number { // TODO: scale shouldn't be here return this.rescaleArcRadius(this.options.outerRadius); } rescaleArcRadius(radius: number): number { return radius * this._scaleFactor; } rescaleValueFont(fontsize: number): number { const scale = 0.8 * this._scaleFactor; return fontsize * scale; } rescaleSpace(space: number): number { const scale = 0.5 * this._scaleFactor; return space * scale; } rescaleWith(width: number): number { const scale = 0.6 * this._scaleFactor; return width * scale; } private get _scaleFactor(): number { const stopOuterRadius = this.options.outerRadius + SPACE_BETWEEN_CIRCLES + STOPS_CIRCLE_WIDTH; const marginForRounded = VALUE_TEXT_MARGIN + 10; const scale = this._minWH / (stopOuterRadius + marginForRounded); return scale; } private get aggregatedValue(): number { switch(this._stat) { case Stat.CURRENT: return _.last(this.series[0].datapoints)[0]; // TODO: support other stats default: throw new Error(`Unsupported stat: ${this._stat}`); } } private get _maxValue(): number { return this.options.maxValue || this.maxValue; } private get _minValue(): number { return this.options.minValue || 0; } /* handlers and overloads */ onMouseOver(): void {} onMouseMove(): void {} onMouseOut(): void { } renderSharedCrosshair(): void {} hideSharedCrosshair(): void {} } // it is used with Vue.component, e.g.: Vue.component('chartwerk-gauge-pod', VueChartwerkGaugePodObject) export const VueChartwerkGaugePodObject = { // alternative to `template: '
'` render(createElement) { return createElement( 'div', { class: { 'chartwerk-gauge-pod': true }, attrs: { id: this.id } } ) }, mixins: [VueChartwerkPodMixin], methods: { render() { const pod = new ChartwerkGaugePod(document.getElementById(this.id), this.series, this.options); pod.render(); } } }; export { GaugeOptions, GaugeTimeSerie, Stat };