You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

594 lines
18 KiB

import { GaugeData, GaugeConfig, Stat, Stop, IconConfig, IconPosition, PointCoordinate } from './types';
import { GaugeSeries } from './models/gauge_series';
import { CORE_OPTIONS_DEFAULTS, GaugeOptions } from './models/gauge_options';
import { findClosest } from './utils';
import { ChartwerkPod, VueChartwerkPodMixin } from '@chartwerk/core';
import * as d3 from 'd3';
import * as _ from 'lodash';
const SPACE_BETWEEN_CIRCLES = 2;
const CIRCLES_ROUNDING = 0.25; //radians
const STOPS_CIRCLE_WIDTH = 8;
const DEFAULT_VALUE_TEXT_FONT_SIZE = 16;
const VALUE_TEXT_MARGIN = 10;
const DEFAULT_ICON_SIZE = 20; //px
export class ChartwerkGaugePod extends ChartwerkPod<GaugeData, GaugeConfig> {
series: GaugeSeries;
options: GaugeOptions;
_draggableLines: any[] = [];
_draggedThresholdValues: number[] = []; // threshold values after dragging
_thresholdArc: any | null = null;
_thresholdTextLabels: any[] = [];
constructor(el: HTMLElement, _series: GaugeData[] = [], _options: GaugeConfig = {}) {
// TODO: something better than assign?
super(el, _series, _.assign(_options, CORE_OPTIONS_DEFAULTS));
this.series = new GaugeSeries(_series);
this.options = new GaugeOptions(_options);
}
renderMetrics(): void {
this._renderOverlayBackground();
this._renderValueArc();
this._renderThresholdArc();
this._renderDraggableLines();
this._renderValue();
this._renderIcons();
this._renderLabels();
}
protected updateOptions(newOptions: GaugeConfig): void {
if(newOptions === undefined) {
return;
}
this.options = new GaugeOptions(newOptions);
}
get _gaugeTransform(): string {
return `translate(${this.width / 2},${0.8 * this.height})`;
}
get _gaugeCenterTranform(): string {
return `translate(${this._gaugeCenterCoordinate.x},${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: 0.8 * 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.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`)
.attr('pointer-events', 'none');
}
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);
}
protected _renderOverlayBackground(): void {
this.svg
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', this.width)
.attr('height', this.height)
.classed('overlay', true)
.attr('pointer-events', 'all')
.attr('fill', 'none')
.on('mouseover', this.onGaugeMouseOver.bind(this))
.on('mouseout', this.onGaugeMouseOut.bind(this))
.on('mousemove', this.onGaugeMouseMove.bind(this));
}
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('pointer-events', 'none')
.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 thresholdArc = this._getThresholdArc();
const stopArcs = this._d3Pie(this._getStopsRange(this._stopsValues));
this._thresholdArc = 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);
}
protected _getThresholdArc(): d3.Arc<any, d3.DefaultArcObject> {
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 arc = d3.arc()
.innerRadius(thresholdInnerRadius)
.outerRadius(thresholdOuterRadius)
.padAngle(0);
return arc;
}
get arcScale(): d3.ScaleLinear<number, number> {
return d3.scaleLinear()
.domain([this.options.minValue, this.options.maxValue])
.range([(-1 * Math.PI) / 2 - CIRCLES_ROUNDING, Math.PI / 2 + CIRCLES_ROUNDING]);
}
protected _renderDraggableLines(): void {
if(this.options.enableThresholdDrag === false) {
return;
}
this._stopsValues.forEach((stopValue, stopIdx) => this._renderDraggableLine(stopValue, stopIdx));
this._draggedThresholdValues = _.clone(this._stopsValues);
}
protected _renderDraggableLine(stopValue: number, idx: number): void {
const arc = this._getThresholdArc();
const draggableSize = 0.025;
const thresholdAngle = this.arcScale(stopValue);
const pie = d3.pie()
.startAngle(thresholdAngle - draggableSize)
.endAngle(thresholdAngle + draggableSize)
.sort(null);
const drag = d3.drag()
.on('drag', () => this.onDrag(idx))
.on('end', () => this.onDragEnd(idx));
const dragLine = this.svg.selectAll(null)
.data(pie([1]))
.enter()
.append('path')
.attr('class', 'drag-line')
.style('fill', 'black')
.attr('d', arc as any)
.attr('transform', this._gaugeTransform)
.style('cursor', 'grab')
.attr('pointer-events', 'all');
dragLine.call(drag);
this._draggableLines.push(dragLine);
}
onDrag(idx: number): void {
const angle = this.getAngleFromCoordinates(d3.event.x, d3.event.y);
const restrictedAngle = this.restrictAngle(angle, idx);
this.updateDraggableLineByAngle(restrictedAngle, idx);
const value = _.ceil(this.arcScale.invert(restrictedAngle), 1);
this._draggedThresholdValues[idx] = value;
this.updateThresholdArcByNewValues(this._draggedThresholdValues);
this.updateThresholdLabel(value, idx);
this.onGaugeMouseMove();
this.options.callbackDrag({ value, idx });
}
updateThresholdArcByNewValues(stops: number[]): void {
const thresholdArc = this._getThresholdArc();
const stopArcs = this._d3Pie(this._getStopsRange(stops));
this._thresholdArc
.data(stopArcs)
.attr('d', thresholdArc as any);
}
updateThresholdLabel(value: number, idx: number): void {
if(_.isEmpty(this._thresholdTextLabels) || !this._thresholdTextLabels[idx]) {
return;
}
this._thresholdTextLabels[idx].text(value);
}
updateDraggableLineByAngle(angle: number, idx: number): void {
const arc = this._getThresholdArc();
const draggableSize = 0.025;
const pie = d3.pie()
.startAngle(angle - draggableSize)
.endAngle(angle + draggableSize)
.sort(null);
this._draggableLines[idx].data(pie([1])).attr('d', arc);
}
onDragEnd(idx: number): void {
this.onGaugeMouseOut();
this.options.callbackDragEnd({ idx });
}
getAngleFromCoordinates(x: number, y: number): number {
const vector1 = { start: this._gaugeCenterCoordinate, end: { x: this._gaugeCenterCoordinate.x, y: 0 } };
const vector2 = { start: this._gaugeCenterCoordinate, end: { x, y } };
let a1 = this.getAngleBetween2Vectors(vector1, vector2);
if(x < this.width / 2) { // angle < 0 degree
return -a1;
}
return a1;
}
getAngleBetween2Vectors(
vector1: { start: PointCoordinate, end: PointCoordinate },
vector2: { start: PointCoordinate, end: PointCoordinate }
): number {
const x1 = vector1.start.x; const y1 = vector1.start.y;
const x2 = vector1.end.x; const y2 = vector1.end.y;
const x3 = vector2.start.x; const y3 = vector2.start.y;
const x4 = vector2.end.x; const y4 = vector2.end.y;
return Math.acos(
((x2 - x1) * (x4 - x3) + (y2 - y1) * (y4 - y3)) /
(Math.sqrt( (x2 - x1)**2 + (y2 - y1)**2 ) *
Math.sqrt( (x4 - x3)**2 + (y4 - y3)**2 ))
)
}
restrictAngle(angle: number, idx: number): number {
return _.clamp(angle, -1.8, 1.8);
}
protected _renderLabels(): void {
const yOffset = this._valueTextFontSize + 8;
if(this.options.enableThresholdLabels) {
if(this._stopsValues && this._stopsValues[0]) {
this.renderLabelBackground(0, yOffset / 2);
this._thresholdTextLabels[0] = this.renderLabelText(this.width / 6, yOffset, String(this._stopsValues[0]));
}
if(this._stopsValues && this._stopsValues[1]) {
this.renderLabelBackground(this.width * 2 / 3, yOffset / 2);
this._thresholdTextLabels[1] = this.renderLabelText(this.width * 5 / 6, yOffset, String(this._stopsValues[1]));
}
}
if(this.options.enableExtremumLabels) {
this.renderLabelBackground(0, this.height - yOffset);
this.renderLabelText(this.width / 6, this.height - yOffset / 2, String(this._minValue));
this.renderLabelBackground(this.width * 2 / 3, this.height - yOffset);
this.renderLabelText(this.width * 5 / 6, this.height - yOffset / 2, String(this._maxValue));
}
}
protected renderLabelBackground(x: number, y: number): void {
this.svg
.append('rect')
.attr('x', x)
.attr('y', y)
.attr('width', this.width / 3 + 'px')
.attr('height', this._valueTextFontSize + 8 + 'px')
.classed('label-background', true)
.attr('rx', 16)
.attr('fill', '#202330')
.attr('pointer-events', 'none')
.style('display', 'none')
.attr('fill-opacity', 0.7);
}
protected renderLabelText(x: number, y: number, text: string): d3.Selection<SVGTextElement, unknown, null, undefined> {
return this.svg
.append('text')
.attr('x', x)
.attr('y', y)
.text(text)
.classed('label-text', true)
.attr('font-family', 'Roboto, "Helvetica Neue", Arial, sans-serif')
.attr('font-size', `${this._valueTextFontSize}px`)
.attr('text-anchor', 'middle')
.attr('alignment-baseline', 'central')
.attr('pointer-events', 'none')
.style('display', 'none')
.attr('fill', 'white');
}
private get _d3Pie(): d3.Pie<any, { valueOf(): number; }> {
return d3.pie()
.startAngle((-1 * Math.PI) / 2 - CIRCLES_ROUNDING)
.endAngle(Math.PI / 2 + CIRCLES_ROUNDING)
.sort(null);
}
private get _valueArcColors(): [string, string] {
if(this.options.reversed === true) {
return [this._valueArcBackgroundColor, this._mainCircleColor];
}
return [this._mainCircleColor, this._valueArcBackgroundColor];
}
private get _mainCircleColor(): string {
if(this.aggregatedValue > _.max(this._stopsValues) || this._sortedStops.length === 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 _getStopsRange(stops: number[]): number[] {
// TODO: refactor
// TODO: max value might be less than the latest stop
let stopValues = [...stops, 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;
if(this.options.reversed === true) {
return _.reverse(updatedRange);
}
return updatedRange;
}
private get _valueRange(): [number, number] {
const valueRange = this._maxValue - this._minValue;
const startValue = this.aggregatedValue - this._minValue;
const endValue = valueRange - startValue;
if(this.options.reversed === true) {
return [endValue, startValue];
}
return [startValue, endValue];
}
private get _sortedStops(): Stop[] {
return _.sortBy(this.options.stops, [stop => stop.value]);
}
private get _stopsValues(): number[] {
return this._sortedStops.map(stop => stop.value);
}
private get _colors(): string[] {
// TODO: refactor
const colors = [...this._sortedStops.map(stop => stop.color), this.options.defaultColor];
if(this.options.reversed === true) {
return _.reverse(colors);
}
return colors;
}
private get _valueText(): string {
return this.options.valueFormatter(this.aggregatedValue);
}
private get _valueTextFontSize(): number {
if(this.options.valueFontSize) {
return this.options.valueFontSize;
}
let font;
if(this._valueText.length <= 6) {
font = DEFAULT_VALUE_TEXT_FONT_SIZE;
} else if(this._valueText.length > 6 && this._valueText.length <= 10) {
font = DEFAULT_VALUE_TEXT_FONT_SIZE - 2;
} else if(this._valueText.length > 10 && this._valueText.length <= 12) {
font = DEFAULT_VALUE_TEXT_FONT_SIZE - 4;
} else {
font = DEFAULT_VALUE_TEXT_FONT_SIZE - 6;
}
return this.rescaleValueFont(font);
}
private get _stat(): Stat {
return this.options.stat;
}
private get _valueArcBackgroundColor(): string {
return this.options.valueArcBackgroundColor;
}
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.visibleSeries[0].datapoints)[1];
// TODO: support other stats
default:
throw new Error(`Unsupported stat: ${this._stat}`);
}
}
private get _maxValue(): number {
return this.options.maxValue || this.state.getMaxValueY();
}
private get _minValue(): number {
return this.options.minValue || 0;
}
/* handlers and overloads */
onMouseOver(): void {}
onMouseMove(): void {}
onMouseOut(): void {}
onGaugeMouseOver(): void {
this.svg.selectAll('.label-text').style('display', null);
this.svg.selectAll('.label-background').style('display', null);
}
onGaugeMouseMove(): void {
this.svg.selectAll('.label-text').style('display', null);
this.svg.selectAll('.label-background').style('display', null);
}
onGaugeMouseOut(): void {
this.svg.selectAll('.label-text').style('display', 'none');
this.svg.selectAll('.label-background').style('display', 'none');
}
renderSharedCrosshair(): void {}
hideSharedCrosshair(): void {}
}
// it is used with Vue.component, e.g.: Vue.component('chartwerk-gauge-pod', VueChartwerkGaugePodObject)
export const VueChartwerkGaugePodObject = {
// alternative to `template: '<div class="chartwerk-gauge-pod" :id="id" />'`
render(createElement) {
return createElement(
'div',
{
class: { 'chartwerk-gauge-pod': true },
attrs: { id: this.id }
}
)
},
mixins: [VueChartwerkPodMixin],
methods: {
render() {
if(this.pod === undefined) {
this.pod = new ChartwerkGaugePod(document.getElementById(this.id), this.series, this.options);
this.pod.render();
} else {
this.pod.updateData(this.series, this.options);
}
},
}
};
export { GaugeConfig, GaugeData, Stat };