zrender
Version:
A lightweight graphic library providing 2d draw for Apache ECharts
1,614 lines (1,402 loc) • 64.6 kB
text/typescript
import Transformable, {TRANSFORMABLE_PROPS, TransformProp} from './core/Transformable';
import { AnimationEasing } from './animation/easing';
import Animator, {cloneValue} from './animation/Animator';
import { ZRenderType } from './zrender';
import {
Dictionary, ElementEventName, ZRRawEvent, BuiltinTextPosition, AllPropTypes,
TextVerticalAlign, TextAlign, MapToType
} from './core/types';
import Path from './graphic/Path';
import BoundingRect, { RectLike } from './core/BoundingRect';
import Eventful from './core/Eventful';
import ZRText, { DefaultTextStyle } from './graphic/Text';
import { calculateTextPosition, TextPositionCalculationResult, parsePercent } from './contain/text';
import {
guid,
isObject,
keys,
extend,
indexOf,
logError,
mixin,
isArrayLike,
isTypedArray,
isGradientObject,
filter,
reduce
} from './core/util';
import Polyline from './graphic/shape/Polyline';
import Group from './graphic/Group';
import Point from './core/Point';
import { LIGHT_LABEL_COLOR, DARK_LABEL_COLOR } from './config';
import { parse, stringify } from './tool/color';
import { REDRAW_BIT } from './graphic/constants';
export interface ElementAnimateConfig {
duration?: number
delay?: number
easing?: AnimationEasing
during?: (percent: number) => void
// `done` will be called when all of the animations of the target props are
// "done" or "aborted", and at least one "done" happened.
// Common cases: animations declared, but some of them are aborted (e.g., by state change).
// The calling of `animationTo` done rather than aborted if at least one done happened.
done?: Function
// `aborted` will be called when all of the animations of the target props are "aborted".
aborted?: Function
scope?: string
/**
* If force animate
* Prevent stop animation and callback
* immediently when target values are the same as current values.
*/
force?: boolean
/**
* If use additive animation.
*/
additive?: boolean
/**
* If set to final state before animation started.
* It can be useful if something you want to calcuate depends on the final state of element.
* Like bounding rect for text layouting.
*
* Only available in animateTo
*/
setToFinal?: boolean
}
export interface ElementTextConfig {
/**
* Position relative to the element bounding rect
* @default 'inside'
*/
position?: BuiltinTextPosition | (number | string)[]
/**
* Rotation of the label.
*/
rotation?: number
/**
* Rect that text will be positioned.
* Default to be the rect of element.
*/
layoutRect?: RectLike
/**
* Offset of the label.
* The difference of offset and position is that it will be applied
* in the rotation
*/
offset?: number[]
/**
* Origin or rotation. Which is relative to the bounding box of the attached element.
* Can be percent value. Relative to the bounding box.
* If specified center. It will be center of the bounding box.
*
* Only available when position and rotation are both set.
*/
origin?: (number | string)[] | 'center'
/**
* Distance to the rect
* @default 5
*/
distance?: number
/**
* If use local user space. Which will apply host's transform
* @default false
*/
local?: boolean
/**
* `insideFill` is a color string or left empty.
* If a `textContent` is "inside", its final `fill` will be picked by this priority:
* `textContent.style.fill` > `textConfig.insideFill` > "auto-calculated-fill"
* In most cases, "auto-calculated-fill" is white.
*/
insideFill?: string
/**
* `insideStroke` is a color string or left empty.
* If a `textContent` is "inside", its final `stroke` will be picked by this priority:
* `textContent.style.stroke` > `textConfig.insideStroke` > "auto-calculated-stroke"
*
* The rule of getting "auto-calculated-stroke":
* If (A) the `fill` is specified in style (either in `textContent.style` or `textContent.style.rich`)
* or (B) needed to draw text background (either defined in `textContent.style` or `textContent.style.rich`)
* "auto-calculated-stroke" will be null.
* Otherwise, "auto-calculated-stroke" will be the same as `fill` of this element if possible, or null.
*
* The reason of (A) is not decisive:
* 1. If users specify `fill` in style and still use "auto-calculated-stroke", the effect
* is not good and unexpected in some cases. It not easy and seams uncessary to auto calculate
* a proper `stroke` for the given `fill`, since they can specify `stroke` themselve.
* 2. Backward compat.
*/
insideStroke?: string
/**
* `outsideFill` is a color string or left empty.
* If a `textContent` is "inside", its final `fill` will be picked by this priority:
* `textContent.style.fill` > `textConfig.outsideFill` > #000
*/
outsideFill?: string
/**
* `outsideStroke` is a color string or left empth.
* If a `textContent` is not "inside", its final `stroke` will be picked by this priority:
* `textContent.style.stroke` > `textConfig.outsideStroke` > "auto-calculated-stroke"
*
* The rule of getting "auto-calculated-stroke":
* If (A) the `fill` is specified in style (either in `textContent.style` or `textContent.style.rich`)
* or (B) needed to draw text background (either defined in `textContent.style` or `textContent.style.rich`)
* "auto-calculated-stroke" will be null.
* Otherwise, "auto-calculated-stroke" will be a neer white color to distinguish "front end"
* label with messy background (like other text label, line or other graphic).
*/
outsideStroke?: string
/**
* Tell zrender I can sure this text is inside or not.
* In case position is not using builtin `inside` hints.
*/
inside?: boolean
}
export interface ElementTextGuideLineConfig {
/**
* Anchor for text guide line.
* Notice: Won't work
*/
anchor?: Point
/**
* If above the target element.
*/
showAbove?: boolean
/**
* Candidates of connectors. Used when autoCalculate is true and anchor is not specified.
*/
candidates?: ('left' | 'top' | 'right' | 'bottom')[]
}
export interface ElementEvent {
type: ElementEventName,
event: ZRRawEvent,
// target can only be an element that is not silent.
target: Element,
// topTarget can be a silent element.
topTarget: Element,
cancelBubble: boolean,
offsetX: number,
offsetY: number,
gestureEvent: string,
pinchX: number,
pinchY: number,
pinchScale: number,
wheelDelta: number,
zrByTouch: boolean,
which: number,
stop: (this: ElementEvent) => void
}
export type ElementEventCallback<Ctx, Impl> = (
this: CbThis<Ctx, Impl>, e: ElementEvent
) => boolean | void
type CbThis<Ctx, Impl> = unknown extends Ctx ? Impl : Ctx;
interface ElementEventHandlerProps {
// Events
onclick: ElementEventCallback<unknown, unknown>
ondblclick: ElementEventCallback<unknown, unknown>
onmouseover: ElementEventCallback<unknown, unknown>
onmouseout: ElementEventCallback<unknown, unknown>
onmousemove: ElementEventCallback<unknown, unknown>
onmousewheel: ElementEventCallback<unknown, unknown>
onmousedown: ElementEventCallback<unknown, unknown>
onmouseup: ElementEventCallback<unknown, unknown>
oncontextmenu: ElementEventCallback<unknown, unknown>
ondrag: ElementEventCallback<unknown, unknown>
ondragstart: ElementEventCallback<unknown, unknown>
ondragend: ElementEventCallback<unknown, unknown>
ondragenter: ElementEventCallback<unknown, unknown>
ondragleave: ElementEventCallback<unknown, unknown>
ondragover: ElementEventCallback<unknown, unknown>
ondrop: ElementEventCallback<unknown, unknown>
}
export interface ElementProps extends Partial<ElementEventHandlerProps>, Partial<Pick<Transformable, TransformProp>> {
name?: string
ignore?: boolean
isGroup?: boolean
draggable?: boolean | 'horizontal' | 'vertical'
silent?: boolean
ignoreClip?: boolean
globalScaleRatio?: number
textConfig?: ElementTextConfig
textContent?: ZRText
clipPath?: Path
drift?: Element['drift']
extra?: Dictionary<unknown>
// For echarts animation.
anid?: string
}
// Properties can be used in state.
export const PRESERVED_NORMAL_STATE = '__zr_normal__';
// export const PRESERVED_MERGED_STATE = '__zr_merged__';
const PRIMARY_STATES_KEYS = (TRANSFORMABLE_PROPS as any).concat(['ignore']) as [TransformProp, 'ignore'];
const DEFAULT_ANIMATABLE_MAP = reduce(TRANSFORMABLE_PROPS, (obj, key) => {
obj[key] = true;
return obj;
}, {ignore: false} as Partial<Record<ElementStatePropNames, boolean>>);
export type ElementStatePropNames = (typeof PRIMARY_STATES_KEYS)[number] | 'textConfig';
export type ElementState = Pick<ElementProps, ElementStatePropNames> & ElementCommonState
export type ElementCommonState = {
hoverLayer?: boolean
}
export type ElementCalculateTextPosition = (
out: TextPositionCalculationResult,
style: ElementTextConfig,
rect: RectLike
) => TextPositionCalculationResult;
let tmpTextPosCalcRes = {} as TextPositionCalculationResult;
let tmpBoundingRect = new BoundingRect(0, 0, 0, 0);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Element<Props extends ElementProps = ElementProps> extends Transformable,
Eventful<{
[key in ElementEventName]: (e: ElementEvent) => void | boolean
} & {
[key in string]: (...args: any) => void | boolean
}>,
ElementEventHandlerProps {
}
class Element<Props extends ElementProps = ElementProps> {
id: number = guid()
/**
* Element type
*/
type: string
/**
* Element name
*/
name: string
/**
* If ignore drawing and events of the element object
*/
ignore: boolean
/**
* Whether to respond to mouse events.
*/
silent: boolean
/**
* 是否是 Group
*/
isGroup: boolean
/**
* Whether it can be dragged.
*/
draggable: boolean | 'horizontal' | 'vertical'
/**
* Whether is it dragging.
*/
dragging: boolean
parent: Group
animators: Animator<any>[] = []
/**
* If ignore clip from it's parent or hosts.
* Applied on itself and all it's children.
*
* NOTE: It won't affect the clipPath set on the children.
*/
ignoreClip: boolean
/**
* If element is used as a component of other element.
*/
__hostTarget: Element
/**
* ZRender instance will be assigned when element is associated with zrender
*/
__zr: ZRenderType
/**
* Dirty bits.
* From which painter will determine if this displayable object needs brush.
*/
__dirty: number
/**
* If element was painted on the screen
*/
__isRendered: boolean;
/**
* If element has been moved to the hover layer.
*
* If so, dirty will only trigger the zrender refresh hover layer
*/
__inHover: boolean
/**
* path to clip the elements and its children, if it is a group.
* @see http://www.w3.org/TR/2dcontext/#clipping-region
*/
private _clipPath?: Path
/**
* Attached text element.
* `position`, `style.textAlign`, `style.textVerticalAlign`
* of element will be ignored if textContent.position is set
*/
private _textContent?: ZRText
/**
* Text guide line.
*/
private _textGuide?: Polyline
/**
* Config of textContent. Inlcuding layout, color, ...etc.
*/
textConfig?: ElementTextConfig
/**
* Config for guide line calculating.
*
* NOTE: This is just a property signature. READ and WRITE are all done in echarts.
*/
textGuideLineConfig?: ElementTextGuideLineConfig
// FOR ECHARTS
/**
* Id for mapping animation
*/
anid: string
extra: Dictionary<unknown>
currentStates?: string[] = []
// prevStates is for storager in echarts.
prevStates?: string[]
/**
* Store of element state.
* '__normal__' key is preserved for default properties.
*/
states: Dictionary<ElementState> = {}
/**
* Animation config applied on state switching.
*/
stateTransition: ElementAnimateConfig
/**
* Proxy function for getting state with given stateName.
* ZRender will first try to get with stateProxy. Then find from states if stateProxy returns nothing
*
* targetStates will be given in useStates
*/
stateProxy?: (stateName: string, targetStates?: string[]) => ElementState
protected _normalState: ElementState
// Temporary storage for inside text color configuration.
private _innerTextDefaultStyle: DefaultTextStyle
constructor(props?: Props) {
this._init(props);
}
protected _init(props?: Props) {
// Init default properties
this.attr(props);
}
/**
* Drift element
* @param {number} dx dx on the global space
* @param {number} dy dy on the global space
*/
drift(dx: number, dy: number, e?: ElementEvent) {
switch (this.draggable) {
case 'horizontal':
dy = 0;
break;
case 'vertical':
dx = 0;
break;
}
let m = this.transform;
if (!m) {
m = this.transform = [1, 0, 0, 1, 0, 0];
}
m[4] += dx;
m[5] += dy;
this.decomposeTransform();
this.markRedraw();
}
/**
* Hook before update
*/
beforeUpdate() {}
/**
* Hook after update
*/
afterUpdate() {}
/**
* Update each frame
*/
update() {
this.updateTransform();
if (this.__dirty) {
this.updateInnerText();
}
}
updateInnerText(forceUpdate?: boolean) {
// Update textContent
const textEl = this._textContent;
if (textEl && (!textEl.ignore || forceUpdate)) {
if (!this.textConfig) {
this.textConfig = {};
}
const textConfig = this.textConfig;
const isLocal = textConfig.local;
const innerTransformable = textEl.innerTransformable;
let textAlign: TextAlign;
let textVerticalAlign: TextVerticalAlign;
let textStyleChanged = false;
// Apply host's transform.
innerTransformable.parent = isLocal ? this as unknown as Group : null;
let innerOrigin = false;
// Reset x/y/rotation
innerTransformable.copyTransform(textEl);
// Force set attached text's position if `position` is in config.
if (textConfig.position != null) {
let layoutRect = tmpBoundingRect;
if (textConfig.layoutRect) {
layoutRect.copy(textConfig.layoutRect);
}
else {
layoutRect.copy(this.getBoundingRect());
}
if (!isLocal) {
layoutRect.applyTransform(this.transform);
}
if (this.calculateTextPosition) {
this.calculateTextPosition(tmpTextPosCalcRes, textConfig, layoutRect);
}
else {
calculateTextPosition(tmpTextPosCalcRes, textConfig, layoutRect);
}
// TODO Should modify back if textConfig.position is set to null again.
// Or textContent is detached.
innerTransformable.x = tmpTextPosCalcRes.x;
innerTransformable.y = tmpTextPosCalcRes.y;
// User specified align/verticalAlign has higher priority, which is
// useful in the case that attached text is rotated 90 degree.
textAlign = tmpTextPosCalcRes.align;
textVerticalAlign = tmpTextPosCalcRes.verticalAlign;
const textOrigin = textConfig.origin;
if (textOrigin && textConfig.rotation != null) {
let relOriginX;
let relOriginY;
if (textOrigin === 'center') {
relOriginX = layoutRect.width * 0.5;
relOriginY = layoutRect.height * 0.5;
}
else {
relOriginX = parsePercent(textOrigin[0], layoutRect.width);
relOriginY = parsePercent(textOrigin[1], layoutRect.height);
}
innerOrigin = true;
innerTransformable.originX = -innerTransformable.x + relOriginX + (isLocal ? 0 : layoutRect.x);
innerTransformable.originY = -innerTransformable.y + relOriginY + (isLocal ? 0 : layoutRect.y);
}
}
if (textConfig.rotation != null) {
innerTransformable.rotation = textConfig.rotation;
}
// TODO
const textOffset = textConfig.offset;
if (textOffset) {
innerTransformable.x += textOffset[0];
innerTransformable.y += textOffset[1];
// Not change the user set origin.
if (!innerOrigin) {
innerTransformable.originX = -textOffset[0];
innerTransformable.originY = -textOffset[1];
}
}
// Calculate text color
const isInside = textConfig.inside == null // Force to be inside or not.
? (typeof textConfig.position === 'string' && textConfig.position.indexOf('inside') >= 0)
: textConfig.inside;
const innerTextDefaultStyle = this._innerTextDefaultStyle || (this._innerTextDefaultStyle = {});
let textFill;
let textStroke;
let autoStroke;
if (isInside && this.canBeInsideText()) {
// In most cases `textContent` need this "auto" strategy.
// So by default be 'auto'. Otherwise users need to literally
// set `insideFill: 'auto', insideStroke: 'auto'` each time.
textFill = textConfig.insideFill;
textStroke = textConfig.insideStroke;
if (textFill == null || textFill === 'auto') {
textFill = this.getInsideTextFill();
}
if (textStroke == null || textStroke === 'auto') {
textStroke = this.getInsideTextStroke(textFill);
autoStroke = true;
}
}
else {
textFill = textConfig.outsideFill;
textStroke = textConfig.outsideStroke;
if (textFill == null || textFill === 'auto') {
textFill = this.getOutsideFill();
}
// By default give a stroke to distinguish "front end" label with
// messy background (like other text label, line or other graphic).
// If textContent.style.fill specified, this auto stroke will not be used.
if (textStroke == null || textStroke === 'auto') {
// If some time need to customize the default stroke getter,
// add some kind of override method.
textStroke = this.getOutsideStroke(textFill);
autoStroke = true;
}
}
// Default `textFill` should must have a value to ensure text can be displayed.
textFill = textFill || '#000';
if (textFill !== innerTextDefaultStyle.fill
|| textStroke !== innerTextDefaultStyle.stroke
|| autoStroke !== innerTextDefaultStyle.autoStroke
|| textAlign !== innerTextDefaultStyle.align
|| textVerticalAlign !== innerTextDefaultStyle.verticalAlign
) {
textStyleChanged = true;
innerTextDefaultStyle.fill = textFill;
innerTextDefaultStyle.stroke = textStroke;
innerTextDefaultStyle.autoStroke = autoStroke;
innerTextDefaultStyle.align = textAlign;
innerTextDefaultStyle.verticalAlign = textVerticalAlign;
textEl.setDefaultTextStyle(innerTextDefaultStyle);
}
// Mark textEl to update transform.
// DON'T use markRedraw. It will cause Element itself to dirty again.
textEl.__dirty |= REDRAW_BIT;
if (textStyleChanged) {
// Only mark style dirty if necessary. Update ZRText is costly.
textEl.dirtyStyle(true);
}
}
}
protected canBeInsideText() {
return true;
}
protected getInsideTextFill(): string | undefined {
return '#fff';
}
protected getInsideTextStroke(textFill: string): string | undefined {
return '#000';
}
protected getOutsideFill(): string | undefined {
return this.__zr && this.__zr.isDarkMode() ? LIGHT_LABEL_COLOR : DARK_LABEL_COLOR;
}
protected getOutsideStroke(textFill: string): string {
const backgroundColor = this.__zr && this.__zr.getBackgroundColor();
let colorArr = typeof backgroundColor === 'string' && parse(backgroundColor as string);
if (!colorArr) {
colorArr = [255, 255, 255, 1];
}
// Assume blending on a white / black(dark) background.
const alpha = colorArr[3];
const isDark = this.__zr.isDarkMode();
for (let i = 0; i < 3; i++) {
colorArr[i] = colorArr[i] * alpha + (isDark ? 0 : 255) * (1 - alpha);
}
colorArr[3] = 1;
return stringify(colorArr, 'rgba');
}
traverse<Context>(
cb: (this: Context, el: Element<Props>) => void,
context?: Context
) {}
protected attrKV(key: string, value: unknown) {
if (key === 'textConfig') {
this.setTextConfig(value as ElementTextConfig);
}
else if (key === 'textContent') {
this.setTextContent(value as ZRText);
}
else if (key === 'clipPath') {
this.setClipPath(value as Path);
}
else if (key === 'extra') {
this.extra = this.extra || {};
extend(this.extra, value);
}
else {
(this as any)[key] = value;
}
}
/**
* Hide the element
*/
hide() {
this.ignore = true;
this.markRedraw();
}
/**
* Show the element
*/
show() {
this.ignore = false;
this.markRedraw();
}
attr(keyOrObj: Props): this
attr<T extends keyof Props>(keyOrObj: T, value: Props[T]): this
attr(keyOrObj: keyof Props | Props, value?: unknown): this {
if (typeof keyOrObj === 'string') {
this.attrKV(keyOrObj as keyof ElementProps, value as AllPropTypes<ElementProps>);
}
else if (isObject(keyOrObj)) {
let obj = keyOrObj as object;
let keysArr = keys(obj);
for (let i = 0; i < keysArr.length; i++) {
let key = keysArr[i];
this.attrKV(key as keyof ElementProps, keyOrObj[key]);
}
}
this.markRedraw();
return this;
}
// Save current state to normal
saveCurrentToNormalState(toState: ElementState) {
this._innerSaveToNormal(toState);
// If we are switching from normal to other state during animation.
// We need to save final value of animation to the normal state. Not interpolated value.
const normalState = this._normalState;
for (let i = 0; i < this.animators.length; i++) {
const animator = this.animators[i];
const fromStateTransition = animator.__fromStateTransition;
// Ignore animation from state transition(except normal).
// Ignore loop animation.
if (animator.getLoop() || fromStateTransition && fromStateTransition !== PRESERVED_NORMAL_STATE) {
continue;
}
const targetName = animator.targetName;
// Respecting the order of animation if multiple animator is
// animating on the same property(If additive animation is used)
const target = targetName
? (normalState as any)[targetName] : normalState;
// Only save keys that are changed by the states.
animator.saveTo(target);
}
}
protected _innerSaveToNormal(toState: ElementState) {
let normalState = this._normalState;
if (!normalState) {
// Clear previous stored normal states when switching from normalState to otherState.
normalState = this._normalState = {};
}
if (toState.textConfig && !normalState.textConfig) {
normalState.textConfig = this.textConfig;
}
this._savePrimaryToNormal(toState, normalState, PRIMARY_STATES_KEYS);
}
protected _savePrimaryToNormal(
toState: Dictionary<any>, normalState: Dictionary<any>, primaryKeys: readonly string[]
) {
for (let i = 0; i < primaryKeys.length; i++) {
let key = primaryKeys[i];
// Only save property that will be changed by toState
// and has not been saved to normalState yet.
if (toState[key] != null && !(key in normalState)) {
(normalState as any)[key] = (this as any)[key];
}
}
}
/**
* If has any state.
*/
hasState() {
return this.currentStates.length > 0;
}
/**
* Get state object
*/
getState(name: string) {
return this.states[name];
}
/**
* Ensure state exists. If not, will create one and return.
*/
ensureState(name: string) {
const states = this.states;
if (!states[name]) {
states[name] = {};
}
return states[name];
}
/**
* Clear all states.
*/
clearStates(noAnimation?: boolean) {
this.useState(PRESERVED_NORMAL_STATE, false, noAnimation);
// TODO set _normalState to null?
}
/**
* Use state. State is a collection of properties.
* Will return current state object if state exists and stateName has been changed.
*
* @param stateName State name to be switched to
* @param keepCurrentState If keep current states.
* If not, it will inherit from the normal state.
*/
useState(stateName: string, keepCurrentStates?: boolean, noAnimation?: boolean, forceUseHoverLayer?: boolean) {
// Use preserved word __normal__
// TODO: Only restore changed properties when restore to normal???
const toNormalState = stateName === PRESERVED_NORMAL_STATE;
const hasStates = this.hasState();
if (!hasStates && toNormalState) {
// If switched from normal to normal.
return;
}
const currentStates = this.currentStates;
const animationCfg = this.stateTransition;
// No need to change in following cases:
// 1. Keep current states. and already being applied before.
// 2. Don't keep current states. And new state is same with the only one exists state.
if (indexOf(currentStates, stateName) >= 0 && (keepCurrentStates || currentStates.length === 1)) {
return;
}
let state;
if (this.stateProxy && !toNormalState) {
state = this.stateProxy(stateName);
}
if (!state) {
state = (this.states && this.states[stateName]);
}
if (!state && !toNormalState) {
logError(`State ${stateName} not exists.`);
return;
}
if (!toNormalState) {
this.saveCurrentToNormalState(state);
}
const useHoverLayer = !!((state && state.hoverLayer) || forceUseHoverLayer);
if (useHoverLayer) {
// Enter hover layer before states update.
this._toggleHoverLayerFlag(true);
}
this._applyStateObj(
stateName,
state,
this._normalState,
keepCurrentStates,
!noAnimation && !this.__inHover && animationCfg && animationCfg.duration > 0,
animationCfg
);
// Also set text content.
const textContent = this._textContent;
const textGuide = this._textGuide;
if (textContent) {
// Force textContent use hover layer if self is using it.
textContent.useState(stateName, keepCurrentStates, noAnimation, useHoverLayer);
}
if (textGuide) {
textGuide.useState(stateName, keepCurrentStates, noAnimation, useHoverLayer);
}
if (toNormalState) {
// Clear state
this.currentStates = [];
// Reset normal state.
this._normalState = {};
}
else {
if (!keepCurrentStates) {
this.currentStates = [stateName];
}
else {
this.currentStates.push(stateName);
}
}
// Update animating target to the new object after state changed.
this._updateAnimationTargets();
this.markRedraw();
if (!useHoverLayer && this.__inHover) {
// Leave hover layer after states update and markRedraw.
this._toggleHoverLayerFlag(false);
// NOTE: avoid unexpected refresh when moving out from hover layer!!
// Only clear from hover layer.
this.__dirty &= ~REDRAW_BIT;
}
// Return used state.
return state;
}
/**
* Apply multiple states.
* @param states States list.
*/
useStates(states: string[], noAnimation?: boolean, forceUseHoverLayer?: boolean) {
if (!states.length) {
this.clearStates();
}
else {
const stateObjects: ElementState[] = [];
const currentStates = this.currentStates;
const len = states.length;
let notChange = len === currentStates.length;
if (notChange) {
for (let i = 0; i < len; i++) {
if (states[i] !== currentStates[i]) {
notChange = false;
break;
}
}
}
if (notChange) {
return;
}
for (let i = 0; i < len; i++) {
const stateName = states[i];
let stateObj: ElementState;
if (this.stateProxy) {
stateObj = this.stateProxy(stateName, states);
}
if (!stateObj) {
stateObj = this.states[stateName];
}
if (stateObj) {
stateObjects.push(stateObj);
}
}
const lastStateObj = stateObjects[len - 1];
const useHoverLayer = !!((lastStateObj && lastStateObj.hoverLayer) || forceUseHoverLayer);
if (useHoverLayer) {
// Enter hover layer before states update.
this._toggleHoverLayerFlag(true);
}
const mergedState = this._mergeStates(stateObjects);
const animationCfg = this.stateTransition;
this.saveCurrentToNormalState(mergedState);
this._applyStateObj(
states.join(','),
mergedState,
this._normalState,
false,
!noAnimation && !this.__inHover && animationCfg && animationCfg.duration > 0,
animationCfg
);
const textContent = this._textContent;
const textGuide = this._textGuide;
if (textContent) {
textContent.useStates(states, noAnimation, useHoverLayer);
}
if (textGuide) {
textGuide.useStates(states, noAnimation, useHoverLayer);
}
this._updateAnimationTargets();
// Create a copy
this.currentStates = states.slice();
this.markRedraw();
if (!useHoverLayer && this.__inHover) {
// Leave hover layer after states update and markRedraw.
this._toggleHoverLayerFlag(false);
// NOTE: avoid unexpected refresh when moving out from hover layer!!
// Only clear from hover layer.
this.__dirty &= ~REDRAW_BIT;
}
}
}
/**
* Return if el.silent or any ancestor element has silent true.
*/
isSilent() {
let isSilent = this.silent;
let ancestor = this.parent;
while (!isSilent && ancestor) {
if (ancestor.silent) {
isSilent = true;
break;
}
ancestor = ancestor.parent;
}
return isSilent;
}
/**
* Update animation targets when reference is changed.
*/
private _updateAnimationTargets() {
for (let i = 0; i < this.animators.length; i++) {
const animator = this.animators[i];
if (animator.targetName) {
animator.changeTarget((this as any)[animator.targetName]);
}
}
}
/**
* Remove state
* @param state State to remove
*/
removeState(state: string) {
const idx = indexOf(this.currentStates, state);
if (idx >= 0) {
const currentStates = this.currentStates.slice();
currentStates.splice(idx, 1);
this.useStates(currentStates);
}
}
/**
* Replace exists state.
* @param oldState
* @param newState
* @param forceAdd If still add when even if replaced target not exists.
*/
replaceState(oldState: string, newState: string, forceAdd: boolean) {
const currentStates = this.currentStates.slice();
const idx = indexOf(currentStates, oldState);
const newStateExists = indexOf(currentStates, newState) >= 0;
if (idx >= 0) {
if (!newStateExists) {
// Replace the old with the new one.
currentStates[idx] = newState;
}
else {
// Only remove the old one.
currentStates.splice(idx, 1);
}
}
else if (forceAdd && !newStateExists) {
currentStates.push(newState);
}
this.useStates(currentStates);
}
/**
* Toogle state.
*/
toggleState(state: string, enable: boolean) {
if (enable) {
this.useState(state, true);
}
else {
this.removeState(state);
}
}
protected _mergeStates(states: ElementState[]) {
const mergedState: ElementState = {};
let mergedTextConfig: ElementTextConfig;
for (let i = 0; i < states.length; i++) {
const state = states[i];
extend(mergedState, state);
if (state.textConfig) {
mergedTextConfig = mergedTextConfig || {};
extend(mergedTextConfig, state.textConfig);
}
}
if (mergedTextConfig) {
mergedState.textConfig = mergedTextConfig;
}
return mergedState;
}
protected _applyStateObj(
stateName: string,
state: ElementState,
normalState: ElementState,
keepCurrentStates: boolean,
transition: boolean,
animationCfg: ElementAnimateConfig
) {
const needsRestoreToNormal = !(state && keepCurrentStates);
// TODO: Save current state to normal?
// TODO: Animation
if (state && state.textConfig) {
// Inherit from current state or normal state.
this.textConfig = extend(
{},
keepCurrentStates ? this.textConfig : normalState.textConfig
);
extend(this.textConfig, state.textConfig);
}
else if (needsRestoreToNormal) {
if (normalState.textConfig) { // Only restore if changed and saved.
this.textConfig = normalState.textConfig;
}
}
const transitionTarget: Dictionary<any> = {};
let hasTransition = false;
for (let i = 0; i < PRIMARY_STATES_KEYS.length; i++) {
const key = PRIMARY_STATES_KEYS[i];
const propNeedsTransition = transition && DEFAULT_ANIMATABLE_MAP[key];
if (state && state[key] != null) {
if (propNeedsTransition) {
hasTransition = true;
transitionTarget[key] = state[key];
}
else {
// Replace if it exist in target state
(this as any)[key] = state[key];
}
}
else if (needsRestoreToNormal) {
if (normalState[key] != null) {
if (propNeedsTransition) {
hasTransition = true;
transitionTarget[key] = normalState[key];
}
else {
// Restore to normal state
(this as any)[key] = normalState[key];
}
}
}
}
if (!transition) {
// Keep the running animation to the new values after states changed.
// Not simply stop animation. Or it may have jump effect.
for (let i = 0; i < this.animators.length; i++) {
const animator = this.animators[i];
const targetName = animator.targetName;
// Ignore loop animation
if (!animator.getLoop()) {
animator.__changeFinalValue(targetName
? ((state || normalState) as any)[targetName]
: (state || normalState)
);
}
}
}
if (hasTransition) {
this._transitionState(
stateName,
transitionTarget as Props,
animationCfg
);
}
}
/**
* Component is some elements attached on this element for specific purpose.
* Like clipPath, textContent
*/
private _attachComponent(componentEl: Element) {
if (componentEl.__zr && !componentEl.__hostTarget) {
if (process.env.NODE_ENV !== 'production') {
throw new Error('Text element has been added to zrender.');
}
return;
}
if (componentEl === this) {
if (process.env.NODE_ENV !== 'production') {
throw new Error('Recursive component attachment.');
}
return;
}
const zr = this.__zr;
if (zr) {
// Needs to add self to zrender. For rerender triggering, or animation.
componentEl.addSelfToZr(zr);
}
componentEl.__zr = zr;
componentEl.__hostTarget = this as unknown as Element;
}
private _detachComponent(componentEl: Element) {
if (componentEl.__zr) {
componentEl.removeSelfFromZr(componentEl.__zr);
}
componentEl.__zr = null;
componentEl.__hostTarget = null;
}
/**
* Get clip path
*/
getClipPath() {
return this._clipPath;
}
/**
* Set clip path
*
* clipPath can't be shared between two elements.
*/
setClipPath(clipPath: Path) {
// Remove previous clip path
if (this._clipPath && this._clipPath !== clipPath) {
this.removeClipPath();
}
this._attachComponent(clipPath);
this._clipPath = clipPath;
this.markRedraw();
}
/**
* Remove clip path
*/
removeClipPath() {
const clipPath = this._clipPath;
if (clipPath) {
this._detachComponent(clipPath);
this._clipPath = null;
this.markRedraw();
}
}
/**
* Get attached text content.
*/
getTextContent(): ZRText {
return this._textContent;
}
/**
* Attach text on element
*/
setTextContent(textEl: ZRText) {
const previousTextContent = this._textContent;
if (previousTextContent === textEl) {
return;
}
// Remove previous textContent
if (previousTextContent && previousTextContent !== textEl) {
this.removeTextContent();
}
if (process.env.NODE_ENV !== 'production') {
if (textEl.__zr && !textEl.__hostTarget) {
throw new Error('Text element has been added to zrender.');
}
}
textEl.innerTransformable = new Transformable();
this._attachComponent(textEl);
this._textContent = textEl;
this.markRedraw();
}
/**
* Set layout of attached text. Will merge with the previous.
*/
setTextConfig(cfg: ElementTextConfig) {
// TODO hide cfg property?
if (!this.textConfig) {
this.textConfig = {};
}
extend(this.textConfig, cfg);
this.markRedraw();
}
/**
* Remove text config
*/
removeTextConfig() {
this.textConfig = null;
this.markRedraw();
}
/**
* Remove attached text element.
*/
removeTextContent() {
const textEl = this._textContent;
if (textEl) {
textEl.innerTransformable = null;
this._detachComponent(textEl);
this._textContent = null;
this._innerTextDefaultStyle = null;
this.markRedraw();
}
}
getTextGuideLine(): Polyline {
return this._textGuide;
}
setTextGuideLine(guideLine: Polyline) {
// Remove previous clip path
if (this._textGuide && this._textGuide !== guideLine) {
this.removeTextGuideLine();
}
this._attachComponent(guideLine);
this._textGuide = guideLine;
this.markRedraw();
}
removeTextGuideLine() {
const textGuide = this._textGuide;
if (textGuide) {
this._detachComponent(textGuide);
this._textGuide = null;
this.markRedraw();
}
}
/**
* Mark element needs to be repainted
*/
markRedraw() {
this.__dirty |= REDRAW_BIT;
const zr = this.__zr;
if (zr) {
if (this.__inHover) {
zr.refreshHover();
}
else {
zr.refresh();
}
}
// Used as a clipPath or textContent
if (this.__hostTarget) {
this.__hostTarget.markRedraw();
}
}
/**
* Besides marking elements to be refreshed.
* It will also invalid all cache and doing recalculate next frame.
*/
dirty() {
this.markRedraw();
}
private _toggleHoverLayerFlag(inHover: boolean) {
this.__inHover = inHover;
const textContent = this._textContent;
const textGuide = this._textGuide;
if (textContent) {
textContent.__inHover = inHover;
}
if (textGuide) {
textGuide.__inHover = inHover;
}
}
/**
* Add self from zrender instance.
* Not recursively because it will be invoked when element added to storage.
*/
addSelfToZr(zr: ZRenderType) {
if (this.__zr === zr) {
return;
}
this.__zr = zr;
// 添加动画
const animators = this.animators;
if (animators) {
for (let i = 0; i < animators.length; i++) {
zr.animation.addAnimator(animators[i]);
}
}
if (this._clipPath) {
this._clipPath.addSelfToZr(zr);
}
if (this._textContent) {
this._textContent.addSelfToZr(zr);
}
if (this._textGuide) {
this._textGuide.addSelfToZr(zr);
}
}
/**
* Remove self from zrender instance.
* Not recursively because it will be invoked when element added to storage.
*/
removeSelfFromZr(zr: ZRenderType) {
if (!this.__zr) {
return;
}
this.__zr = null;
// Remove animation
const animators = this.animators;
if (animators) {
for (let i = 0; i < animators.length; i++) {
zr.animation.removeAnimator(animators[i]);
}
}
if (this._clipPath) {
this._clipPath.removeSelfFromZr(zr);
}
if (this._textContent) {
this._textContent.removeSelfFromZr(zr);
}
if (this._textGuide) {
this._textGuide.removeSelfFromZr(zr);
}
}
/**
* 动画
*
* @param path The key to fetch value from object. Mostly style or shape.
* @param loop Whether to loop animation.
* @param allowDiscreteAnimation Whether to allow discrete animation
* @example:
* el.animate('style', false)
* .when(1000, {x: 10} )
* .done(function(){ // Animation done })
* .start()
*/
animate(key?: string, loop?: boolean, allowDiscreteAnimation?: boolean) {
let target = key ? (this as any)[key] : this;
if (process.env.NODE_ENV !== 'production') {
if (!target) {
logError(
'Property "'
+ key
+ '" is not existed in element '
+ this.id
);
return;
}
}
const animator = new Animator(target, loop, allowDiscreteAnimation);
key && (animator.targetName = key);
this.addAnimator(animator, key);
return animator;
}
addAnimator(animator: Animator<any>, key: string): void {
const zr = this.__zr;
const el = this;
animator.during(function () {
el.updateDuringAnimation(key as string);
}).done(function () {
const animators = el.animators;
// FIXME Animator will not be removed if use `Animator#stop` to stop animation
const idx = indexOf(animators, animator);
if (idx >= 0) {
animators.splice(idx, 1);
}
});
this.animators.push(animator);
// If animate after added to the zrender
if (zr) {
zr.animation.addAnimator(animator);
}
// Wake up zrender to start the animation loop.
zr && zr.wakeUp();
}
updateDuringAnimation(key: string) {
this.markRedraw();
}
/**
* 停止动画
* @param {boolean} forwardToLast If move to last frame before stop
*/
stopAnimation(scope?: string, forwardToLast?: boolean) {
const animators = this.animators;
const len = animators.length;
const leftAnimators: Animator<any>[] = [];
for (let i = 0; i < len; i++) {
const animator = animators[i];
if (!scope || scope === animator.scope) {
animator.stop(forwardToLast);
}
else {
leftAnimators.push(animator);
}
}
this.animators = leftAnimators;
return this;
}
/**
* @param animationProps A map to specify which property to animate. If not specified, will animate all.
* @example
* // Animate position
* el.animateTo({
* position: [10, 10]
* }, { done: () => { // done } })
*
* // Animate shape, style and position in 100ms, delayed 100ms, with cubicOut easing
* el.animateTo({
* shape: {
* width: 500
* },
* style: {
* fill: 'red'
* }
* position: [10, 10]
* }, {
* duration: 100,
* delay: 100,
* easing: 'cubicOut',
* done: () => { // done }
* })
*/
animateTo(target: Props, cfg?: ElementAnimateConfig, animationProps?: MapToType<Props, boolean>) {
animateTo(this, target, cfg, animationProps);
}
/**
* Animate from the target state to current state.
* The params and the value are the same as `this.animateTo`.
*/
// Overload definitions
animateFrom(
target: Props, cfg: ElementAnimateConfig, animationProps?: MapToType<Props, boolean>
) {
animateTo(this, target, cfg, animationProps, true);
}
protected _transitionState(
stateName: string, target: Props, cfg?: ElementAnimateConfig, animationProps?: MapToType<Props, boolean>
) {
const animators = animateTo(this, target, cfg, animationProps);
for (let i = 0; i < animators.length; i++) {
animators[i].__fromStateTransition = stateName;
}
}
/**
* Interface of getting the minimum bounding box.
*/
getBoundingRect(): BoundingRect {
return null;
}
getPaintRect(): BoundingRect {
return null;
}
/**
* The string value of `textPosition` needs to be calculated to a real postion.
* For example, `'inside'` is calculated to `[rec