/* * 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 * as zrUtil from 'zrender/src/core/util'; import visualDefault from '../../visual/visualDefault'; import VisualMapping, { VisualMappingOption } from '../../visual/VisualMapping'; import * as visualSolution from '../../visual/visualSolution'; import * as modelUtil from '../../util/model'; import * as numberUtil from '../../util/number'; import { ComponentOption, BoxLayoutOptionMixin, LabelOption, ColorString, ZRColor, BorderOptionMixin, OptionDataValue, BuiltinVisualProperty, DimensionIndex } from '../../util/types'; import ComponentModel from '../../model/Component'; import Model from '../../model/Model'; import GlobalModel from '../../model/Global'; import SeriesModel from '../../model/Series'; import SeriesData from '../../data/SeriesData'; const mapVisual = VisualMapping.mapVisual; const eachVisual = VisualMapping.eachVisual; const isArray = zrUtil.isArray; const each = zrUtil.each; const asc = numberUtil.asc; const linearMap = numberUtil.linearMap; type VisualOptionBase = {[key in BuiltinVisualProperty]?: any}; type LabelFormatter = (min: OptionDataValue, max?: OptionDataValue) => string; type VisualState = VisualMapModel['stateList'][number]; export interface VisualMapOption extends ComponentOption, BoxLayoutOptionMixin, BorderOptionMixin { mainType?: 'visualMap' show?: boolean align?: string realtime?: boolean /** * 'all' or null/undefined: all series. * A number or an array of number: the specified series. * set min: 0, max: 200, only for campatible with ec2. * In fact min max should not have default value. */ seriesIndex?: 'all' | number[] | number /** * min value, must specified if pieces is not specified. */ min?: number /** * max value, must specified if pieces is not specified. */ max?: number /** * Dimension to be encoded */ dimension?: number /** * Visual configuration for the data in selection */ inRange?: T /** * Visual configuration for the out of selection */ outOfRange?: T controller?: { inRange?: T outOfRange?: T } target?: { inRange?: T outOfRange?: T } /** * Width of the display item */ itemWidth?: number /** * Height of the display item */ itemHeight?: number inverse?: boolean orient?: 'horizontal' | 'vertical' backgroundColor?: ZRColor contentColor?: ZRColor inactiveColor?: ZRColor /** * Padding of the component. Can be an array similar to CSS */ padding?: number[] | number /** * Gap between text and item */ textGap?: number precision?: number /** * @deprecated * Option from version 2 */ color?: ColorString[] formatter?: string | LabelFormatter /** * Text on the both end. Such as ['High', 'Low'] */ text?: string[] textStyle?: LabelOption categories?: unknown } export interface VisualMeta { stops: { value: number, color: ColorString}[] outerColors: ColorString[] dimension?: DimensionIndex } class VisualMapModel extends ComponentModel { static type = 'visualMap'; type = VisualMapModel.type; static readonly dependencies = ['series']; readonly stateList = ['inRange', 'outOfRange'] as const; readonly replacableOptionKeys = [ 'inRange', 'outOfRange', 'target', 'controller', 'color' ] as const; readonly layoutMode = { type: 'box', ignoreSize: true } as const; /** * [lowerBound, upperBound] */ dataBound = [-Infinity, Infinity]; protected _dataExtent: [number, number]; targetVisuals = {} as ReturnType; controllerVisuals = {} as ReturnType; textStyleModel: Model; itemSize: number[]; init(option: Opts, parentModel: Model, ecModel: GlobalModel) { this.mergeDefaultAndTheme(option, ecModel); } /** * @protected */ optionUpdated(newOption: Opts, isInit?: boolean) { const thisOption = this.option; !isInit && visualSolution.replaceVisualOption( thisOption, newOption, this.replacableOptionKeys ); this.textStyleModel = this.getModel('textStyle'); this.resetItemSize(); this.completeVisualOption(); } /** * @protected */ resetVisual( supplementVisualOption: (this: this, mappingOption: VisualMappingOption, state: string) => void ) { const stateList = this.stateList; supplementVisualOption = zrUtil.bind(supplementVisualOption, this); this.controllerVisuals = visualSolution.createVisualMappings( this.option.controller, stateList, supplementVisualOption ); this.targetVisuals = visualSolution.createVisualMappings( this.option.target, stateList, supplementVisualOption ); } /** * @public */ getItemSymbol(): string { return null; } /** * @protected * @return {Array.} An array of series indices. */ getTargetSeriesIndices() { const optionSeriesIndex = this.option.seriesIndex; let seriesIndices: number[] = []; if (optionSeriesIndex == null || optionSeriesIndex === 'all') { this.ecModel.eachSeries(function (seriesModel, index) { seriesIndices.push(index); }); } else { seriesIndices = modelUtil.normalizeToArray(optionSeriesIndex); } return seriesIndices; } /** * @public */ eachTargetSeries( callback: (this: Ctx, series: SeriesModel) => void, context?: Ctx ) { zrUtil.each(this.getTargetSeriesIndices(), function (seriesIndex) { const seriesModel = this.ecModel.getSeriesByIndex(seriesIndex); if (seriesModel) { callback.call(context, seriesModel); } }, this); } /** * @pubilc */ isTargetSeries(seriesModel: SeriesModel) { let is = false; this.eachTargetSeries(function (model) { model === seriesModel && (is = true); }); return is; } /** * @example * this.formatValueText(someVal); // format single numeric value to text. * this.formatValueText(someVal, true); // format single category value to text. * this.formatValueText([min, max]); // format numeric min-max to text. * this.formatValueText([this.dataBound[0], max]); // using data lower bound. * this.formatValueText([min, this.dataBound[1]]); // using data upper bound. * * @param value Real value, or this.dataBound[0 or 1]. * @param isCategory Only available when value is number. * @param edgeSymbols Open-close symbol when value is interval. * @protected */ formatValueText( value: number | string | number[], isCategory?: boolean, edgeSymbols?: string[] ): string { const option = this.option; const precision = option.precision; const dataBound = this.dataBound; const formatter = option.formatter; let isMinMax: boolean; edgeSymbols = edgeSymbols || ['<', '>'] as [string, string]; if (zrUtil.isArray(value)) { value = value.slice(); isMinMax = true; } const textValue = isCategory ? value as string // Value is string when isCategory : (isMinMax ? [toFixed((value as number[])[0]), toFixed((value as number[])[1])] : toFixed(value as number) ); if (zrUtil.isString(formatter)) { return formatter .replace('{value}', isMinMax ? (textValue as string[])[0] : textValue as string) .replace('{value2}', isMinMax ? (textValue as string[])[1] : textValue as string); } else if (zrUtil.isFunction(formatter)) { return isMinMax ? formatter((value as number[])[0], (value as number[])[1]) : formatter(value as number); } if (isMinMax) { if ((value as number[])[0] === dataBound[0]) { return edgeSymbols[0] + ' ' + textValue[1]; } else if ((value as number[])[1] === dataBound[1]) { return edgeSymbols[1] + ' ' + textValue[0]; } else { return textValue[0] + ' - ' + textValue[1]; } } else { // Format single value (includes category case). return textValue as string; } function toFixed(val: number) { return val === dataBound[0] ? 'min' : val === dataBound[1] ? 'max' : (+val).toFixed(Math.min(precision, 20)); } } /** * @protected */ resetExtent() { const thisOption = this.option; // Can not calculate data extent by data here. // Because series and data may be modified in processing stage. // So we do not support the feature "auto min/max". const extent = asc([thisOption.min, thisOption.max] as [number, number]); this._dataExtent = extent; } /** * PENDING: * delete this method if no outer usage. * * Return Concrete dimension. If null/undefined is returned, no dimension is used. */ // getDataDimension(data: SeriesData) { // const optDim = this.option.dimension; // if (optDim != null) { // return data.getDimension(optDim); // } // const dimNames = data.dimensions; // for (let i = dimNames.length - 1; i >= 0; i--) { // const dimName = dimNames[i]; // const dimInfo = data.getDimensionInfo(dimName); // if (!dimInfo.isCalculationCoord) { // return dimName; // } // } // } getDataDimensionIndex(data: SeriesData): DimensionIndex { const optDim = this.option.dimension; if (optDim != null) { return data.getDimensionIndex(optDim); } const dimNames = data.dimensions; for (let i = dimNames.length - 1; i >= 0; i--) { const dimName = dimNames[i]; const dimInfo = data.getDimensionInfo(dimName); if (!dimInfo.isCalculationCoord) { return dimInfo.storeDimIndex; } } } getExtent() { return this._dataExtent.slice() as [number, number]; } completeVisualOption() { const ecModel = this.ecModel; const thisOption = this.option; const base = { inRange: thisOption.inRange, outOfRange: thisOption.outOfRange }; const target = thisOption.target || (thisOption.target = {}); const controller = thisOption.controller || (thisOption.controller = {}); zrUtil.merge(target, base); // Do not override zrUtil.merge(controller, base); // Do not override const isCategory = this.isCategory(); completeSingle.call(this, target); completeSingle.call(this, controller); completeInactive.call(this, target, 'inRange', 'outOfRange'); // completeInactive.call(this, target, 'outOfRange', 'inRange'); completeController.call(this, controller); function completeSingle(this: VisualMapModel, base: VisualMapOption['target']) { // Compatible with ec2 dataRange.color. // The mapping order of dataRange.color is: [high value, ..., low value] // whereas inRange.color and outOfRange.color is [low value, ..., high value] // Notice: ec2 has no inverse. if (isArray(thisOption.color) // If there has been inRange: {symbol: ...}, adding color is a mistake. // So adding color only when no inRange defined. && !base.inRange ) { base.inRange = {color: thisOption.color.slice().reverse()}; } // Compatible with previous logic, always give a default color, otherwise // simple config with no inRange and outOfRange will not work. // Originally we use visualMap.color as the default color, but setOption at // the second time the default color will be erased. So we change to use // constant DEFAULT_COLOR. // If user do not want the default color, set inRange: {color: null}. base.inRange = base.inRange || {color: ecModel.get('gradientColor')}; } function completeInactive( this: VisualMapModel, base: VisualMapOption['target'], stateExist: VisualState, stateAbsent: VisualState ) { const optExist = base[stateExist]; let optAbsent = base[stateAbsent]; if (optExist && !optAbsent) { optAbsent = base[stateAbsent] = {}; each(optExist, function (visualData, visualType: BuiltinVisualProperty) { if (!VisualMapping.isValidType(visualType)) { return; } const defa = visualDefault.get(visualType, 'inactive', isCategory); if (defa != null) { optAbsent[visualType] = defa; // Compatibable with ec2: // Only inactive color to rgba(0,0,0,0) can not // make label transparent, so use opacity also. if (visualType === 'color' && !optAbsent.hasOwnProperty('opacity') && !optAbsent.hasOwnProperty('colorAlpha') ) { optAbsent.opacity = [0, 0]; } } }); } } function completeController(this: VisualMapModel, controller?: VisualMapOption['controller']) { const symbolExists = (controller.inRange || {}).symbol || (controller.outOfRange || {}).symbol; const symbolSizeExists = (controller.inRange || {}).symbolSize || (controller.outOfRange || {}).symbolSize; const inactiveColor = this.get('inactiveColor'); const itemSymbol = this.getItemSymbol(); const defaultSymbol = itemSymbol || 'roundRect'; each(this.stateList, function (state: VisualState) { const itemSize = this.itemSize; let visuals = controller[state]; // Set inactive color for controller if no other color // attr (like colorAlpha) specified. if (!visuals) { visuals = controller[state] = { color: isCategory ? inactiveColor : [inactiveColor] }; } // Consistent symbol and symbolSize if not specified. if (visuals.symbol == null) { visuals.symbol = symbolExists && zrUtil.clone(symbolExists) || (isCategory ? defaultSymbol : [defaultSymbol]); } if (visuals.symbolSize == null) { visuals.symbolSize = symbolSizeExists && zrUtil.clone(symbolSizeExists) || (isCategory ? itemSize[0] : [itemSize[0], itemSize[0]]); } // Filter none visuals.symbol = mapVisual(visuals.symbol, function (symbol) { return symbol === 'none' ? defaultSymbol : symbol; }); // Normalize symbolSize const symbolSize = visuals.symbolSize; if (symbolSize != null) { let max = -Infinity; // symbolSize can be object when categories defined. eachVisual(symbolSize, function (value) { value > max && (max = value); }); visuals.symbolSize = mapVisual(symbolSize, function (value) { return linearMap(value, [0, max], [0, itemSize[0]], true); }); } }, this); } } resetItemSize() { this.itemSize = [ parseFloat(this.get('itemWidth') as unknown as string), parseFloat(this.get('itemHeight') as unknown as string) ]; } isCategory() { return !!this.option.categories; } /** * @public * @abstract */ setSelected(selected?: any) {} getSelected(): any { return null; } /** * @public * @abstract */ getValueState(value: any): VisualMapModel['stateList'][number] { return null; } /** * FIXME * Do not publish to thirt-part-dev temporarily * util the interface is stable. (Should it return * a function but not visual meta?) * * @pubilc * @abstract * @param getColorVisual * params: value, valueState * return: color * @return {Object} visualMeta * should includes {stops, outerColors} * outerColor means [colorBeyondMinValue, colorBeyondMaxValue] */ getVisualMeta(getColorVisual: (value: number, valueState: VisualState) => string): VisualMeta { return null; } static defaultOption: VisualMapOption = { show: true, // zlevel: 0, z: 4, seriesIndex: 'all', min: 0, max: 200, left: 0, right: null, top: null, bottom: 0, itemWidth: null, itemHeight: null, inverse: false, orient: 'vertical', // 'horizontal' ¦ 'vertical' backgroundColor: 'rgba(0,0,0,0)', borderColor: '#ccc', // 值域边框颜色 contentColor: '#5793f3', inactiveColor: '#aaa', borderWidth: 0, padding: 5, // 接受数组分别设定上右下左边距,同css textGap: 10, // precision: 0, // 小数精度,默认为0,无小数点 textStyle: { color: '#333' // 值域文字颜色 } }; } export default VisualMapModel;