blob: 00129837337a5effddc9175f4d0d806e6eb3056d [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 ZRText, { TextStyleProps } from 'zrender/src/graphic/Text';
import { Dictionary } from 'zrender/src/core/types';
import Element, { ElementTextConfig } from 'zrender/src/Element';
import Model from '../model/Model';
import {
LabelOption,
DisplayState,
TextCommonOption,
StatesOptionMixin,
DisplayStateNonNormal,
ColorString,
ZRStyleProps,
AnimationOptionMixin,
InterpolatableValue,
SeriesDataType
} from '../util/types';
import GlobalModel from '../model/Global';
import { isFunction, retrieve2, extend, keys, trim } from 'zrender/src/core/util';
import { SPECIAL_STATES, DISPLAY_STATES } from '../util/states';
import { deprecateReplaceLog } from '../util/log';
import { makeInner, interpolateRawValues } from '../util/model';
import List from '../data/List';
import { initProps, updateProps } from '../util/graphic';
import { getECData } from '../util/innerStore';
type TextCommonParams = {
/**
* Whether disable drawing box of block (outer most).
*/
disableBox?: boolean
/**
* Specify a color when color is 'inherit',
* If inheritColor specified, it is used as default textFill.
*/
inheritColor?: ColorString
/**
* Specify a opacity when opacity is not given.
*/
defaultOpacity?: number
defaultOutsidePosition?: LabelOption['position']
/**
* If support legacy 'auto' for 'inherit' usage.
*/
// supportLegacyAuto?: boolean
textStyle?: ZRStyleProps
};
const EMPTY_OBJ = {};
interface SetLabelStyleOpt<TLabelDataIndex> extends TextCommonParams {
defaultText?: string | ((
labelDataIndex: TLabelDataIndex,
opt: SetLabelStyleOpt<TLabelDataIndex>,
interpolatedValue?: InterpolatableValue
) => string);
// Fetch text by:
// opt.labelFetcher.getFormattedLabel(
// opt.labelDataIndex, 'normal'/'emphasis', null, opt.labelDimIndex, opt.labelProp
// )
labelFetcher?: {
getFormattedLabel: (
// In MapDraw case it can be string (region name)
labelDataIndex: TLabelDataIndex,
status: DisplayState,
dataType?: string,
labelDimIndex?: number,
formatter?: string | ((params: object) => string),
// If provided, the implementation of `getFormattedLabel` can use it
// to generate the final label text.
extendParams?: {
interpolatedValue: InterpolatableValue
}
) => string;
};
labelDataIndex?: TLabelDataIndex;
labelDimIndex?: number;
/**
* Inject a setter of text for the text animation case.
*/
enableTextSetter?: boolean
}
type LabelModel = Model<LabelOption & {
formatter?: string | ((params: any) => string);
showDuringLabel?: boolean // Currently only supported by line charts
}>;
type LabelModelForText = Model<Omit<
// Remove
LabelOption, 'position' | 'rotate'> & {
formatter?: string | ((params: any) => string);
}>;
type LabelStatesModels<LabelModel> = Partial<Record<DisplayStateNonNormal, LabelModel>> & {normal: LabelModel};
export function setLabelText(label: ZRText, labelTexts: Record<DisplayState, string>) {
for (let i = 0; i < SPECIAL_STATES.length; i++) {
const stateName = SPECIAL_STATES[i];
const text = labelTexts[stateName];
const state = label.ensureState(stateName);
state.style = state.style || {};
state.style.text = text;
}
const oldStates = label.currentStates.slice();
label.clearStates(true);
label.setStyle({ text: labelTexts.normal });
label.useStates(oldStates, true);
}
function getLabelText<TLabelDataIndex>(
opt: SetLabelStyleOpt<TLabelDataIndex>,
stateModels: LabelStatesModels<LabelModel>,
interpolatedValue?: InterpolatableValue
): Record<DisplayState, string> {
const labelFetcher = opt.labelFetcher;
const labelDataIndex = opt.labelDataIndex;
const labelDimIndex = opt.labelDimIndex;
const normalModel = stateModels.normal;
let baseText;
if (labelFetcher) {
baseText = labelFetcher.getFormattedLabel(
labelDataIndex, 'normal',
null,
labelDimIndex,
normalModel && normalModel.get('formatter'),
interpolatedValue != null ? {
interpolatedValue: interpolatedValue
} : null
);
}
if (baseText == null) {
baseText = isFunction(opt.defaultText)
? opt.defaultText(labelDataIndex, opt, interpolatedValue)
: opt.defaultText;
}
const statesText = {
normal: baseText
} as Record<DisplayState, string>;
for (let i = 0; i < SPECIAL_STATES.length; i++) {
const stateName = SPECIAL_STATES[i];
const stateModel = stateModels[stateName];
statesText[stateName] = retrieve2(labelFetcher
? labelFetcher.getFormattedLabel(
labelDataIndex,
stateName,
null,
labelDimIndex,
stateModel && stateModel.get('formatter')
)
: null, baseText);
}
return statesText;
}
/**
* Set normal styles and emphasis styles about text on target element
* If target is a ZRText. It will create a new style object.
* If target is other Element. It will create or reuse ZRText which is attached on the target.
* And create a new style object.
*
* NOTICE: Because the style on ZRText will be replaced with new(only x, y are keeped).
* So please update the style on ZRText after use this method.
*/
// eslint-disable-next-line
function setLabelStyle<TLabelDataIndex>(
targetEl: ZRText,
labelStatesModels: LabelStatesModels<LabelModelForText>,
opt?: SetLabelStyleOpt<TLabelDataIndex>,
stateSpecified?: Partial<Record<DisplayState, TextStyleProps>>
): void;
// eslint-disable-next-line
function setLabelStyle<TLabelDataIndex>(
targetEl: Element,
labelStatesModels: LabelStatesModels<LabelModel>,
opt?: SetLabelStyleOpt<TLabelDataIndex>,
stateSpecified?: Partial<Record<DisplayState, TextStyleProps>>
): void;
function setLabelStyle<TLabelDataIndex>(
targetEl: Element,
labelStatesModels: LabelStatesModels<LabelModel>,
opt?: SetLabelStyleOpt<TLabelDataIndex>,
stateSpecified?: Partial<Record<DisplayState, TextStyleProps>>
// TODO specified position?
) {
opt = opt || EMPTY_OBJ;
const isSetOnText = targetEl instanceof ZRText;
let needsCreateText = false;
for (let i = 0; i < DISPLAY_STATES.length; i++) {
const stateModel = labelStatesModels[DISPLAY_STATES[i]];
if (stateModel && stateModel.getShallow('show')) {
needsCreateText = true;
break;
}
}
let textContent = isSetOnText ? targetEl as ZRText : targetEl.getTextContent();
if (needsCreateText) {
if (!isSetOnText) {
// Reuse the previous
if (!textContent) {
textContent = new ZRText();
targetEl.setTextContent(textContent);
}
// Use same state proxy
if (targetEl.stateProxy) {
textContent.stateProxy = targetEl.stateProxy;
}
}
const labelStatesTexts = getLabelText(opt, labelStatesModels);
const normalModel = labelStatesModels.normal;
const showNormal = !!normalModel.getShallow('show');
const normalStyle = createTextStyle(
normalModel, stateSpecified && stateSpecified.normal, opt, false, !isSetOnText
);
normalStyle.text = labelStatesTexts.normal;
if (!isSetOnText) {
// Always create new
targetEl.setTextConfig(createTextConfig(normalModel, opt, false));
}
for (let i = 0; i < SPECIAL_STATES.length; i++) {
const stateName = SPECIAL_STATES[i];
const stateModel = labelStatesModels[stateName];
if (stateModel) {
const stateObj = textContent.ensureState(stateName);
const stateShow = !!retrieve2(stateModel.getShallow('show'), showNormal);
if (stateShow !== showNormal) {
stateObj.ignore = !stateShow;
}
stateObj.style = createTextStyle(
stateModel, stateSpecified && stateSpecified[stateName], opt, true, !isSetOnText
);
stateObj.style.text = labelStatesTexts[stateName];
if (!isSetOnText) {
const targetElEmphasisState = targetEl.ensureState(stateName);
targetElEmphasisState.textConfig = createTextConfig(stateModel, opt, true);
}
}
}
// PENDING: if there is many requirements that emphasis position
// need to be different from normal position, we might consider
// auto slient is those cases.
textContent.silent = !!normalModel.getShallow('silent');
// Keep x and y
if (textContent.style.x != null) {
normalStyle.x = textContent.style.x;
}
if (textContent.style.y != null) {
normalStyle.y = textContent.style.y;
}
textContent.ignore = !showNormal;
// Always create new style.
textContent.useStyle(normalStyle);
textContent.dirty();
if (opt.enableTextSetter) {
labelInner(textContent).setLabelText = function (interpolatedValue: InterpolatableValue) {
const labelStatesTexts = getLabelText(opt, labelStatesModels, interpolatedValue);
setLabelText(textContent, labelStatesTexts);
};
}
}
else if (textContent) {
// Not display rich text.
textContent.ignore = true;
}
targetEl.dirty();
}
export { setLabelStyle };
export function getLabelStatesModels<LabelName extends string = 'label'>(
itemModel: Model<StatesOptionMixin<any> & Partial<Record<LabelName, any>>>,
labelName?: LabelName
): Record<DisplayState, LabelModel> {
labelName = (labelName || 'label') as LabelName;
const statesModels = {
normal: itemModel.getModel(labelName) as LabelModel
} as Record<DisplayState, LabelModel>;
for (let i = 0; i < SPECIAL_STATES.length; i++) {
const stateName = SPECIAL_STATES[i];
statesModels[stateName] = itemModel.getModel([stateName, labelName]);
}
return statesModels;
}
/**
* Set basic textStyle properties.
*/
export function createTextStyle(
textStyleModel: Model,
specifiedTextStyle?: TextStyleProps, // Fixed style in the code. Can't be set by model.
opt?: Pick<TextCommonParams, 'inheritColor' | 'disableBox'>,
isNotNormal?: boolean,
isAttached?: boolean // If text is attached on an element. If so, auto color will handling in zrender.
) {
const textStyle: TextStyleProps = {};
setTextStyleCommon(textStyle, textStyleModel, opt, isNotNormal, isAttached);
specifiedTextStyle && extend(textStyle, specifiedTextStyle);
// textStyle.host && textStyle.host.dirty && textStyle.host.dirty(false);
return textStyle;
}
export function createTextConfig(
textStyleModel: Model,
opt?: Pick<TextCommonParams, 'defaultOutsidePosition' | 'inheritColor'>,
isNotNormal?: boolean
) {
opt = opt || {};
const textConfig: ElementTextConfig = {};
let labelPosition;
let labelRotate = textStyleModel.getShallow('rotate');
const labelDistance = retrieve2(textStyleModel.getShallow('distance'), isNotNormal ? null : 5);
const labelOffset = textStyleModel.getShallow('offset');
labelPosition = textStyleModel.getShallow('position')
|| (isNotNormal ? null : 'inside');
// 'outside' is not a valid zr textPostion value, but used
// in bar series, and magric type should be considered.
labelPosition === 'outside' && (labelPosition = opt.defaultOutsidePosition || 'top');
if (labelPosition != null) {
textConfig.position = labelPosition;
}
if (labelOffset != null) {
textConfig.offset = labelOffset;
}
if (labelRotate != null) {
labelRotate *= Math.PI / 180;
textConfig.rotation = labelRotate;
}
if (labelDistance != null) {
textConfig.distance = labelDistance;
}
// fill and auto is determined by the color of path fill if it's not specified by developers.
textConfig.outsideFill = textStyleModel.get('color') === 'inherit'
? (opt.inheritColor || null)
: 'auto';
return textConfig;
}
/**
* The uniform entry of set text style, that is, retrieve style definitions
* from `model` and set to `textStyle` object.
*
* Never in merge mode, but in overwrite mode, that is, all of the text style
* properties will be set. (Consider the states of normal and emphasis and
* default value can be adopted, merge would make the logic too complicated
* to manage.)
*/
function setTextStyleCommon(
textStyle: TextStyleProps,
textStyleModel: Model,
opt?: Pick<TextCommonParams, 'inheritColor' | 'defaultOpacity' | 'disableBox'>,
isNotNormal?: boolean,
isAttached?: boolean
) {
// Consider there will be abnormal when merge hover style to normal style if given default value.
opt = opt || EMPTY_OBJ;
const ecModel = textStyleModel.ecModel;
const globalTextStyle = ecModel && ecModel.option.textStyle;
// Consider case:
// {
// data: [{
// value: 12,
// label: {
// rich: {
// // no 'a' here but using parent 'a'.
// }
// }
// }],
// rich: {
// a: { ... }
// }
// }
const richItemNames = getRichItemNames(textStyleModel);
let richResult: TextStyleProps['rich'];
if (richItemNames) {
richResult = {};
for (const name in richItemNames) {
if (richItemNames.hasOwnProperty(name)) {
// Cascade is supported in rich.
const richTextStyle = textStyleModel.getModel(['rich', name]);
// In rich, never `disableBox`.
// FIXME: consider `label: {formatter: '{a|xx}', color: 'blue', rich: {a: {}}}`,
// the default color `'blue'` will not be adopted if no color declared in `rich`.
// That might confuses users. So probably we should put `textStyleModel` as the
// root ancestor of the `richTextStyle`. But that would be a break change.
setTokenTextStyle(
richResult[name] = {}, richTextStyle, globalTextStyle, opt, isNotNormal, isAttached, false, true
);
}
}
}
if (richResult) {
textStyle.rich = richResult;
}
const overflow = textStyleModel.get('overflow');
if (overflow) {
textStyle.overflow = overflow;
}
const margin = textStyleModel.get('minMargin');
if (margin != null) {
textStyle.margin = margin;
}
setTokenTextStyle(textStyle, textStyleModel, globalTextStyle, opt, isNotNormal, isAttached, true, false);
}
// Consider case:
// {
// data: [{
// value: 12,
// label: {
// rich: {
// // no 'a' here but using parent 'a'.
// }
// }
// }],
// rich: {
// a: { ... }
// }
// }
// TODO TextStyleModel
function getRichItemNames(textStyleModel: Model<LabelOption>) {
// Use object to remove duplicated names.
let richItemNameMap: Dictionary<number>;
while (textStyleModel && textStyleModel !== textStyleModel.ecModel) {
const rich = (textStyleModel.option || EMPTY_OBJ as LabelOption).rich;
if (rich) {
richItemNameMap = richItemNameMap || {};
const richKeys = keys(rich);
for (let i = 0; i < richKeys.length; i++) {
const richKey = richKeys[i];
richItemNameMap[richKey] = 1;
}
}
textStyleModel = textStyleModel.parentModel;
}
return richItemNameMap;
}
const TEXT_PROPS_WITH_GLOBAL = [
'fontStyle', 'fontWeight', 'fontSize', 'fontFamily',
'textShadowColor', 'textShadowBlur', 'textShadowOffsetX', 'textShadowOffsetY'
] as const;
const TEXT_PROPS_SELF = [
'align', 'lineHeight', 'width', 'height', 'tag', 'verticalAlign'
] as const;
const TEXT_PROPS_BOX = [
'padding', 'borderWidth', 'borderRadius', 'borderDashOffset',
'backgroundColor', 'borderColor',
'shadowColor', 'shadowBlur', 'shadowOffsetX', 'shadowOffsetY'
] as const;
function setTokenTextStyle(
textStyle: TextStyleProps['rich'][string],
textStyleModel: Model<LabelOption>,
globalTextStyle: LabelOption,
opt?: Pick<TextCommonParams, 'inheritColor' | 'defaultOpacity' | 'disableBox'>,
isNotNormal?: boolean,
isAttached?: boolean,
isBlock?: boolean,
inRich?: boolean
) {
// In merge mode, default value should not be given.
globalTextStyle = !isNotNormal && globalTextStyle || EMPTY_OBJ;
const inheritColor = opt && opt.inheritColor;
let fillColor = textStyleModel.getShallow('color');
let strokeColor = textStyleModel.getShallow('textBorderColor');
let opacity = retrieve2(textStyleModel.getShallow('opacity'), globalTextStyle.opacity);
if (fillColor === 'inherit' || fillColor === 'auto') {
if (__DEV__) {
if (fillColor === 'auto') {
deprecateReplaceLog('color: \'auto\'', 'color: \'inherit\'');
}
}
if (inheritColor) {
fillColor = inheritColor;
}
else {
fillColor = null;
}
}
if (strokeColor === 'inherit' || (strokeColor === 'auto')) {
if (__DEV__) {
if (strokeColor === 'auto') {
deprecateReplaceLog('color: \'auto\'', 'color: \'inherit\'');
}
}
if (inheritColor) {
strokeColor = inheritColor;
}
else {
strokeColor = null;
}
}
if (!isAttached) {
// Only use default global textStyle.color if text is individual.
// Otherwise it will use the strategy of attached text color because text may be on a path.
fillColor = fillColor || globalTextStyle.color;
strokeColor = strokeColor || globalTextStyle.textBorderColor;
}
if (fillColor != null) {
textStyle.fill = fillColor;
}
if (strokeColor != null) {
textStyle.stroke = strokeColor;
}
const textBorderWidth = retrieve2(textStyleModel.getShallow('textBorderWidth'), globalTextStyle.textBorderWidth);
if (textBorderWidth != null) {
textStyle.lineWidth = textBorderWidth;
}
const textBorderType = retrieve2(textStyleModel.getShallow('textBorderType'), globalTextStyle.textBorderType);
if (textBorderType != null) {
textStyle.lineDash = textBorderType as any;
}
const textBorderDashOffset = retrieve2(
textStyleModel.getShallow('textBorderDashOffset'), globalTextStyle.textBorderDashOffset
);
if (textBorderDashOffset != null) {
textStyle.lineDashOffset = textBorderDashOffset;
}
if (!isNotNormal && (opacity == null) && !inRich) {
opacity = opt && opt.defaultOpacity;
}
if (opacity != null) {
textStyle.opacity = opacity;
}
// TODO
if (!isNotNormal && !isAttached) {
// Set default finally.
if (textStyle.fill == null && opt.inheritColor) {
textStyle.fill = opt.inheritColor;
}
}
// Do not use `getFont` here, because merge should be supported, where
// part of these properties may be changed in emphasis style, and the
// others should remain their original value got from normal style.
for (let i = 0; i < TEXT_PROPS_WITH_GLOBAL.length; i++) {
const key = TEXT_PROPS_WITH_GLOBAL[i];
const val = retrieve2(textStyleModel.getShallow(key), globalTextStyle[key]);
if (val != null) {
(textStyle as any)[key] = val;
}
}
for (let i = 0; i < TEXT_PROPS_SELF.length; i++) {
const key = TEXT_PROPS_SELF[i];
const val = textStyleModel.getShallow(key);
if (val != null) {
(textStyle as any)[key] = val;
}
}
if (textStyle.verticalAlign == null) {
const baseline = textStyleModel.getShallow('baseline');
if (baseline != null) {
textStyle.verticalAlign = baseline;
}
}
if (!isBlock || !opt.disableBox) {
for (let i = 0; i < TEXT_PROPS_BOX.length; i++) {
const key = TEXT_PROPS_BOX[i];
const val = textStyleModel.getShallow(key);
if (val != null) {
(textStyle as any)[key] = val;
}
}
const borderType = textStyleModel.getShallow('borderType');
if (borderType != null) {
textStyle.borderDash = borderType as any;
}
if ((textStyle.backgroundColor === 'auto' || textStyle.backgroundColor === 'inherit') && inheritColor) {
if (__DEV__) {
if (textStyle.backgroundColor === 'auto') {
deprecateReplaceLog('backgroundColor: \'auto\'', 'backgroundColor: \'inherit\'');
}
}
textStyle.backgroundColor = inheritColor;
}
if ((textStyle.borderColor === 'auto' || textStyle.borderColor === 'inherit') && inheritColor) {
if (__DEV__) {
if (textStyle.borderColor === 'auto') {
deprecateReplaceLog('borderColor: \'auto\'', 'borderColor: \'inherit\'');
}
}
textStyle.borderColor = inheritColor;
}
}
}
export function getFont(
opt: Pick<TextCommonOption, 'fontStyle' | 'fontWeight' | 'fontSize' | 'fontFamily'>,
ecModel: GlobalModel
) {
const gTextStyleModel = ecModel && ecModel.getModel('textStyle');
return trim([
// FIXME in node-canvas fontWeight is before fontStyle
opt.fontStyle || gTextStyleModel && gTextStyleModel.getShallow('fontStyle') || '',
opt.fontWeight || gTextStyleModel && gTextStyleModel.getShallow('fontWeight') || '',
(opt.fontSize || gTextStyleModel && gTextStyleModel.getShallow('fontSize') || 12) + 'px',
opt.fontFamily || gTextStyleModel && gTextStyleModel.getShallow('fontFamily') || 'sans-serif'
].join(' '));
}
export const labelInner = makeInner<{
/**
* Previous target value stored used for label.
* It's mainly for text animation
*/
prevValue?: InterpolatableValue
/**
* Target value stored used for label.
*/
value?: InterpolatableValue
/**
* Current value in text animation.
*/
interpolatedValue?: InterpolatableValue
/**
* If enable value animation
*/
valueAnimation?: boolean
/**
* Label value precision during animation.
*/
precision?: number | 'auto'
/**
* If enable value animation
*/
statesModels?: LabelStatesModels<LabelModelForText>
/**
* Default text getter during interpolation
*/
defaultInterpolatedText?: (value: InterpolatableValue) => string
/**
* Change label text from interpolated text during animation
*/
setLabelText?: (interpolatedValue?: InterpolatableValue) => void
}, ZRText>();
export function setLabelValueAnimation(
label: ZRText,
labelStatesModels: LabelStatesModels<LabelModelForText>,
value: InterpolatableValue,
getDefaultText: (value: InterpolatableValue) => string
) {
if (!label) {
return;
}
const obj = labelInner(label);
obj.prevValue = obj.value;
obj.value = value;
const normalLabelModel = labelStatesModels.normal;
obj.valueAnimation = normalLabelModel.get('valueAnimation');
if (obj.valueAnimation) {
obj.precision = normalLabelModel.get('precision');
obj.defaultInterpolatedText = getDefaultText;
obj.statesModels = labelStatesModels;
}
}
export function animateLabelValue(
textEl: ZRText,
dataIndex: number,
data: List,
animatableModel: Model<AnimationOptionMixin>,
labelFetcher: SetLabelStyleOpt<number>['labelFetcher']
) {
const labelInnerStore = labelInner(textEl);
if (!labelInnerStore.valueAnimation) {
return;
}
const defaultInterpolatedText = labelInnerStore.defaultInterpolatedText;
// Consider the case that being animating, do not use the `obj.value`,
// Otherwise it will jump to the `obj.value` when this new animation started.
const currValue = retrieve2(labelInnerStore.interpolatedValue, labelInnerStore.prevValue);
const targetValue = labelInnerStore.value;
function during(percent: number) {
const interpolated = interpolateRawValues(
data,
labelInnerStore.precision,
currValue,
targetValue,
percent
);
labelInnerStore.interpolatedValue = percent === 1 ? null : interpolated;
const labelText = getLabelText({
labelDataIndex: dataIndex,
labelFetcher: labelFetcher,
defaultText: defaultInterpolatedText
? defaultInterpolatedText(interpolated)
: interpolated + ''
}, labelInnerStore.statesModels, interpolated);
setLabelText(textEl, labelText);
}
(currValue == null
? initProps
: updateProps
)(textEl, {}, animatableModel, dataIndex, null, during);
}