/*
|
* 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 env from 'zrender/src/core/env';
|
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
|
} from '../../util/types';
|
import ComponentModel from '../../model/Component';
|
import Model from '../../model/Model';
|
import GlobalModel from '../../model/Global';
|
import SeriesModel from '../../model/Series';
|
import List from '../../data/List';
|
|
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<T extends VisualOptionBase = VisualOptionBase> 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?: number
|
}
|
|
class VisualMapModel<Opts extends VisualMapOption = VisualMapOption> extends ComponentModel<Opts> {
|
|
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<typeof visualSolution.createVisualMappings>;
|
|
controllerVisuals = {} as ReturnType<typeof visualSolution.createVisualMappings>;
|
|
textStyleModel: Model<LabelOption>;
|
|
itemSize: number[];
|
|
init(option: Opts, parentModel: Model, ecModel: GlobalModel) {
|
this.mergeDefaultAndTheme(option, ecModel);
|
}
|
|
/**
|
* @protected
|
*/
|
optionUpdated(newOption: Opts, isInit?: boolean) {
|
const thisOption = this.option;
|
|
// FIXME
|
// necessary?
|
// Disable realtime view update if canvas is not supported.
|
if (!env.canvasSupported) {
|
thisOption.realtime = false;
|
}
|
|
!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.<number>} 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<Ctx>(
|
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;
|
}
|
|
/**
|
* Return Concrete dimention. If return null/undefined, no dimension used.
|
*/
|
getDataDimension(list: List) {
|
const optDim = this.option.dimension;
|
const listDimensions = list.dimensions;
|
if (optDim == null && !listDimensions.length) {
|
return;
|
}
|
|
if (optDim != null) {
|
return list.getDimension(optDim);
|
}
|
|
const dimNames = list.dimensions;
|
for (let i = dimNames.length - 1; i >= 0; i--) {
|
const dimName = dimNames[i];
|
const dimInfo = list.getDimensionInfo(dimName);
|
if (!dimInfo.isCalculationCoord) {
|
return dimName;
|
}
|
}
|
}
|
|
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 defautl 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;
|