blob: 520c7094ae0d1abf6680a8be3d8594244725f53a [file] [log] [blame]
/*
* 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 VisualMapModel, { VisualMapOption, VisualMeta } from './VisualMapModel';
import VisualMapping, { VisualMappingOption } from '../../visual/VisualMapping';
import visualDefault from '../../visual/visualDefault';
import {reformIntervals} from '../../util/number';
import { VisualOptionPiecewise, BuiltinVisualProperty } from '../../util/types';
import { Dictionary } from 'zrender/src/core/types';
import { inheritDefaultOption } from '../../util/component';
// TODO: use `relationExpression.ts` instead
interface VisualPiece extends VisualOptionPiecewise {
min?: number
max?: number
lt?: number
gt?: number
lte?: number
gte?: number
value?: number
label?: string
}
type VisualState = VisualMapModel['stateList'][number];
type InnerVisualPiece = VisualMappingOption['pieceList'][number];
type GetPieceValueType<T extends InnerVisualPiece>
= T extends { interval: InnerVisualPiece['interval'] } ? number : string;
/**
* Order Rule:
*
* option.categories / option.pieces / option.text / option.selected:
* If !option.inverse,
* Order when vertical: ['top', ..., 'bottom'].
* Order when horizontal: ['left', ..., 'right'].
* If option.inverse, the meaning of
* the order should be reversed.
*
* this._pieceList:
* The order is always [low, ..., high].
*
* Mapping from location to low-high:
* If !option.inverse
* When vertical, top is high.
* When horizontal, right is high.
* If option.inverse, reverse.
*/
export interface PiecewiseVisualMapOption extends VisualMapOption {
align?: 'auto' | 'left' | 'right'
minOpen?: boolean
maxOpen?: boolean
/**
* When put the controller vertically, it is the length of
* horizontal side of each item. Otherwise, vertical side.
* When put the controller vertically, it is the length of
* vertical side of each item. Otherwise, horizontal side.
*/
itemWidth?: number
itemHeight?: number
itemSymbol?: string
pieces?: VisualPiece[]
/**
* category names, like: ['some1', 'some2', 'some3'].
* Attr min/max are ignored when categories set. See "Order Rule"
*/
categories?: string[]
/**
* If set to 5, auto split five pieces equally.
* If set to 0 and component type not set, component type will be
* determined as "continuous". (It is less reasonable but for ec2
* compatibility, see echarts/component/visualMap/typeDefaulter)
*/
splitNumber?: number
/**
* Object. If not specified, means selected. When pieces and splitNumber: {'0': true, '5': true}
* When categories: {'cate1': false, 'cate3': true} When selected === false, means all unselected.
*/
selected?: Dictionary<boolean>
selectedMode?: 'multiple' | 'single'
/**
* By default, when text is used, label will hide (the logic
* is remained for compatibility reason)
*/
showLabel?: boolean
itemGap?: number
hoverLink?: boolean
}
class PiecewiseModel extends VisualMapModel<PiecewiseVisualMapOption> {
static type = 'visualMap.piecewise' as const;
type = PiecewiseModel.type;
/**
* The order is always [low, ..., high].
* [{text: string, interval: Array.<number>}, ...]
*/
private _pieceList: InnerVisualPiece[] = [];
private _mode: 'pieces' | 'categories' | 'splitNumber';
optionUpdated(newOption: PiecewiseVisualMapOption, isInit?: boolean) {
super.optionUpdated.apply(this, arguments as any);
this.resetExtent();
const mode = this._mode = this._determineMode();
this._pieceList = [];
resetMethods[this._mode].call(this, this._pieceList);
this._resetSelected(newOption, isInit);
const categories = this.option.categories;
this.resetVisual(function (mappingOption, state) {
if (mode === 'categories') {
mappingOption.mappingMethod = 'category';
mappingOption.categories = zrUtil.clone(categories);
}
else {
mappingOption.dataExtent = this.getExtent();
mappingOption.mappingMethod = 'piecewise';
mappingOption.pieceList = zrUtil.map(this._pieceList, function (piece) {
piece = zrUtil.clone(piece);
if (state !== 'inRange') {
// FIXME
// outOfRange do not support special visual in pieces.
piece.visual = null;
}
return piece;
});
}
});
}
/**
* @protected
* @override
*/
completeVisualOption() {
// Consider this case:
// visualMap: {
// pieces: [{symbol: 'circle', lt: 0}, {symbol: 'rect', gte: 0}]
// }
// where no inRange/outOfRange set but only pieces. So we should make
// default inRange/outOfRange for this case, otherwise visuals that only
// appear in `pieces` will not be taken into account in visual encoding.
const option = this.option;
const visualTypesInPieces: {[key in BuiltinVisualProperty]?: 0 | 1} = {};
const visualTypes = VisualMapping.listVisualTypes();
const isCategory = this.isCategory();
zrUtil.each(option.pieces, function (piece) {
zrUtil.each(visualTypes, function (visualType: BuiltinVisualProperty) {
if (piece.hasOwnProperty(visualType)) {
visualTypesInPieces[visualType] = 1;
}
});
});
zrUtil.each(visualTypesInPieces, function (v, visualType: BuiltinVisualProperty) {
let exists = false;
zrUtil.each(this.stateList, function (state: VisualState) {
exists = exists || has(option, state, visualType)
|| has(option.target, state, visualType);
}, this);
!exists && zrUtil.each(this.stateList, function (state: VisualState) {
(option[state] || (option[state] = {}))[visualType] = visualDefault.get(
visualType, state === 'inRange' ? 'active' : 'inactive', isCategory
);
});
}, this);
function has(obj: PiecewiseVisualMapOption['target'], state: VisualState, visualType: BuiltinVisualProperty) {
return obj && obj[state] && obj[state].hasOwnProperty(visualType);
}
super.completeVisualOption.apply(this, arguments as any);
}
private _resetSelected(newOption: PiecewiseVisualMapOption, isInit?: boolean) {
const thisOption = this.option;
const pieceList = this._pieceList;
// Selected do not merge but all override.
const selected = (isInit ? thisOption : newOption).selected || {};
thisOption.selected = selected;
// Consider 'not specified' means true.
zrUtil.each(pieceList, function (piece, index) {
const key = this.getSelectedMapKey(piece);
if (!selected.hasOwnProperty(key)) {
selected[key] = true;
}
}, this);
if (thisOption.selectedMode === 'single') {
// Ensure there is only one selected.
let hasSel = false;
zrUtil.each(pieceList, function (piece, index) {
const key = this.getSelectedMapKey(piece);
if (selected[key]) {
hasSel
? (selected[key] = false)
: (hasSel = true);
}
}, this);
}
// thisOption.selectedMode === 'multiple', default: all selected.
}
/**
* @public
*/
getItemSymbol(): string {
return this.get('itemSymbol');
}
/**
* @public
*/
getSelectedMapKey(piece: InnerVisualPiece) {
return this._mode === 'categories'
? piece.value + '' : piece.index + '';
}
/**
* @public
*/
getPieceList(): InnerVisualPiece[] {
return this._pieceList;
}
/**
* @return {string}
*/
private _determineMode() {
const option = this.option;
return option.pieces && option.pieces.length > 0
? 'pieces'
: this.option.categories
? 'categories'
: 'splitNumber';
}
/**
* @override
*/
setSelected(selected: this['option']['selected']) {
this.option.selected = zrUtil.clone(selected);
}
/**
* @override
*/
getValueState(value: number): VisualState {
const index = VisualMapping.findPieceIndex(value, this._pieceList);
return index != null
? (this.option.selected[this.getSelectedMapKey(this._pieceList[index])]
? 'inRange' : 'outOfRange'
)
: 'outOfRange';
}
/**
* @public
* @param pieceIndex piece index in visualMapModel.getPieceList()
*/
findTargetDataIndices(pieceIndex: number) {
type DataIndices = {
seriesId: string
dataIndex: number[]
};
const result: DataIndices[] = [];
const pieceList = this._pieceList;
this.eachTargetSeries(function (seriesModel) {
const dataIndices: number[] = [];
const data = seriesModel.getData();
data.each(this.getDataDimension(data), function (value: number, dataIndex: number) {
// Should always base on model pieceList, because it is order sensitive.
const pIdx = VisualMapping.findPieceIndex(value, pieceList);
pIdx === pieceIndex && dataIndices.push(dataIndex);
}, this);
result.push({seriesId: seriesModel.id, dataIndex: dataIndices});
}, this);
return result;
}
/**
* @private
* @param piece piece.value or piece.interval is required.
* @return Can be Infinity or -Infinity
*/
getRepresentValue(piece: InnerVisualPiece) {
let representValue;
if (this.isCategory()) {
representValue = piece.value;
}
else {
if (piece.value != null) {
representValue = piece.value;
}
else {
const pieceInterval = piece.interval || [];
representValue = (pieceInterval[0] === -Infinity && pieceInterval[1] === Infinity)
? 0
: (pieceInterval[0] + pieceInterval[1]) / 2;
}
}
return representValue;
}
getVisualMeta(
getColorVisual: (value: number, valueState: VisualState) => string
): VisualMeta {
// Do not support category. (category axis is ordinal, numerical)
if (this.isCategory()) {
return;
}
const stops: VisualMeta['stops'] = [];
const outerColors: VisualMeta['outerColors'] = ['', ''];
const visualMapModel = this;
function setStop(interval: [number, number], valueState?: VisualState) {
const representValue = visualMapModel.getRepresentValue({
interval: interval
}) as number;// Not category
if (!valueState) {
valueState = visualMapModel.getValueState(representValue);
}
const color = getColorVisual(representValue, valueState);
if (interval[0] === -Infinity) {
outerColors[0] = color;
}
else if (interval[1] === Infinity) {
outerColors[1] = color;
}
else {
stops.push(
{value: interval[0], color: color},
{value: interval[1], color: color}
);
}
}
// Suplement
const pieceList = this._pieceList.slice();
if (!pieceList.length) {
pieceList.push({interval: [-Infinity, Infinity]});
}
else {
let edge = pieceList[0].interval[0];
edge !== -Infinity && pieceList.unshift({interval: [-Infinity, edge]});
edge = pieceList[pieceList.length - 1].interval[1];
edge !== Infinity && pieceList.push({interval: [edge, Infinity]});
}
let curr = -Infinity;
zrUtil.each(pieceList, function (piece) {
const interval = piece.interval;
if (interval) {
// Fulfill gap.
interval[0] > curr && setStop([curr, interval[0]], 'outOfRange');
setStop(interval.slice() as [number, number]);
curr = interval[1];
}
}, this);
return {stops: stops, outerColors: outerColors};
}
static defaultOption = inheritDefaultOption(VisualMapModel.defaultOption, {
selected: null,
minOpen: false, // Whether include values that smaller than `min`.
maxOpen: false, // Whether include values that bigger than `max`.
align: 'auto', // 'auto', 'left', 'right'
itemWidth: 20,
itemHeight: 14,
itemSymbol: 'roundRect',
pieces: null,
categories: null,
splitNumber: 5,
selectedMode: 'multiple', // Can be 'multiple' or 'single'.
itemGap: 10, // The gap between two items, in px.
hoverLink: true // Enable hover highlight.
}) as PiecewiseVisualMapOption;
};
type ResetMethod = (outPieceList: InnerVisualPiece[]) => void;
/**
* Key is this._mode
* @type {Object}
* @this {module:echarts/component/viusalMap/PiecewiseMode}
*/
const resetMethods: Dictionary<ResetMethod> & ThisType<PiecewiseModel> = {
splitNumber(outPieceList) {
const thisOption = this.option;
let precision = Math.min(thisOption.precision, 20);
const dataExtent = this.getExtent();
let splitNumber = thisOption.splitNumber;
splitNumber = Math.max(parseInt(splitNumber as unknown as string, 10), 1);
thisOption.splitNumber = splitNumber;
let splitStep = (dataExtent[1] - dataExtent[0]) / splitNumber;
// Precision auto-adaption
while (+splitStep.toFixed(precision) !== splitStep && precision < 5) {
precision++;
}
thisOption.precision = precision;
splitStep = +splitStep.toFixed(precision);
if (thisOption.minOpen) {
outPieceList.push({
interval: [-Infinity, dataExtent[0]],
close: [0, 0]
});
}
for (
let index = 0, curr = dataExtent[0];
index < splitNumber;
curr += splitStep, index++
) {
const max = index === splitNumber - 1 ? dataExtent[1] : (curr + splitStep);
outPieceList.push({
interval: [curr, max],
close: [1, 1]
});
}
if (thisOption.maxOpen) {
outPieceList.push({
interval: [dataExtent[1], Infinity],
close: [0, 0]
});
}
reformIntervals(outPieceList as Required<InnerVisualPiece>[]);
zrUtil.each(outPieceList, function (piece, index) {
piece.index = index;
piece.text = this.formatValueText(piece.interval);
}, this);
},
categories(outPieceList) {
const thisOption = this.option;
zrUtil.each(thisOption.categories, function (cate) {
// FIXME category模式也使用pieceList,但在visualMapping中不是使用pieceList。
// 是否改一致。
outPieceList.push({
text: this.formatValueText(cate, true),
value: cate
});
}, this);
// See "Order Rule".
normalizeReverse(thisOption, outPieceList);
},
pieces(outPieceList) {
const thisOption = this.option;
zrUtil.each(thisOption.pieces, function (pieceListItem, index) {
if (!zrUtil.isObject(pieceListItem)) {
pieceListItem = {value: pieceListItem};
}
const item: InnerVisualPiece = {text: '', index: index};
if (pieceListItem.label != null) {
item.text = pieceListItem.label;
}
if (pieceListItem.hasOwnProperty('value')) {
const value = item.value = pieceListItem.value;
item.interval = [value, value];
item.close = [1, 1];
}
else {
// `min` `max` is legacy option.
// `lt` `gt` `lte` `gte` is recommanded.
const interval = item.interval = [] as unknown as [number, number];
const close: typeof item.close = item.close = [0, 0];
const closeList = [1, 0, 1] as const;
const infinityList = [-Infinity, Infinity];
const useMinMax = [];
for (let lg = 0; lg < 2; lg++) {
const names = ([['gte', 'gt', 'min'], ['lte', 'lt', 'max']] as const)[lg];
for (let i = 0; i < 3 && interval[lg] == null; i++) {
interval[lg] = pieceListItem[names[i]];
close[lg] = closeList[i];
useMinMax[lg] = i === 2;
}
interval[lg] == null && (interval[lg] = infinityList[lg]);
}
useMinMax[0] && interval[1] === Infinity && (close[0] = 0);
useMinMax[1] && interval[0] === -Infinity && (close[1] = 0);
if (__DEV__) {
if (interval[0] > interval[1]) {
console.warn(
'Piece ' + index + 'is illegal: ' + interval
+ ' lower bound should not greater then uppper bound.'
);
}
}
if (interval[0] === interval[1] && close[0] && close[1]) {
// Consider: [{min: 5, max: 5, visual: {...}}, {min: 0, max: 5}],
// we use value to lift the priority when min === max
item.value = interval[0];
}
}
item.visual = VisualMapping.retrieveVisuals(pieceListItem);
outPieceList.push(item);
}, this);
// See "Order Rule".
normalizeReverse(thisOption, outPieceList);
// Only pieces
reformIntervals(outPieceList as Required<InnerVisualPiece>[]);
zrUtil.each(outPieceList, function (piece) {
const close = piece.close;
const edgeSymbols = [['<', '≤'][close[1]], ['>', '≥'][close[0]]];
piece.text = piece.text || this.formatValueText(
piece.value != null ? piece.value : piece.interval,
false,
edgeSymbols
);
}, this);
}
};
function normalizeReverse(thisOption: PiecewiseVisualMapOption, pieceList: InnerVisualPiece[]) {
const inverse = thisOption.inverse;
if (thisOption.orient === 'vertical' ? !inverse : inverse) {
pieceList.reverse();
}
}
export default PiecewiseModel;