states.ts 14.9 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
import ZRText from 'zrender/src/graphic/Text';
import { Dictionary } from 'zrender/src/core/types';
import LRU from 'zrender/src/core/LRU';
import Displayable, { DisplayableState } from 'zrender/src/graphic/Displayable';
import { PatternObject } from 'zrender/src/graphic/Pattern';
import { GradientObject } from 'zrender/src/graphic/Gradient';
import Element, { ElementEvent } from 'zrender/src/Element';
import Model from '../model/Model';
import { DisplayState, ECElement, ColorString, BlurScope } from './types';
import { extend, indexOf } from 'zrender/src/core/util';
import { __DEV__ } from '../config';
import {
    Z2_EMPHASIS_LIFT,
    getECData,
    _highlightKeyMap
} from './graphic';
import * as colorTool from 'zrender/src/tool/color';
18
import { EChartsType } from '../echarts';
19 20 21 22

// Reserve 0 as default.
export let _highlightNextDigit = 1;

23 24 25 26 27 28 29 30 31 32 33 34 35 36
export const HOVER_STATE_NORMAL: 0 = 0;
export const HOVER_STATE_BLUR: 1 = 1;
export const HOVER_STATE_EMPHASIS: 2 = 2;

type ExtendedProps = {
    __highByOuter: number

    __highDownSilentOnTouch: boolean

    __highDownDispatcher: boolean
};
type ExtendedElement = Element & ExtendedProps;
type ExtendedDisplayable = Displayable & ExtendedProps;

37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
function hasFillOrStroke(fillOrStroke: string | PatternObject | GradientObject) {
    return fillOrStroke != null && fillOrStroke !== 'none';
}
// Most lifted color are duplicated.
const liftedColorCache = new LRU<string>(100);
function liftColor(color: string): string {
    if (typeof color !== 'string') {
        return color;
    }
    let liftedColor = liftedColorCache.get(color);
    if (!liftedColor) {
        liftedColor = colorTool.lift(color, -0.1);
        liftedColorCache.put(color, liftedColor);
    }
    return liftedColor;
}

54 55 56 57 58 59 60
function triggerStateChange(el: ECElement, stateEnum: 0 | 1 | 2, stateName: DisplayState) {
    if ((el.hoverState || 0) !== stateEnum) {
        el.onStateChange && el.onStateChange(stateName);
    }
}

function singleEnterEmphasis(el: ECElement) {
61 62
    // Only mark the flag.
    // States will be applied in the echarts.ts in next frame.
63 64
    triggerStateChange(el, HOVER_STATE_EMPHASIS, 'emphasis');
    el.hoverState = HOVER_STATE_EMPHASIS;
65 66
}

67
function singleLeaveEmphasis(el: ECElement) {
68 69
    // Only mark the flag.
    // States will be applied in the echarts.ts in next frame.
70 71
    triggerStateChange(el, HOVER_STATE_NORMAL, 'normal');
    el.hoverState = HOVER_STATE_NORMAL;
72 73
}

74 75 76 77 78 79 80 81 82 83 84
function singleEnterBlur(el: ECElement) {
    triggerStateChange(el, HOVER_STATE_BLUR, 'blur');
    el.hoverState = HOVER_STATE_BLUR;
}

function singleLeaveBlur(el: ECElement) {
    triggerStateChange(el, HOVER_STATE_NORMAL, 'normal');
    el.hoverState = HOVER_STATE_NORMAL;
}


85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
function updateElementState<T>(
    el: ExtendedElement,
    updater: (this: void, el: Element, commonParam?: T) => void,
    commonParam?: T
) {
    updater(el, commonParam);
}

function traverseUpdateState<T>(
    el: ExtendedElement,
    updater: (this: void, el: Element, commonParam?: T) => void,
    commonParam?: T
) {
    updateElementState(el, updater, commonParam);
    el.isGroup && el.traverse(function (child: ExtendedElement) {
        updateElementState(child, updater, commonParam);
    });
}
/**
 * If we reuse elements when rerender.
 * DONT forget to clearStates before we update the style and shape.
 * Or we may update on the wrong state instead of normal state.
 */
export function clearStates(el: Element) {
    if (el.isGroup) {
        el.traverse(function (child) {
            child.clearStates();
        });
    }
    else {
        el.clearStates();
    }
}
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141

function createEmphasisDefaultState(
    el: Displayable,
    stateName: 'emphasis',
    state: Displayable['states'][number]
) {
    const hasEmphasis = indexOf(el.currentStates, stateName) >= 0;
    if (!(el instanceof ZRText)) {
        const currentFill = el.style.fill;
        const currentStroke = el.style.stroke;
        if (currentFill || currentStroke) {
            let fromState: {
                fill: ColorString;
                stroke: ColorString;
            };
            if (!hasEmphasis) {
                fromState = { fill: currentFill, stroke: currentStroke };
                for (let i = 0; i < el.animators.length; i++) {
                    const animator = el.animators[i];
                    if (animator.__fromStateTransition
                        // Dont consider the animation to emphasis state.
                        && animator.__fromStateTransition.indexOf(stateName) < 0
                        && animator.targetName === 'style') {
                        animator.saveFinalToTarget(fromState, ['fill', 'stroke']);
142 143
                    }
                }
144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
            }
            state = state || {};
            // Apply default color lift
            let emphasisStyle = state.style || {};
            let cloned = false;
            if (!hasFillOrStroke(emphasisStyle.fill)) {
                cloned = true;
                // Not modify the original value.
                state = extend({}, state);
                emphasisStyle = extend({}, emphasisStyle);
                // Already being applied 'emphasis'. DON'T lift color multiple times.
                emphasisStyle.fill = hasEmphasis ? currentFill : liftColor(fromState.fill);
            }
            if (!hasFillOrStroke(emphasisStyle.stroke)) {
                if (!cloned) {
159 160 161
                    state = extend({}, state);
                    emphasisStyle = extend({}, emphasisStyle);
                }
162
                emphasisStyle.stroke = hasEmphasis ? currentStroke : liftColor(fromState.stroke);
163
            }
164
            state.style = emphasisStyle;
165
        }
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219
    }
    if (state) {
        const z2EmphasisLift = (el as ECElement).z2EmphasisLift;
        // TODO Share with textContent?
        state.z2 = el.z2 + (z2EmphasisLift != null ? z2EmphasisLift : Z2_EMPHASIS_LIFT);
    }
    return state;
}

function createBlurDefaultState(
    el: Displayable,
    stateName: 'blur',
    state: Displayable['states'][number]
) {
    const hasBlur = indexOf(el.currentStates, stateName) >= 0;
    const currentOpacity = el.style.opacity;
    const fromState = {
        opacity: currentOpacity == null ? 1 : currentOpacity
    };
    if (!hasBlur) {
        for (let i = 0; i < el.animators.length; i++) {
            const animator = el.animators[i];
            if (animator.__fromStateTransition
                // Dont consider the animation to emphasis state.
                && animator.__fromStateTransition.indexOf(stateName) < 0
                && animator.targetName === 'style') {
                animator.saveFinalToTarget(fromState, ['opacity']);
            }
        }
    }

    state = state || {};
    let blurStyle = state.style || {};
    if (blurStyle.opacity == null) {
        // clone state
        state = extend({}, state);
        blurStyle = extend({
            // Already being applied 'emphasis'. DON'T mul opacity multiple times.
            opacity: hasBlur ? currentOpacity : (fromState.opacity * 0.1)
        }, blurStyle);
        state.style = blurStyle;
    }

    return state;
}

function elementStateProxy(this: Displayable, stateName: string): DisplayableState {
    const state = this.states[stateName];
    if (this.style) {
        if (stateName === 'emphasis') {
            return createEmphasisDefaultState(this, stateName, state);
        }
        else if (stateName === 'blur') {
            return createBlurDefaultState(this, stateName, state);
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268
        }
    }
    return state;
}
/**FI
 * Set hover style (namely "emphasis style") of element.
 * @param el Should not be `zrender/graphic/Group`.
 * @param focus 'self' | 'selfInSeries' | 'series'
 */
export function enableElementHoverEmphasis(el: Displayable) {
    el.stateProxy = elementStateProxy;
    const textContent = el.getTextContent();
    const textGuide = el.getTextGuideLine();
    if (textContent) {
        textContent.stateProxy = elementStateProxy;
    }
    if (textGuide) {
        textGuide.stateProxy = elementStateProxy;
    }
}

export function enterEmphasisWhenMouseOver(el: Element, e: ElementEvent) {
    !shouldSilent(el, e)
        // "emphasis" event highlight has higher priority than mouse highlight.
        && !(el as ExtendedElement).__highByOuter
        && traverseUpdateState((el as ExtendedElement), singleEnterEmphasis);
}

export function leaveEmphasisWhenMouseOut(el: Element, e: ElementEvent) {
    !shouldSilent(el, e)
        // "emphasis" event highlight has higher priority than mouse highlight.
        && !(el as ExtendedElement).__highByOuter
        && traverseUpdateState((el as ExtendedElement), singleLeaveEmphasis);
}

export function enterEmphasis(el: Element, highlightDigit?: number) {
    (el as ExtendedElement).__highByOuter |= 1 << (highlightDigit || 0);
    traverseUpdateState((el as ExtendedElement), singleEnterEmphasis);
}

export function leaveEmphasis(el: Element, highlightDigit?: number) {
    !((el as ExtendedElement).__highByOuter &= ~(1 << (highlightDigit || 0)))
        && traverseUpdateState((el as ExtendedElement), singleLeaveEmphasis);
}

function shouldSilent(el: Element, e: ElementEvent) {
    return (el as ExtendedElement).__highDownSilentOnTouch && e.zrByTouch;
}

269 270 271 272 273 274 275 276
export function toggleSeriesBlurStates(
    targetSeriesIndex: number,
    focus: string,
    blurScope: BlurScope,
    ecIns: EChartsType,
    isBlur: boolean
) {
    if (targetSeriesIndex == null) {
277 278 279
        return;
    }

280 281 282 283 284 285
    const model = ecIns.getModel();
    blurScope = blurScope || 'coordinateSystem';

    if (!(focus === 'series' || focus === 'self')) {
        return;
    }
286 287

    model.eachSeries(function (seriesModel) {
288 289 290 291 292 293 294 295 296 297 298 299 300 301
        const sameSeries = targetSeriesIndex === seriesModel.seriesIndex;
        if (!(
            blurScope === 'series' && !sameSeries   // Not blur other series if blurScope series
          || focus === 'series' && sameSeries   // Not blur self series if focus is series.
          // TODO blurScope: coordinate system
        )) {
            const view = ecIns.getViewOfSeriesModel(seriesModel);
            view.group.traverse(function (child) {
                // TODO getECData will mount an empty object on the element.
                // DON'T mount on all elements?
                const otherECData = getECData(child);
                // TODO distinguish edge data in graph.
                if (otherECData.dataIndex != null) {
                    if (isBlur) {
302 303
                        traverseUpdateState((child as ExtendedElement), singleEnterBlur);
                    }
304 305 306 307 308
                    else {
                        traverseUpdateState((child as ExtendedElement), singleLeaveBlur);
                    }
                    // No need to traverse the children.
                    return true;
309
                }
310 311
            });
        }
312 313 314
    });
}

315 316 317 318 319 320 321 322 323 324 325 326 327
/**
 * Enable the function that mouseover will trigger the emphasis state.
 *
 * NOTICE:
 * Call the method for a "root" element once. Do not call it for each descendants.
 * If the descendants elemenets of a group has itself hover style different from the
 * root group, we can simply mount the style on `el.states.emphasis` for them, but should
 * not call this method for them.
 */
export function enableHoverEmphasis(el: Element, focus?: string, blurScope?: BlurScope) {
    setAsHighDownDispatcher(el, true);
    traverseUpdateState(el as ExtendedElement, enableElementHoverEmphasis);
    const ecData = getECData(el);
328 329
    if (ecData.dataIndex == null && focus != null) {
        if (__DEV__) {
330 331 332
            console.warn('focus can only been set on element with dataIndex');
        }
    }
333 334 335 336
    else {
        ecData.focus = focus;
        ecData.blurScope = blurScope;
    }
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364
}

const OTHER_STATES = ['emphasis', 'blur', 'select'] as const;
const styleGetterMap: Dictionary<'getItemStyle' | 'getLineStyle' | 'getAreaStyle'> = {
    itemStyle: 'getItemStyle',
    lineStyle: 'getLineStyle',
    areaStyle: 'getAreaStyle'
};
/**
 * Set emphasis/blur/selected states of element.
 */
export function setStatesStylesFromModel(
    el: Displayable,
    itemModel: Model<Partial<Record<'emphasis' | 'blur' | 'select', any>>>,
    styleType?: string, // default itemStyle
    getterType?: 'getItemStyle' | 'getLineStyle' | 'getAreaStyle'
) {
    styleType = styleType || 'itemStyle';
    for (let i = 0; i < OTHER_STATES.length; i++) {
        const stateName = OTHER_STATES[i];
        const model = itemModel.getModel([stateName, styleType]);
        const state = el.ensureState(stateName);
        // Let it throw error if getterType is not found.
        state.style = model[getterType || styleGetterMap[styleType]]();
    }
}

/**
365 366
 * @parame el
 * @param el.highDownSilentOnTouch
367 368 369 370 371 372 373 374 375
 *        In touch device, mouseover event will be trigger on touchstart event
 *        (see module:zrender/dom/HandlerProxy). By this mechanism, we can
 *        conveniently use hoverStyle when tap on touch screen without additional
 *        code for compatibility.
 *        But if the chart/component has select feature, which usually also use
 *        hoverStyle, there might be conflict between 'select-highlight' and
 *        'hover-highlight' especially when roam is enabled (see geo for example).
 *        In this case, `highDownSilentOnTouch` should be used to disable
 *        hover-highlight on touch device.
376
 * @param asDispatcher If `false`, do not set as "highDownDispatcher".
377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
 */
export function setAsHighDownDispatcher(el: Element, asDispatcher: boolean) {
    const disable = asDispatcher === false;
    const extendedEl = el as ExtendedElement;
    // Make `highDownSilentOnTouch` and `onStateChange` only work after
    // `setAsHighDownDispatcher` called. Avoid it is modified by user unexpectedly.
    if ((el as ECElement).highDownSilentOnTouch) {
        extendedEl.__highDownSilentOnTouch = (el as ECElement).highDownSilentOnTouch;
    }
    // Simple optimize, since this method might be
    // called for each elements of a group in some cases.
    if (!disable || extendedEl.__highDownDispatcher) {
        // Emphasis, normal can be triggered manually by API or other components like hover link.
        // el[method]('emphasis', onElementEmphasisEvent)[method]('normal', onElementNormalEvent);
        // Also keep previous record.
        extendedEl.__highByOuter = extendedEl.__highByOuter || 0;
        extendedEl.__highDownDispatcher = !disable;
    }
}

export function isHighDownDispatcher(el: Element): boolean {
    return !!(el && (el as ExtendedDisplayable).__highDownDispatcher);
}

/**
 * Support hightlight/downplay record on each elements.
 * For the case: hover highlight/downplay (legend, visualMap, ...) and
 * user triggerred hightlight/downplay should not conflict.
 * Only all of the highlightDigit cleared, return to normal.
 * @param {string} highlightKey
 * @return {number} highlightDigit
 */
export function getHighlightDigit(highlightKey: number) {
    let highlightDigit = _highlightKeyMap[highlightKey];
    if (highlightDigit == null && _highlightNextDigit <= 32) {
        highlightDigit = _highlightKeyMap[highlightKey] = _highlightNextDigit++;
    }
    return highlightDigit;
415
}