/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ import PointerPath from './PointerPath'; import * as graphic from '../../util/graphic'; import { setStatesStylesFromModel, toggleHoverEmphasis } from '../../util/states'; import {createTextStyle, setLabelValueAnimation, animateLabelValue} from '../../label/labelStyle'; import ChartView from '../../view/Chart'; import {parsePercent, round, linearMap} from '../../util/number'; import GaugeSeriesModel, { GaugeDataItemOption } from './GaugeSeries'; import GlobalModel from '../../model/Global'; import ExtensionAPI from '../../core/ExtensionAPI'; import { ColorString, ECElement } from '../../util/types'; import SeriesData from '../../data/SeriesData'; import Sausage from '../../util/shape/sausage'; import {createSymbol} from '../../util/symbol'; import ZRImage from 'zrender/src/graphic/Image'; import { extend, isFunction, isString, isNumber, each } from 'zrender/src/core/util'; import {setCommonECData} from '../../util/innerStore'; import { normalizeArcAngles } from 'zrender/src/core/PathProxy'; type ECSymbol = ReturnType; interface PosInfo { cx: number cy: number r: number } function parsePosition(seriesModel: GaugeSeriesModel, api: ExtensionAPI): PosInfo { const center = seriesModel.get('center'); const width = api.getWidth(); const height = api.getHeight(); const size = Math.min(width, height); const cx = parsePercent(center[0], api.getWidth()); const cy = parsePercent(center[1], api.getHeight()); const r = parsePercent(seriesModel.get('radius'), size / 2); return { cx: cx, cy: cy, r: r }; } function formatLabel(value: number, labelFormatter: string | ((value: number) => string)): string { let label = value == null ? '' : (value + ''); if (labelFormatter) { if (isString(labelFormatter)) { label = labelFormatter.replace('{value}', label); } else if (isFunction(labelFormatter)) { label = labelFormatter(value); } } return label; } class GaugeView extends ChartView { static type = 'gauge' as const; type = GaugeView.type; private _data: SeriesData; private _progressEls: graphic.Path[]; private _titleEls: graphic.Text[]; private _detailEls: graphic.Text[]; render(seriesModel: GaugeSeriesModel, ecModel: GlobalModel, api: ExtensionAPI) { this.group.removeAll(); const colorList = seriesModel.get(['axisLine', 'lineStyle', 'color']); const posInfo = parsePosition(seriesModel, api); this._renderMain( seriesModel, ecModel, api, colorList, posInfo ); this._data = seriesModel.getData(); } dispose() {} _renderMain( seriesModel: GaugeSeriesModel, ecModel: GlobalModel, api: ExtensionAPI, colorList: [number, ColorString][], posInfo: PosInfo ) { const group = this.group; const clockwise = seriesModel.get('clockwise'); let startAngle = -seriesModel.get('startAngle') / 180 * Math.PI; let endAngle = -seriesModel.get('endAngle') / 180 * Math.PI; const axisLineModel = seriesModel.getModel('axisLine'); const roundCap = axisLineModel.get('roundCap'); const MainPath = roundCap ? Sausage : graphic.Sector; const showAxis = axisLineModel.get('show'); const lineStyleModel = axisLineModel.getModel('lineStyle'); const axisLineWidth = lineStyleModel.get('width'); const angles = [startAngle, endAngle]; normalizeArcAngles(angles, !clockwise); startAngle = angles[0]; endAngle = angles[1]; const angleRangeSpan = endAngle - startAngle; let prevEndAngle = startAngle; const sectors: (Sausage | graphic.Sector)[] = []; for (let i = 0; showAxis && i < colorList.length; i++) { // Clamp const percent = Math.min(Math.max(colorList[i][0], 0), 1); endAngle = startAngle + angleRangeSpan * percent; const sector = new MainPath({ shape: { startAngle: prevEndAngle, endAngle: endAngle, cx: posInfo.cx, cy: posInfo.cy, clockwise: clockwise, r0: posInfo.r - axisLineWidth, r: posInfo.r }, silent: true }); sector.setStyle({ fill: colorList[i][1] }); sector.setStyle(lineStyleModel.getLineStyle( // Because we use sector to simulate arc // so the properties for stroking are useless ['color', 'width'] )); sectors.push(sector); prevEndAngle = endAngle; } sectors.reverse(); each(sectors, sector => group.add(sector)); const getColor = function (percent: number) { // Less than 0 if (percent <= 0) { return colorList[0][1]; } let i; for (i = 0; i < colorList.length; i++) { if (colorList[i][0] >= percent && (i === 0 ? 0 : colorList[i - 1][0]) < percent ) { return colorList[i][1]; } } // More than 1 return colorList[i - 1][1]; }; this._renderTicks( seriesModel, ecModel, api, getColor, posInfo, startAngle, endAngle, clockwise, axisLineWidth ); this._renderTitleAndDetail( seriesModel, ecModel, api, getColor, posInfo ); this._renderAnchor(seriesModel, posInfo); this._renderPointer( seriesModel, ecModel, api, getColor, posInfo, startAngle, endAngle, clockwise, axisLineWidth ); } _renderTicks( seriesModel: GaugeSeriesModel, ecModel: GlobalModel, api: ExtensionAPI, getColor: (percent: number) => ColorString, posInfo: PosInfo, startAngle: number, endAngle: number, clockwise: boolean, axisLineWidth: number ) { const group = this.group; const cx = posInfo.cx; const cy = posInfo.cy; const r = posInfo.r; const minVal = +seriesModel.get('min'); const maxVal = +seriesModel.get('max'); const splitLineModel = seriesModel.getModel('splitLine'); const tickModel = seriesModel.getModel('axisTick'); const labelModel = seriesModel.getModel('axisLabel'); const splitNumber = seriesModel.get('splitNumber'); const subSplitNumber = tickModel.get('splitNumber'); const splitLineLen = parsePercent( splitLineModel.get('length'), r ); const tickLen = parsePercent( tickModel.get('length'), r ); let angle = startAngle; const step = (endAngle - startAngle) / splitNumber; const subStep = step / subSplitNumber; const splitLineStyle = splitLineModel.getModel('lineStyle').getLineStyle(); const tickLineStyle = tickModel.getModel('lineStyle').getLineStyle(); const splitLineDistance = splitLineModel.get('distance'); let unitX; let unitY; for (let i = 0; i <= splitNumber; i++) { unitX = Math.cos(angle); unitY = Math.sin(angle); // Split line if (splitLineModel.get('show')) { const distance = splitLineDistance ? splitLineDistance + axisLineWidth : axisLineWidth; const splitLine = new graphic.Line({ shape: { x1: unitX * (r - distance) + cx, y1: unitY * (r - distance) + cy, x2: unitX * (r - splitLineLen - distance) + cx, y2: unitY * (r - splitLineLen - distance) + cy }, style: splitLineStyle, silent: true }); if (splitLineStyle.stroke === 'auto') { splitLine.setStyle({ stroke: getColor(i / splitNumber) }); } group.add(splitLine); } // Label if (labelModel.get('show')) { const distance = labelModel.get('distance') + splitLineDistance; const label = formatLabel( round(i / splitNumber * (maxVal - minVal) + minVal), labelModel.get('formatter') ); const autoColor = getColor(i / splitNumber); const textStyleX = unitX * (r - splitLineLen - distance) + cx; const textStyleY = unitY * (r - splitLineLen - distance) + cy; const rotateType = labelModel.get('rotate'); let rotate = 0; if (rotateType === 'radial') { rotate = -angle + 2 * Math.PI; if (rotate > Math.PI / 2) { rotate += Math.PI; } } else if (rotateType === 'tangential') { rotate = -angle - Math.PI / 2; } else if (isNumber(rotateType)) { rotate = rotateType * Math.PI / 180; } if (rotate === 0) { group.add(new graphic.Text({ style: createTextStyle(labelModel, { text: label, x: textStyleX, y: textStyleY, verticalAlign: unitY < -0.8 ? 'top' : (unitY > 0.8 ? 'bottom' : 'middle'), align: unitX < -0.4 ? 'left' : (unitX > 0.4 ? 'right' : 'center') }, { inheritColor: autoColor }), silent: true })); } else { group.add(new graphic.Text({ style: createTextStyle(labelModel, { text: label, x: textStyleX, y: textStyleY, verticalAlign: 'middle', align: 'center' }, { inheritColor: autoColor }), silent: true, originX: textStyleX, originY: textStyleY, rotation: rotate })); } } // Axis tick if (tickModel.get('show') && i !== splitNumber) { let distance = tickModel.get('distance'); distance = distance ? distance + axisLineWidth : axisLineWidth; for (let j = 0; j <= subSplitNumber; j++) { unitX = Math.cos(angle); unitY = Math.sin(angle); const tickLine = new graphic.Line({ shape: { x1: unitX * (r - distance) + cx, y1: unitY * (r - distance) + cy, x2: unitX * (r - tickLen - distance) + cx, y2: unitY * (r - tickLen - distance) + cy }, silent: true, style: tickLineStyle }); if (tickLineStyle.stroke === 'auto') { tickLine.setStyle({ stroke: getColor((i + j / subSplitNumber) / splitNumber) }); } group.add(tickLine); angle += subStep; } angle -= subStep; } else { angle += step; } } } _renderPointer( seriesModel: GaugeSeriesModel, ecModel: GlobalModel, api: ExtensionAPI, getColor: (percent: number) => ColorString, posInfo: PosInfo, startAngle: number, endAngle: number, clockwise: boolean, axisLineWidth: number ) { const group = this.group; const oldData = this._data; const oldProgressData = this._progressEls; const progressList = [] as graphic.Path[]; const showPointer = seriesModel.get(['pointer', 'show']); const progressModel = seriesModel.getModel('progress'); const showProgress = progressModel.get('show'); const data = seriesModel.getData(); const valueDim = data.mapDimension('value'); const minVal = +seriesModel.get('min'); const maxVal = +seriesModel.get('max'); const valueExtent = [minVal, maxVal]; const angleExtent = [startAngle, endAngle]; function createPointer(idx: number, angle: number) { const itemModel = data.getItemModel(idx); const pointerModel = itemModel.getModel('pointer'); const pointerWidth = parsePercent(pointerModel.get('width'), posInfo.r); const pointerLength = parsePercent(pointerModel.get('length'), posInfo.r); const pointerStr = seriesModel.get(['pointer', 'icon']); const pointerOffset = pointerModel.get('offsetCenter'); const pointerOffsetX = parsePercent(pointerOffset[0], posInfo.r); const pointerOffsetY = parsePercent(pointerOffset[1], posInfo.r); const pointerKeepAspect = pointerModel.get('keepAspect'); let pointer; // not exist icon type will be set 'rect' if (pointerStr) { pointer = createSymbol( pointerStr, pointerOffsetX - pointerWidth / 2, pointerOffsetY - pointerLength, pointerWidth, pointerLength, null, pointerKeepAspect ) as graphic.Path; } else { pointer = new PointerPath({ shape: { angle: -Math.PI / 2, width: pointerWidth, r: pointerLength, x: pointerOffsetX, y: pointerOffsetY } }); } pointer.rotation = -(angle + Math.PI / 2); pointer.x = posInfo.cx; pointer.y = posInfo.cy; return pointer; } function createProgress(idx: number, endAngle: number) { const roundCap = progressModel.get('roundCap'); const ProgressPath = roundCap ? Sausage : graphic.Sector; const isOverlap = progressModel.get('overlap'); const progressWidth = isOverlap ? progressModel.get('width') : axisLineWidth / data.count(); const r0 = isOverlap ? posInfo.r - progressWidth : posInfo.r - (idx + 1) * progressWidth; const r = isOverlap ? posInfo.r : posInfo.r - idx * progressWidth; const progress = new ProgressPath({ shape: { startAngle: startAngle, endAngle: endAngle, cx: posInfo.cx, cy: posInfo.cy, clockwise: clockwise, r0: r0, r: r } }); isOverlap && (progress.z2 = maxVal - (data.get(valueDim, idx) as number) % maxVal); return progress; } if (showProgress || showPointer) { data.diff(oldData) .add(function (idx) { const val = data.get(valueDim, idx) as number; if (showPointer) { const pointer = createPointer(idx, startAngle); // TODO hide pointer on NaN value? graphic.initProps(pointer, { rotation: -( (isNaN(+val) ? angleExtent[0] : linearMap(val, valueExtent, angleExtent, true)) + Math.PI / 2 ) }, seriesModel); group.add(pointer); data.setItemGraphicEl(idx, pointer); } if (showProgress) { const progress = createProgress(idx, startAngle) as graphic.Sector; const isClip = progressModel.get('clip'); graphic.initProps(progress, { shape: { endAngle: linearMap(val, valueExtent, angleExtent, isClip) } }, seriesModel); group.add(progress); // Add data index and series index for indexing the data by element // Useful in tooltip setCommonECData(seriesModel.seriesIndex, data.dataType, idx, progress); progressList[idx] = progress; } }) .update(function (newIdx, oldIdx) { const val = data.get(valueDim, newIdx) as number; if (showPointer) { const previousPointer = oldData.getItemGraphicEl(oldIdx) as PointerPath; const previousRotate = previousPointer ? previousPointer.rotation : startAngle; const pointer = createPointer(newIdx, previousRotate); pointer.rotation = previousRotate; graphic.updateProps(pointer, { rotation: -( (isNaN(+val) ? angleExtent[0] : linearMap(val, valueExtent, angleExtent, true)) + Math.PI / 2 ) }, seriesModel); group.add(pointer); data.setItemGraphicEl(newIdx, pointer); } if (showProgress) { const previousProgress = oldProgressData[oldIdx]; const previousEndAngle = previousProgress ? previousProgress.shape.endAngle : startAngle; const progress = createProgress(newIdx, previousEndAngle) as graphic.Sector; const isClip = progressModel.get('clip'); graphic.updateProps(progress, { shape: { endAngle: linearMap(val, valueExtent, angleExtent, isClip) } }, seriesModel); group.add(progress); // Add data index and series index for indexing the data by element // Useful in tooltip setCommonECData(seriesModel.seriesIndex, data.dataType, newIdx, progress); progressList[newIdx] = progress; } }) .execute(); data.each(function (idx) { const itemModel = data.getItemModel(idx); const emphasisModel = itemModel.getModel('emphasis'); const focus = emphasisModel.get('focus'); const blurScope = emphasisModel.get('blurScope'); const emphasisDisabled = emphasisModel.get('disabled'); if (showPointer) { const pointer = data.getItemGraphicEl(idx) as ECSymbol; const symbolStyle = data.getItemVisual(idx, 'style'); const visualColor = symbolStyle.fill; if (pointer instanceof ZRImage) { const pathStyle = pointer.style; pointer.useStyle(extend({ image: pathStyle.image, x: pathStyle.x, y: pathStyle.y, width: pathStyle.width, height: pathStyle.height }, symbolStyle)); } else { pointer.useStyle(symbolStyle); pointer.type !== 'pointer' && pointer.setColor(visualColor); } pointer.setStyle(itemModel.getModel(['pointer', 'itemStyle']).getItemStyle()); if (pointer.style.fill === 'auto') { pointer.setStyle('fill', getColor( linearMap(data.get(valueDim, idx) as number, valueExtent, [0, 1], true) )); } (pointer as ECElement).z2EmphasisLift = 0; setStatesStylesFromModel(pointer, itemModel); toggleHoverEmphasis(pointer, focus, blurScope, emphasisDisabled); } if (showProgress) { const progress = progressList[idx]; progress.useStyle(data.getItemVisual(idx, 'style')); progress.setStyle(itemModel.getModel(['progress', 'itemStyle']).getItemStyle()); (progress as ECElement).z2EmphasisLift = 0; setStatesStylesFromModel(progress, itemModel); toggleHoverEmphasis(progress, focus, blurScope, emphasisDisabled); } }); this._progressEls = progressList; } } _renderAnchor( seriesModel: GaugeSeriesModel, posInfo: PosInfo ) { const anchorModel = seriesModel.getModel('anchor'); const showAnchor = anchorModel.get('show'); if (showAnchor) { const anchorSize = anchorModel.get('size'); const anchorType = anchorModel.get('icon'); const offsetCenter = anchorModel.get('offsetCenter'); const anchorKeepAspect = anchorModel.get('keepAspect'); const anchor = createSymbol( anchorType, posInfo.cx - anchorSize / 2 + parsePercent(offsetCenter[0], posInfo.r), posInfo.cy - anchorSize / 2 + parsePercent(offsetCenter[1], posInfo.r), anchorSize, anchorSize, null, anchorKeepAspect ) as graphic.Path; anchor.z2 = anchorModel.get('showAbove') ? 1 : 0; anchor.setStyle(anchorModel.getModel('itemStyle').getItemStyle()); this.group.add(anchor); } } _renderTitleAndDetail( seriesModel: GaugeSeriesModel, ecModel: GlobalModel, api: ExtensionAPI, getColor: (percent: number) => ColorString, posInfo: PosInfo ) { const data = seriesModel.getData(); const valueDim = data.mapDimension('value'); const minVal = +seriesModel.get('min'); const maxVal = +seriesModel.get('max'); const contentGroup = new graphic.Group(); const newTitleEls: graphic.Text[] = []; const newDetailEls: graphic.Text[] = []; const hasAnimation = seriesModel.isAnimationEnabled(); const showPointerAbove = seriesModel.get(['pointer', 'showAbove']); data.diff(this._data) .add((idx) => { newTitleEls[idx] = new graphic.Text({ silent: true }); newDetailEls[idx] = new graphic.Text({ silent: true }); }) .update((idx, oldIdx) => { newTitleEls[idx] = this._titleEls[oldIdx]; newDetailEls[idx] = this._detailEls[oldIdx]; }) .execute(); data.each(function (idx) { const itemModel = data.getItemModel(idx); const value = data.get(valueDim, idx) as number; const itemGroup = new graphic.Group(); const autoColor = getColor( linearMap(value, [minVal, maxVal], [0, 1], true) ); const itemTitleModel = itemModel.getModel('title'); if (itemTitleModel.get('show')) { const titleOffsetCenter = itemTitleModel.get('offsetCenter'); const titleX = posInfo.cx + parsePercent(titleOffsetCenter[0], posInfo.r); const titleY = posInfo.cy + parsePercent(titleOffsetCenter[1], posInfo.r); const labelEl = newTitleEls[idx]; labelEl.attr({ z2: showPointerAbove ? 0 : 2, style: createTextStyle(itemTitleModel, { x: titleX, y: titleY, text: data.getName(idx), align: 'center', verticalAlign: 'middle' }, {inheritColor: autoColor}) }); itemGroup.add(labelEl); } const itemDetailModel = itemModel.getModel('detail'); if (itemDetailModel.get('show')) { const detailOffsetCenter = itemDetailModel.get('offsetCenter'); const detailX = posInfo.cx + parsePercent(detailOffsetCenter[0], posInfo.r); const detailY = posInfo.cy + parsePercent(detailOffsetCenter[1], posInfo.r); const width = parsePercent(itemDetailModel.get('width'), posInfo.r); const height = parsePercent(itemDetailModel.get('height'), posInfo.r); const detailColor = ( seriesModel.get(['progress', 'show']) ? data.getItemVisual(idx, 'style').fill : autoColor ) as string; const labelEl = newDetailEls[idx]; const formatter = itemDetailModel.get('formatter'); labelEl.attr({ z2: showPointerAbove ? 0 : 2, style: createTextStyle(itemDetailModel, { x: detailX, y: detailY, text: formatLabel(value, formatter), width: isNaN(width) ? null : width, height: isNaN(height) ? null : height, align: 'center', verticalAlign: 'middle' }, {inheritColor: detailColor}) }); setLabelValueAnimation( labelEl, {normal: itemDetailModel}, value, (value: number) => formatLabel(value, formatter) ); hasAnimation && animateLabelValue(labelEl, idx, data, seriesModel, { getFormattedLabel( labelDataIndex, status, dataType, labelDimIndex, fmt, extendParams ) { return formatLabel( extendParams ? extendParams.interpolatedValue as typeof value : value, formatter ); } }); itemGroup.add(labelEl); } contentGroup.add(itemGroup); }); this.group.add(contentGroup); this._titleEls = newTitleEls; this._detailEls = newDetailEls; } } export default GaugeView;