diff --git a/src/Element.ts b/src/Element.ts index 6b1377c50..7237a7000 100644 --- a/src/Element.ts +++ b/src/Element.ts @@ -4,7 +4,7 @@ import Animator, {cloneValue} from './animation/Animator'; import { ZRenderType } from './zrender'; import { Dictionary, ElementEventName, ZRRawEvent, BuiltinTextPosition, AllPropTypes, - TextVerticalAlign, TextAlign, MapToType + TextVerticalAlign, TextAlign, MapToType, } from './core/types'; import Path from './graphic/Path'; import BoundingRect, { RectLike } from './core/BoundingRect'; @@ -288,7 +288,13 @@ export type ElementStatePropNames = (typeof PRIMARY_STATES_KEYS)[number] | 'text export type ElementState = Pick & ElementCommonState export type ElementCommonState = { - hoverLayer?: boolean + /** + * NOTICE: Only canvas renderer supports hover layer. Users must not set hoverLayer + * flag in non-canvas renderer, otherwise it may cause unexpected behavior. + * + * A truthy value (regardless of number or boolean) means hover layer is used. + */ + hoverLayer?: boolean | number } export type ElementCalculateTextPosition = ( @@ -301,6 +307,23 @@ const tmpTextPosCalcRes = {} as TextPositionCalculationResult; const tmpBoundingRect = new BoundingRect(0, 0, 0, 0); const tmpInnerTextTrans: number[] = []; + +// It indicates a status of the element - whether it should be rendered or have been rendered +// in a hover layer. +// It also record the restriction of props changes when entering the hover status. +// A falsy value means not in haver layer; a truthy value means in haver layer. +export type InHoverLayerKind = + typeof IN_HOVER_LAYER_KIND_NO + | typeof IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE; + // | typeof IN_HOVER_LAYER_KIND_NO_LIMIT; +// Not in hover layer. +export const IN_HOVER_LAYER_KIND_NO = 0; +// In hover layer and only style change when entering hover layer. +export const IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE = 1; +// In hover layer and no restriction of changing. +// export const IN_HOVER_LAYER_KIND_NO_LIMIT = 2; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Element extends Transformable, Eventful<{ @@ -391,11 +414,50 @@ class Element { __isRendered: boolean; /** - * If element has been moved to the hover layer. + * Whether this element has been moved to the hover layer. + * If so, dirty will only trigger the zrender refresh hover layer. * - * If so, dirty will only trigger the zrender refresh hover layer - */ - __inHover: boolean + * Hover layer is typically useful for progressive rendering case, + * where the underlying layers can remain not dirty for most hovering + * interactions. + * + * [HOVER_LAYER_CONSTRAINTS]: + * + * The "hover layer" mechanism expects the changes are applied only on a hover layer, while the original + * layer should not be repainted. However, subsequent user operations may still require the original layer + * to be repainted. If the element props have been modified due to hover state switching, the final effect + * will differ unexpectedly after repainting. + * For example, suppose a hover state defines different opacity, color and transform scale. when hovering + * triggers that state, an extra glyph with those props is rendered on the hover layer and overlays the + * the original glyph, but the original layer remains unchanged. The final effect is a visual composition + * of the two. Then if clicking something to trigger a repaint of all layers (e.g., click echarts legend + * to hide and then show them, or triggered by axisPointer, where hover style is expected to keep displaying), + * and if it is rendered on the normal layer differently, the final composition is changed unexpectedly. + * + * Several candidate approches may resolve this issue: + * (A) Clone elements for hover layer rendering. This might be a thorough solution, since all of the original + * elements remain intact and can be repainted to the original layer without changes. + * (B) Introduce a separate `__hoverStyle` to keep the original `this.style` unchanged, and only styles + * changes are allowed in entering or leaving hover layer via `useState` and `useStates` while other changes + * are ignored. And for simplicity, and no separate structures are provided for storing other props. + * This approach can resolve many cases, but is still problematic in some cases. + * + * PENDING: + * 1. Currently we simply implement (B), until some concrete scenarios require (A) in future. + * 2. [HOVER_LAYER_CONSTRAINTS_TEXT] + * Consider: + * - Text style change may lead to creating or updating of subText elements (TSpan). + * - An special handling can be make in (B) - if the element is not rendered on the original layer + * (typically due to `ignore: true` or `invisible: true`), it can be rendered to the hover layer + * without the restriction "only style can change". This is useful to the scenario "hover an + * element to show its _textContent". + * All these cases require display list to be re-built, or need a exclusive display list for hover layer, + * and more precise dirty bit (REDRAW_BIT) handling is needed for that. + * But it would introduce considerable complexity. And unlike `Path`, rendering the same text in multiple + * layers may cause undesirable visual effect. Therefore, we do not implement it unless required. Currently + * hover layer is disabled for text. Text must still be rendered, since it may carry important infomation. + */ + __inHover: InHoverLayerKind __clipPaths?: Path[] @@ -926,11 +988,11 @@ class Element { this.saveCurrentToNormalState(state); } - const useHoverLayer = !!((state && state.hoverLayer) || forceUseHoverLayer); - - if (useHoverLayer) { + const textContent = this._textContent; + const useHoverLayer = shouldUseHoverLayer(this, textContent, state, forceUseHoverLayer); + if (useHoverLayer && !this.__inHover) { // Enter hover layer before states update. - this._toggleHoverLayerFlag(true); + this.__inHover = useHoverLayer; } this._applyStateObj( @@ -938,19 +1000,18 @@ class Element { state, this._normalState, keepCurrentStates, - !noAnimation && !this.__inHover && animationCfg && animationCfg.duration > 0, - animationCfg + canTransition(this, noAnimation, animationCfg), + 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); + textContent.useState(stateName, keepCurrentStates, noAnimation, !!useHoverLayer); } if (textGuide) { - textGuide.useState(stateName, keepCurrentStates, noAnimation, useHoverLayer); + textGuide.useState(stateName, keepCurrentStates, noAnimation, !!useHoverLayer); } if (toNormalState) { @@ -975,7 +1036,7 @@ class Element { if (!useHoverLayer && this.__inHover) { // Leave hover layer after states update and markRedraw. - this._toggleHoverLayerFlag(false); + this.__inHover = IN_HOVER_LAYER_KIND_NO; // NOTE: avoid unexpected refresh when moving out from hover layer!! // Only clear from hover layer. this.__dirty &= ~REDRAW_BIT; @@ -1025,10 +1086,11 @@ class Element { } const lastStateObj = stateObjects[len - 1]; - const useHoverLayer = !!((lastStateObj && lastStateObj.hoverLayer) || forceUseHoverLayer); - if (useHoverLayer) { + const textContent = this._textContent; + const useHoverLayer = shouldUseHoverLayer(this, textContent, lastStateObj, forceUseHoverLayer); + if (useHoverLayer && !this.__inHover) { // Enter hover layer before states update. - this._toggleHoverLayerFlag(true); + this.__inHover = useHoverLayer; } const mergedState = this._mergeStates(stateObjects); @@ -1041,17 +1103,16 @@ class Element { mergedState, this._normalState, false, - !noAnimation && !this.__inHover && animationCfg && animationCfg.duration > 0, - animationCfg + canTransition(this, noAnimation, animationCfg), + animationCfg, ); - const textContent = this._textContent; const textGuide = this._textGuide; if (textContent) { - textContent.useStates(states, noAnimation, useHoverLayer); + textContent.useStates(states, noAnimation, !!useHoverLayer); } if (textGuide) { - textGuide.useStates(states, noAnimation, useHoverLayer); + textGuide.useStates(states, noAnimation, !!useHoverLayer); } this._updateAnimationTargets(); @@ -1062,7 +1123,7 @@ class Element { if (!useHoverLayer && this.__inHover) { // Leave hover layer after states update and markRedraw. - this._toggleHoverLayerFlag(false); + this.__inHover = IN_HOVER_LAYER_KIND_NO; // NOTE: avoid unexpected refresh when moving out from hover layer!! // Only clear from hover layer. this.__dirty &= ~REDRAW_BIT; @@ -1176,8 +1237,11 @@ class Element { transition: boolean, animationCfg: ElementAnimateConfig ) { - const needsRestoreToNormal = !(state && keepCurrentStates); + if (this.__inHover === IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE) { + return; + } + const needsRestoreToNormal = !(state && keepCurrentStates); // TODO: Save current state to normal? // TODO: Animation if (state && state.textConfig) { @@ -1446,18 +1510,6 @@ class Element { 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. @@ -1694,8 +1746,9 @@ class Element { elProto.isGroup = elProto.draggable = elProto.dragging = - elProto.ignoreClip = - elProto.__inHover = false; + elProto.ignoreClip = false; + + elProto.__inHover = IN_HOVER_LAYER_KIND_NO; elProto.__dirty = REDRAW_BIT; @@ -2080,4 +2133,40 @@ function animateToShallow( } } +function shouldUseHoverLayer( + el: Element, + textContent: Element, + nextState: ElementState, + forceUseHoverLayer: boolean +): InHoverLayerKind { + return ( + !((nextState && nextState.hoverLayer) || forceUseHoverLayer) + // PENDING: See HOVER_LAYER_CONSTRAINTS_TEXT for the reasons. + || isTextRelatedEl(el) + || (textContent && isTextRelatedEl(textContent)) + ) + ? IN_HOVER_LAYER_KIND_NO + // If using haver layer and previously it is not in a hover layer and invisible. + // PENDING: See HOVER_LAYER_CONSTRAINTS_TEXT for the reasons. + // : (!el.__inHover && (el.ignore || (el as DisplayableProps).invisible)) + // ? IN_HOVER_LAYER_KIND_NO_LIMIT + // Otherwise (typically, perviously anything has been painted on the original layer), + // only styles can be modified. See more detailed reasons in `HOVER_LAYER_CONSTRAINTS`. + : IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE; +} + +function isTextRelatedEl(el: Element): boolean { + return el.type === 'text' || el.type === 'tspan'; +} + + +function canTransition( + el: Element, + noAnimation: boolean, + animationCfg: ElementAnimateConfig +): boolean { + return !noAnimation && !el.__inHover && animationCfg && animationCfg.duration > 0; +} + + export default Element; diff --git a/src/PainterBase.ts b/src/PainterBase.ts index fa42c37ad..8775d1db6 100644 --- a/src/PainterBase.ts +++ b/src/PainterBase.ts @@ -1,6 +1,7 @@ import { GradientObject } from './graphic/Gradient'; import { PatternObject } from './graphic/Pattern'; -import { Dictionary } from './core/types'; +import { Dictionary, NullUndefined } from './core/types'; +import { CanvasPainterRefreshOpt } from './canvas/Painter'; // interface PainterOption { // width?: number | string // Can be 10 / 10px / auto @@ -20,7 +21,7 @@ export interface PainterBase { // constructor(dom: HTMLElement, storage: Storage, opts: PainterOption, id: number): void resize(width?: number | string, height?: number | string): void - refresh(): void + refresh(opt?: CanvasPainterRefreshOpt | NullUndefined): void clear(): void // must be given if ssr is true. @@ -35,8 +36,6 @@ export interface PainterBase { getViewportRoot: () => HTMLElement getViewportRootOffset: () => {offsetLeft: number, offsetTop: number} - refreshHover(): void - configLayer(zlevel: number, config: Dictionary): void setBackgroundColor(backgroundColor: string | GradientObject | PatternObject): void } \ No newline at end of file diff --git a/src/Storage.ts b/src/Storage.ts index 4c38b0f7b..548654cff 100644 --- a/src/Storage.ts +++ b/src/Storage.ts @@ -79,6 +79,8 @@ export default class Storage { displayList.length = this._displayListLen; + // PENDING: Indicatively, it may cost over 10~20ms when list length is over 1e5. + // See PENDING_SEPARATE_DISPLAY_LIST timsort(displayList, shapeCompareFunc); } diff --git a/src/canvas/Layer.ts b/src/canvas/Layer.ts index c887d884b..e1e3c3911 100644 --- a/src/canvas/Layer.ts +++ b/src/canvas/Layer.ts @@ -3,9 +3,12 @@ import {devicePixelRatio} from '../config'; import { ImagePatternObject } from '../graphic/Pattern'; import CanvasPainter from './Painter'; import { GradientObject, InnerGradientObject } from '../graphic/Gradient'; -import { ZRCanvasRenderingContext } from '../core/types'; +import { + INCREMENTAL_ID_FALSE, IncrementalId, ZLevel, ZLevel2, ZLEVEL2_NORMAL_BELOW, + ZRCanvasRenderingContext +} from '../core/types'; import Eventful from '../core/Eventful'; -import { ElementEventCallback } from '../Element'; +import Element, { ElementEventCallback } from '../Element'; import { getCanvasGradient } from './helper'; import { createCanvasPattern } from './graphic'; import Displayable from '../graphic/Displayable'; @@ -35,6 +38,19 @@ function createDom(id: string, painter: CanvasPainter, dpr: number) { return newDom; } +export function isIncrementalLayer(layer: Layer): boolean { + return !layer.__cursors.get(INCREMENTAL_ID_FALSE); +} + +function getStartEndFromCursor(layer: Layer): LayerDrawCursorStartEnd { + // this.__cursors.get(INCREMENTAL_ID_FALSE) is absent if incremental. + const cursor = layer.__cursors.get(INCREMENTAL_ID_FALSE); + return { + startIdx: cursor ? cursor.startIdx : 0, + endIdx: cursor ? cursor.endIdx : 0, + }; +} + export interface LayerConfig { // 每次清空画布的颜色 clearColor?: string | GradientObject | ImagePatternObject @@ -44,6 +60,34 @@ export interface LayerConfig { lastFrameAlpha?: number }; +export interface LayerDrawCursor { + key: IncrementalId + // Mark using for one run of `Painter['refresh']` + used: boolean + // The next index (of `displayList`) to be drawn. + // CANVAS_INCREMENTAL_CASE_MULTIPLE_ELEMENTS is implemented by this pointer. + drawIdx: number + // The first `notClear` el in this cursor to be drawn. + // If `-1`, no `notClear` el need to be drawn in this cursor in this pass. + // CANVAS_INCREMENTAL_CASE_SINGLE_ELEMENT is implemented by this pointer. + notClearIdx: number + startIdx: number + // The max index + 1 (of `displayList`) can be drawn. + endIdx: number + endIdxNew: number + // Element ids on this layer in the last pass. + // For incremental layer, only save the `els[0]`. + // ids: Element['id'][] + // idsLen: number + // The first element id corresponding to `startIdx`. + first: Element['id'] + // The end element id corresponding to `endIdx`. + last: Element['id'] +} + +type LayerDrawCursorStartEnd = Pick; + + export default class Layer extends Eventful { id: string @@ -76,31 +120,35 @@ export default class Layer extends Eventful { /** * Virtual layer will not be inserted into dom. + * It may be set outside zrender, e.g., by echarts-gl. */ virtual = false config = {} - incremental = false - - zlevel = 0 + zlevel: ZLevel = 0 + zlevel2: ZLevel2 = ZLEVEL2_NORMAL_BELOW maxRepaintRectCount = 5 private _paintRects: BoundingRect[] + // `__dirty` means need clear the canvas. __dirty = true - __firstTimePaint = true - __used = false + __firstTimePaint = true - __drawIndex = 0 - __startIndex = 0 - __endIndex = 0 + // `__cursorStack` represents existing draw cursors and the draw order. + // Each item is a key of `__cursors`. + // For non-incremental layer, only `0` is included. + // For incremental layer, do `0` is not included. + __cursorStack: IncrementalId[] + // Do not iterate `__cursors`; iterate `__cursorStack` instead. + __cursors: util.HashMap // indices in the previous frame - __prevStartIndex: number = null - __prevEndIndex: number = null + // Used on dirty rect rebuild. + __prevIdx: LayerDrawCursorStartEnd = {startIdx: 0, endIdx: 0} __builtin__: boolean @@ -134,13 +182,8 @@ export default class Layer extends Eventful { this.dpr = dpr; } - getElementCount() { - return this.__endIndex - this.__startIndex; - } - afterBrush() { - this.__prevStartIndex = this.__startIndex; - this.__prevEndIndex = this.__endIndex; + this.__prevIdx = getStartEndFromCursor(this); } initContext() { @@ -249,7 +292,8 @@ export default class Layer extends Eventful { * Loop the paint list of this frame and get the dirty rects of elements * in this frame. */ - for (let i = this.__startIndex; i < this.__endIndex; ++i) { + const se = getStartEndFromCursor(this); + for (let i = se.startIdx; i < se.endIdx; ++i) { const el = displayList[i]; if (el) { /** @@ -294,7 +338,8 @@ export default class Layer extends Eventful { * paint list this frame, which does not include those elements removed * in this frame. So we loop the `prevList` to get the removed elements. */ - for (let i = this.__prevStartIndex; i < this.__prevEndIndex; ++i) { + const prevIdx = this.__prevIdx; + for (let i = prevIdx.startIdx; i < prevIdx.endIdx; ++i) { const el = prevList[i]; /** * Consider the elements whose ancestors are invisible, they should diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 568885692..c047dc292 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -1,25 +1,41 @@ import {devicePixelRatio} from '../config'; import * as util from '../core/util'; -import Layer, { LayerConfig } from './Layer'; +import Layer, { isIncrementalLayer, LayerConfig, LayerDrawCursor } from './Layer'; import requestAnimationFrame from '../animation/requestAnimationFrame'; import env from '../core/env'; import Displayable from '../graphic/Displayable'; -import { WXCanvasRenderingContext } from '../core/types'; +import { + IncrementalIdCompat, NullUndefined, WXCanvasRenderingContext, + ZLevel, ZLevel2, ZLEVEL2_INCREMENTAL, ZLEVEL2_NORMAL_ABOVE, ZLEVEL2_NORMAL_BELOW +} from '../core/types'; import { GradientObject } from '../graphic/Gradient'; import { ImagePatternObject } from '../graphic/Pattern'; import Storage from '../Storage'; -import { brush, BrushScope, brushSingle } from './graphic'; +import { brush, brushLoopFinalize, BrushScope, brushSingle } from './graphic'; import { PainterBase } from '../PainterBase'; import BoundingRect from '../core/BoundingRect'; import { REDRAW_BIT } from '../graphic/constants'; import { getSize } from './helper'; -import type IncrementalDisplayable from '../graphic/IncrementalDisplayable'; +import { platformApi } from '../core/platform'; + const HOVER_LAYER_ZLEVEL = 1e5; +// zlevel for the case that `Painter['_singleCanvas']` is `true`. const CANVAS_ZLEVEL = 314159; -const EL_AFTER_INCREMENTAL_INC = 0.01; -const INCREMENTAL_INC = 0.001; +// Truthy value means dirty. +type HoverLayerDirty = + typeof HOVER_LAYER_DIRTY_NO + | typeof HOVER_LAYER_DIRTY_REPAINT_IF_EXISTING + | typeof HOVER_LAYER_DIRTY_REPAINT +// Do noting to hover layer. +const HOVER_LAYER_DIRTY_NO: undefined = undefined; +// Repaint only if existing. In most cases hover layer is not used, +// do not need to travel one more time to detect hover state. +const HOVER_LAYER_DIRTY_REPAINT_IF_EXISTING = 1; +// Create a hover layer if not existing, and repaint. +const HOVER_LAYER_DIRTY_REPAINT = 2; + function isLayerValid(layer: Layer) { @@ -63,13 +79,134 @@ function createRoot(width: number, height: number) { return domRoot; } +function createBuiltinLayer( + id: string | HTMLCanvasElement, + painter: CanvasPainter, + zlevel: ZLevel, + zlevel2: ZLevel2 +): Layer { + const layer = new Layer(id, painter, painter.dpr); + layer.zlevel = zlevel; + layer.zlevel2 = zlevel2; + layer.__builtin__ = true; + resetLayerDrawCursors(layer); + return layer; +} + interface CanvasPainterOption { devicePixelRatio?: number width?: number | string // Can be 10 / 10px / auto - height?: number | string, + height?: number | string useDirtyRect?: boolean } +export type CanvasPainterRefreshOpt = { + // repaint all displayable, rather than only dirty ones. + paintAll?: boolean; + + // By default true. Can set to false to skip the normal repaint for + // the case that only hover layer need to be repainted. + refresh?: boolean; + // By default false. If true, for repaint hover layer. + // Note that a hover layer will also be repainted if normal layers are + // repainted and mark dirty to hover layer, even if refreshHover is false. + refreshHover?: boolean; +} + +type LayerKey = { + zl: ZLevel; + zl2: ZLevel2; +}; + +// const LAYER_CURSOR_IDS_MAX = 1e3; // A safeguard + +function resetLayerDrawCursors(layer: Layer): void { + layer.__cursorStack = []; + layer.__cursors = util.createHashMap(); +} + +function resetLayerDrawCursor(cursor: LayerDrawCursor): LayerDrawCursor { + cursor.startIdx = cursor.drawIdx = cursor.endIdx = cursor.endIdxNew = 0; + cursor.used = false; + cursor.first = cursor.last = NaN; + cursor.notClearIdx = -1; + // cursor.idsLen = 0; + // NOTE: cursor.key should not be modified after being created. + return cursor; +} + +// Get the cursor, create one if not exist. +function ensureLayerDrawCursor(layer: Layer, incrementalCompat: IncrementalIdCompat): LayerDrawCursor { + const cursors = layer.__cursors; + const incremental = +incrementalCompat; + return cursors.get(incremental) + || ( + layer.__cursorStack.push(incremental), + cursors.set(incremental, resetLayerDrawCursor({key: incremental/*, ids: []*/} as LayerDrawCursor)) + ); +} + +function eachCursorInLayer(layer: Layer, cb: (cursor: LayerDrawCursor) => void): void { + const cursorStack = layer.__cursorStack; + for (let i = 0; i < cursorStack.length; i++) { + cb(layer.__cursors.get(cursorStack[i])); + } +} + +function ensureLayerListInZLevel(internal: CanvasPainterInternal, zlevel: ZLevel): Layer[] { + const layers = internal.layers; + return layers[zlevel] || (layers[zlevel] = new Array(3)); // See `ZLevel2` +} + +/** + * Iterate existing layers in ascending z-order. + */ +function eachLayer( + internal: CanvasPainterInternal, + cb: ( + layer: Layer, // Never be null/undefined + zlevel: number, zlevel2: number, idx: number + ) => void, + filter?: EachLayerFilter, +) { + const layerStack = internal.layerStack; + for (let i = 0; i < layerStack.length; i++) { + const zlevel = layerStack[i].zl; + const zlevel2 = layerStack[i].zl2; + const layer = internal.layers[zlevel][zlevel2]; + if (!filter || ( + (!(filter & EACH_LAYER_BUILTIN) || layer.__builtin__) + && (!(filter & EACH_LAYER_NOT_BUILTIN) || !layer.__builtin__) + && (!(filter & EACH_LAYER_NOT_HOVER) || layer !== internal.hoverlayer) + )) { + cb(layer, zlevel, zlevel2, i); + } + } +} + +// Can be `EACH_LAYER_BUILTIN | EACH_LAYER_NO_HOVER`, +// which means "built-in" and "not hover layer". +// By default `0` means no filter - iterate all layers. +type EachLayerFilter = number; +const EACH_LAYER_BUILTIN = 1; +const EACH_LAYER_NOT_BUILTIN = 2; +const EACH_LAYER_NOT_HOVER = 4; +const EACH_LAYER_BUILTIN_NOT_HOVER = EACH_LAYER_BUILTIN | EACH_LAYER_NOT_HOVER; + + +interface CanvasPainterInternal { + // Order is maintained by zlevel and zlevel2. + // This list represents the existing layers and the actual z-order. + layerStack: LayerKey[]; + // structure: _layers[zlevel][zlevel2] + // See more details in CANVAS_LAYER_STACKING + // CAVEAT: + // Do not iterate `layers`; iterate `layerStack` instead. + layers: Layer[][]; + + hoverlayer?: Layer; +} + export default class CanvasPainter implements PainterBase { type = 'canvas' @@ -80,16 +217,14 @@ export default class CanvasPainter implements PainterBase { storage: Storage + private _i: CanvasPainterInternal; + private _singleCanvas: boolean private _opts: CanvasPainterOption - private _zlevelList: number[] = [] - private _prevDisplayList: Displayable[] = [] - private _layers: {[key: number]: Layer} = {} // key is zlevel - private _layerConfig: {[key: number]: LayerConfig} = {} // key is zlevel /** @@ -102,7 +237,15 @@ export default class CanvasPainter implements PainterBase { private _domRoot: HTMLElement - private _hoverlayer: Layer + // hover layer is created only when needed, not save dirty flag separately. + // We need to detect hover layer requirements in this cases: + // (A) Hover state exist when the element is drawing, especially in progressive case. + // (B) Hover state is applied after the element has been drawn and keep no dirty. + // We need to avoid repeatedly drawing the hover layer, especially in progressive case. + // Hover layer should be cleared whenever a normal layer is cleared. + // otherwise it can not follow the elements changing. + // For example, the original el may have been moved. + private _hoverLayerDirty: HoverLayerDirty private _redrawId: number @@ -113,46 +256,34 @@ export default class CanvasPainter implements PainterBase { this.type = 'canvas'; + this._i = { + layerStack: [], + layers: [], + }; + // In node environment using node-canvas const singleCanvas = !root.nodeName // In node ? || root.nodeName.toUpperCase() === 'CANVAS'; this._opts = opts = util.extend({}, opts || {}) as CanvasPainterOption; - /** - * @type {number} - */ this.dpr = opts.devicePixelRatio || devicePixelRatio; - /** - * @type {boolean} - * @private - */ + this._singleCanvas = singleCanvas; - /** - * 绘图容器 - * @type {HTMLElement} - */ + this.root = root; const rootStyle = root.style; if (rootStyle) { - // @ts-ignore util.disableUserSelect(root); root.innerHTML = ''; } - /** - * @type {module:zrender/Storage} - */ this.storage = storage; - const zlevelList: number[] = this._zlevelList; - this._prevDisplayList = []; - const layers = this._layers; - if (!singleCanvas) { this._width = getSize(root, 0, opts); this._height = getSize(root, 1, opts); @@ -186,21 +317,16 @@ export default class CanvasPainter implements PainterBase { // Create layer if only one given canvas // Device can be specified to create a high dpi image. - const mainLayer = new Layer(rootCanvas, this, this.dpr); - mainLayer.__builtin__ = true; - mainLayer.initContext(); + const singleLayer = createBuiltinLayer(rootCanvas, this, CANVAS_ZLEVEL, ZLEVEL2_NORMAL_BELOW); + singleLayer.initContext(); // FIXME Use canvas width and height - // mainLayer.resize(width, height); - layers[CANVAS_ZLEVEL] = mainLayer; - mainLayer.zlevel = CANVAS_ZLEVEL; - // Not use common zlevel. - zlevelList.push(CANVAS_ZLEVEL); + // singleLayer.resize(width, height); + this._insertLayer(singleLayer, CANVAS_ZLEVEL, ZLEVEL2_NORMAL_BELOW, true); this._domRoot = root; } } - getType() { return 'canvas'; } @@ -226,29 +352,42 @@ export default class CanvasPainter implements PainterBase { } } - /** - * 刷新 - * @param paintAll 强制绘制所有displayable - */ - refresh(paintAll?: boolean) { - const list = this.storage.getDisplayList(true); - const prevList = this._prevDisplayList; + refresh(optOrPaintAll?: CanvasPainterRefreshOpt | CanvasPainterRefreshOpt['paintAll']) { + let opt: CanvasPainterRefreshOpt; + if (optOrPaintAll && !util.isObject(optOrPaintAll)) { + opt = {paintAll: !!optOrPaintAll}; // Backward compatible + } + else { + opt = (optOrPaintAll as CanvasPainterRefreshOpt) || {}; + } + const refresh = util.retrieve2(opt.refresh, true); + const refreshHover = util.retrieve2(opt.refreshHover, false); - const zlevelList = this._zlevelList; + if (refreshHover) { + this._hoverLayerDirty = HOVER_LAYER_DIRTY_REPAINT; + } - this._redrawId = Math.random(); + if (!refresh) { + if (refreshHover) { + this._paintHoverList(this.storage.getDisplayList(false)); + } + return this; + } - this._paintList(list, prevList, paintAll, this._redrawId); + const list = this.storage.getDisplayList(true); + this._updateLayerStatus(list, opt.paintAll); + + this._redrawId = Math.random(); + const prevList = this._prevDisplayList; + this._paintList(list, prevList, this._redrawId); - // Paint custum layers - for (let i = 0; i < zlevelList.length; i++) { - const z = zlevelList[i]; - const layer = this._layers[z]; - if (!layer.__builtin__ && layer.refresh) { - const clearColor = i === 0 ? this._backgroundColor : null; - layer.refresh(clearColor); + // Paint custom layers + const bgColor = this._backgroundColor; + eachLayer(this._i, function (layer, zlevel, zlevel2, idx) { + if (layer.refresh) { + layer.refresh(idx === 0 ? bgColor : null); } - } + }, EACH_LAYER_NOT_BUILTIN); if (this._opts.useDirtyRect) { this._prevDisplayList = list.slice(); @@ -257,299 +396,346 @@ export default class CanvasPainter implements PainterBase { return this; } + private _paintHoverList(list: Displayable[]): void { + let hoverLayer = this._i.hoverlayer; + const hoverLayerDirty = this._hoverLayerDirty; + // Always clear dirty flag before return. + this._hoverLayerDirty = HOVER_LAYER_DIRTY_NO; - refreshHover() { - this._paintHoverList(this.storage.getDisplayList(false)); - } + if (hoverLayerDirty === HOVER_LAYER_DIRTY_NO) { + return; + } - private _paintHoverList(list: Displayable[]) { - let len = list.length; - let hoverLayer = this._hoverlayer; - hoverLayer && hoverLayer.clear(); + if (!hoverLayer && hoverLayerDirty === HOVER_LAYER_DIRTY_REPAINT) { + hoverLayer = this._i.hoverlayer = this._ensureLayer(HOVER_LAYER_ZLEVEL); + } - if (!len) { + if (!hoverLayer) { return; } + // Clear the previous content. But use _hoverLayerDirty to avoid + // unnecessarily repeated clearing. + hoverLayer.clear(); + const scope: BrushScope = { inHover: true, viewWidth: this._width, - viewHeight: this._height + viewHeight: this._height, + beforeBrushParam: {}, }; let ctx; - for (let i = 0; i < len; i++) { + for (let i = 0, len = list.length; i < len; i++) { const el = list[i]; - if (el.__inHover) { - // Use a extream large zlevel - // FIXME? - if (!hoverLayer) { - hoverLayer = this._hoverlayer = this.getLayer(HOVER_LAYER_ZLEVEL); - } - - if (!ctx) { - ctx = hoverLayer.ctx; - ctx.save(); - } - - brush(ctx, el, scope, i === len - 1); + if (!el.__inHover) { + continue; + } + if (!ctx) { + ctx = hoverLayer.ctx; + ctx.save(); + } + // `el.style` is replaced with `el.__hoverStyle` when and only when hover layer is brushing. + // Any omission or any over replacing may cause incorrect result. + // Consider a problematic case: + // Suppose an element fades out via `opacity:0`, which is set into `this.style` via `el.attr()`, + // and then new styles (including `opacity: 0.8`) are assigned to `this.__hoverStyle` via + // `el.useStyle()`, but `saveCurrentToNormalState` uses `this.style`, the element will never be + // displayed. + // And notice upstream libraries, such as echarts, typically call `useStyle()` in every update + // cycle. It should write to `this.style` as normal, rather than to `__hoverStyle`, since + // `this.style` is the source of `saveCurrentToNormalState` when state switching. + const hoverStyle = el.__hoverStyle; + let originalStyle: Displayable['style']; + if (hoverStyle) { + originalStyle = el.style; + el.style = hoverStyle; + } + brush(ctx, el, scope); + if (hoverStyle) { + el.style = originalStyle; } } if (ctx) { + brushLoopFinalize(ctx, scope); ctx.restore(); } } + /** + * @deprecated + */ getHoverLayer() { - return this.getLayer(HOVER_LAYER_ZLEVEL); + return this._ensureLayer(HOVER_LAYER_ZLEVEL); } + /** + * @deprecated + */ paintOne(ctx: CanvasRenderingContext2D, el: Displayable) { brushSingle(ctx, el); } - private _paintList(list: Displayable[], prevList: Displayable[], paintAll: boolean, redrawId?: number) { + private _paintList(list: Displayable[], prevList: Displayable[], redrawId?: number) { if (this._redrawId !== redrawId) { return; } - paintAll = paintAll || false; - - this._updateLayerStatus(list); - - const {finished, needsRefreshHover} = this._doPaintList(list, prevList, paintAll); + const finished = this._doPaintList(list, prevList); if (this._needsManuallyCompositing) { this._compositeManually(); } - if (needsRefreshHover) { - this._paintHoverList(list); - } - if (!finished) { const self = this; requestAnimationFrame(function () { - self._paintList(list, prevList, paintAll, redrawId); + self._paintList(list, prevList, redrawId); }); } else { - this.eachLayer(layer => { + eachLayer(this._i, function (layer) { layer.afterBrush && layer.afterBrush(); - }); + }, EACH_LAYER_BUILTIN_NOT_HOVER); + // Hover layer may be dirty by user interactions before progressive rendering + // finished. Therefore we do NOT paint hover layer per frame following _doPaintList, + // instead, we simply repaint it once after finished. + this._paintHoverList(list); } } private _compositeManually() { - const ctx = this.getLayer(CANVAS_ZLEVEL).ctx; + const ctx = this._ensureLayer(CANVAS_ZLEVEL).ctx; const width = (this._domRoot as HTMLCanvasElement).width; const height = (this._domRoot as HTMLCanvasElement).height; ctx.clearRect(0, 0, width, height); - // PENDING, If only builtin layer? - this.eachBuiltinLayer(function (layer) { + // PENDING, Whether only builtin layer? + eachLayer(this._i, function (layer) { if (layer.virtual) { ctx.drawImage(layer.dom, 0, 0, width, height); } - }); + }, EACH_LAYER_BUILTIN); } private _doPaintList( list: Displayable[], prevList: Displayable[], - paintAll?: boolean - ): { - finished: boolean - needsRefreshHover: boolean - } { - const layerList = []; - const useDirtyRect = this._opts.useDirtyRect; - for (let zi = 0; zi < this._zlevelList.length; zi++) { - const zlevel = this._zlevelList[zi]; - const layer = this._layers[zlevel]; - if (layer.__builtin__ - && layer !== this._hoverlayer - && (layer.__dirty || paintAll) - // Layer with hover elements can't be redrawn. - // && !layer.__hasHoverLayerELement - ) { - layerList.push(layer); - } - } - + // Return: `finished` + ): boolean { + const painter = this; let finished = true; - let needsRefreshHover = false; - - for (let k = 0; k < layerList.length; k++) { - const layer = layerList[k]; - const ctx = layer.ctx; - const repaintRects = useDirtyRect - && layer.createRepaintRects(list, prevList, this._width, this._height); + eachLayer(this._i, function (layer) { + let needDraw = false; + eachCursorInLayer(layer, function (cursor) { + if (cursor.drawIdx < cursor.endIdx + || cursor.notClearIdx >= 0 + ) { + needDraw = true; + } + }); - let start = paintAll ? layer.__startIndex : layer.__drawIndex; + if (!needDraw && !layer.__dirty) { + return; + } - const useTimer = !paintAll && layer.incremental && Date.now; - const startTime = useTimer && Date.now(); + const repaintRects = (painter._opts.useDirtyRect && !isIncrementalLayer(layer)) + ? layer.createRepaintRects(list, prevList, painter._width, painter._height) : null; - const clearColor = layer.zlevel === this._zlevelList[0] - ? this._backgroundColor : null; + const firstLayerKey = painter._i.layerStack[0]; + let contentRetained = true; - // All elements in this layer are removed. - if (layer.__startIndex === layer.__endIndex) { + if (layer.__dirty) { // Perform layer clear. + contentRetained = false; + layer.__dirty = false; + const clearColor = (layer.zlevel === firstLayerKey.zl && layer.zlevel2 === firstLayerKey.zl2) + ? painter._backgroundColor : null; layer.clear(false, clearColor, repaintRects); } - else if (start === layer.__startIndex) { - const firstEl = list[start]; - if (!firstEl.incremental || !(firstEl as IncrementalDisplayable).notClear || paintAll) { - layer.clear(false, clearColor, repaintRects); - } - } - if (start === -1) { - console.error('For some unknown reason. drawIndex is -1'); - start = layer.__startIndex; - } - let i: number; - /* eslint-disable-next-line */ - const repaint = (repaintRect?: BoundingRect) => { - const scope: BrushScope = { - inHover: false, - allClipped: false, - prevEl: null, - viewWidth: this._width, - viewHeight: this._height - }; - - for (i = start; i < layer.__endIndex; i++) { - const el = list[i]; - - if (el.__inHover) { - needsRefreshHover = true; - } - this._doPaintEl(el, layer, useDirtyRect, repaintRect, scope, i === layer.__endIndex - 1); + eachCursorInLayer(layer, function (cursor) { + const cursorFinished = painter._paintPerCursor( + layer, cursor, list, repaintRects, contentRetained + ); + finished = finished && cursorFinished; + }); + }, EACH_LAYER_BUILTIN_NOT_HOVER); - if (useTimer) { - // Date.now can be executed in 13,025,305 ops/second. - const dTime = Date.now() - startTime; - // Give 15 millisecond to draw. - // The rest elements will be drawn in the next frame. - if (dTime > 15) { - break; - } - } + if (env.wxa) { + // Flush for weixin application + eachLayer(this._i, function (layer) { + if (layer && layer.ctx && (layer.ctx as WXCanvasRenderingContext).draw) { + (layer.ctx as WXCanvasRenderingContext).draw(); } + }); + } - if (scope.prevElClipPaths) { - // Needs restore the state. If last drawn element is in the clipping area. - ctx.restore(); - } - }; + return finished; + } - if (repaintRects) { - if (repaintRects.length === 0) { - // Nothing to repaint, mark as finished - i = layer.__endIndex; - } - else { - const dpr = this.dpr; - // Set repaintRect as clipPath - for (var r = 0; r < repaintRects.length; ++r) { - const rect = repaintRects[r]; - - ctx.save(); - ctx.beginPath(); - ctx.rect( - rect.x * dpr, - rect.y * dpr, - rect.width * dpr, - rect.height * dpr - ); - ctx.clip(); - - repaint(rect); - ctx.restore(); - } - } + private _paintPerCursor( + layer: Layer, + layerCursor: LayerDrawCursor, + list: Displayable[], + repaintRects: BoundingRect[] | NullUndefined, + contentRetained: boolean + // Return `finished` + ): boolean { + const ctx = layer.ctx; + + if (repaintRects) { + if (!repaintRects.length) { + layerCursor.drawIdx = layerCursor.endIdx; // Nothing to repaint, mark as finished } else { - // Paint all once - ctx.save(); - repaint(); - ctx.restore(); + const dpr = this.dpr; + // Set repaintRect as clipPath + for (let r = 0; r < repaintRects.length; ++r) { + const rect = repaintRects[r]; + + ctx.save(); + ctx.beginPath(); + ctx.rect( + rect.x * dpr, + rect.y * dpr, + rect.width * dpr, + rect.height * dpr + ); + ctx.clip(); + this._paintPerCursorInRect(layer, layerCursor, list, rect, contentRetained); + ctx.restore(); + } + } + } + else { + // Paint all once + ctx.save(); + this._paintPerCursorInRect(layer, layerCursor, list, null, contentRetained); + ctx.restore(); + } + + return layerCursor.drawIdx >= layerCursor.endIdx; + } + + private _paintPerCursorInRect( + layer: Layer, + layerCursor: LayerDrawCursor, + list: Displayable[], + repaintRect: BoundingRect | NullUndefined, + contentRetained: boolean, + ): void { + const scope: BrushScope = { + inHover: false, + allClipped: false, + prevEl: null, + viewWidth: this._width, + viewHeight: this._height, + beforeBrushParam: {contentRetained} + }; + const ctx = layer.ctx; + const useTimer = isIncrementalLayer(layer); + const startTime = useTimer && platformApi.getTime(); + + // NOTICE: This loop is performance-sensitive, especially for large data. + const drawIdxBegin = layerCursor.drawIdx; + const notClearIdx = layerCursor.notClearIdx; + let idx = notClearIdx >= 0 ? Math.min(notClearIdx, drawIdxBegin) : drawIdxBegin; + for (; idx < layerCursor.endIdx; idx++) { + const el = list[idx]; + + if (idx < drawIdxBegin && !el.notClear) { + // In this portion, all non-`notClear` elements do not need to be painted. + continue; } - layer.__drawIndex = i; + if (el.__inHover) { + // To avoid repeatedly repaint hover layer in progressive rendering, + // set HOVER_LAYER_DIRTY_REPAINT only when needed. + // Notice rendered el may not be traveled here again if the layer is not dirty, + // in this case HOVER_LAYER_DIRTY_REPAINT is set via markRedraw() calling + // zr.refreshHover(). + this._hoverLayerDirty = HOVER_LAYER_DIRTY_REPAINT; + // NOTE: To ensure a consistent composited visual effect, `el` should be + // always painted to normal layers regardless of whether it will be painted + // to a hover layer. + } - if (layer.__drawIndex < layer.__endIndex) { - finished = false; + if (repaintRect != null) { + const paintRect = el.getPaintRect(); + if (paintRect && paintRect.intersect(repaintRect)) { + brush(ctx, el, scope); + el.setPrevPaintRect(paintRect); + } + } + else { + brush(ctx, el, scope); } - } - if (env.wxa) { - // Flush for weixin application - util.each(this._layers, function (layer) { - if (layer && layer.ctx && (layer.ctx as WXCanvasRenderingContext).draw) { - (layer.ctx as WXCanvasRenderingContext).draw(); + if (useTimer) { + const dTime = platformApi.getTime() - startTime; + // Give 15 millisecond to draw. + // The rest elements will be drawn in the next frame. + // FIXME: + // This 15 is unreasonable enough - draw operations execution time is + // considerable but not a part of JS execution time here. + // We may change to record the last frame end time and compare it here. + if (dTime > 15) { + idx++; + break; } - }); + } } + brushLoopFinalize(ctx, scope); - return { - finished, - needsRefreshHover - }; + layerCursor.drawIdx = Math.max(idx, drawIdxBegin); // `idx` may < `drawIdxBegin` due to `notClearIdx`. } - private _doPaintEl( - el: Displayable, - currentLayer: Layer, - useDirtyRect: boolean, - repaintRect: BoundingRect, - scope: BrushScope, - isLast: boolean - ) { - const ctx = currentLayer.ctx; - if (useDirtyRect) { - const paintRect = el.getPaintRect(); - if (!repaintRect || paintRect && paintRect.intersect(repaintRect)) { - brush(ctx, el, scope, isLast); - el.setPrevPaintRect(paintRect); - } - } - else { - brush(ctx, el, scope, isLast); - } + /** + * FIXME: + * Currently layer remove or reuse in different zlevel is not supported due to + * the external link. + * + * Obtain a layer; create one if not exist. + * + * Keep backward compatibile - this method may be called from outside of zrender. + * i.e., get a webGL layer, or built-in layer in some special cases. + * + * A virtual layer can be used in _singleCanvas case. + * A virtual layer can also be a WebGL layer and assigned to a ZRImage element + * But it still under management of zrender. + */ + getLayer(zlevel: ZLevel, virtual?: boolean) { + return this._ensureLayer(zlevel, 0, virtual); } /** - * 获取 zlevel 所在层,如果不存在则会创建一个新的层 - * @param zlevel - * @param virtual Virtual layer will not be inserted into dom. + * Obtain a layer; create one if not exist. */ - getLayer(zlevel: number, virtual?: boolean) { - if (this._singleCanvas && !this._needsManuallyCompositing) { + private _ensureLayer(zlevel: ZLevel, zlevel2?: ZLevel2, virtual?: boolean) { + zlevel2 = zlevel2 || 0; + const singleCanvas = this._singleCanvas; + + if (singleCanvas && !this._needsManuallyCompositing) { zlevel = CANVAS_ZLEVEL; + zlevel2 = 0; } - let layer = this._layers[zlevel]; + + let layer = ensureLayerListInZLevel(this._i, zlevel)[zlevel2]; + if (!layer) { - // Create a new layer - layer = new Layer('zr_' + zlevel, this, this.dpr); - layer.zlevel = zlevel; - layer.__builtin__ = true; + layer = createBuiltinLayer('zr_' + zlevel + '.' + zlevel2, this, zlevel, zlevel2); if (this._layerConfig[zlevel]) { util.merge(layer, this._layerConfig[zlevel], true); } - // TODO Remove EL_AFTER_INCREMENTAL_INC magic number - else if (this._layerConfig[zlevel - EL_AFTER_INCREMENTAL_INC]) { - util.merge(layer, this._layerConfig[zlevel - EL_AFTER_INCREMENTAL_INC], true); - } - if (virtual) { - layer.virtual = virtual; + if (virtual + || (singleCanvas && zlevel !== CANVAS_ZLEVEL) + ) { + layer.virtual = true; } - this.insertLayer(zlevel, layer); + this._insertLayer(layer, zlevel, zlevel2, false); // Context is created after dom inserted to document // Or excanvas will get 0px clientWidth and clientHeight @@ -559,22 +745,33 @@ export default class CanvasPainter implements PainterBase { return layer; } - insertLayer(zlevel: number, layer: Layer) { + /** + * Keep backward compatibile - this method may be called from outside of zrender. + * e.g., insert a webGL layer by echarts-gl. + */ + insertLayer(zlevel: ZLevel, layer: Layer) { + this._insertLayer(layer, zlevel, 0, false); + } - const layersMap = this._layers; - const zlevelList = this._zlevelList; - const len = zlevelList.length; + private _insertLayer( + layer: Layer, + zlevel: ZLevel, + zlevel2: ZLevel2, + suppressDOMInsert: boolean + ) { + const internal = this._i; + const layersMap = internal.layers; + const layerStack = internal.layerStack; const domRoot = this._domRoot; let prevLayer = null; - let i = -1; - if (layersMap[zlevel]) { + if (layersMap[zlevel] && layersMap[zlevel][zlevel2]) { if (process.env.NODE_ENV !== 'production') { - util.logError('ZLevel ' + zlevel + ' has been used already'); + util.logError('ZLevel ' + zlevel + '.' + zlevel2 + ' has been used already'); } return; } - // Check if is a valid layer + if (!isLayerValid(layer)) { if (process.env.NODE_ENV !== 'production') { util.logError('Layer of zlevel ' + zlevel + ' is not valid'); @@ -582,25 +779,25 @@ export default class CanvasPainter implements PainterBase { return; } - if (len > 0 && zlevel > zlevelList[0]) { - for (i = 0; i < len - 1; i++) { - if ( - zlevelList[i] < zlevel - && zlevelList[i + 1] > zlevel - ) { - break; - } - } - prevLayer = layersMap[zlevelList[i]]; + const len = layerStack.length; + let i = 0; + while (i < len + && (layerStack[i].zl < zlevel + || (layerStack[i].zl === zlevel && layerStack[i].zl2 < zlevel2) + ) + ) { + i++; } - zlevelList.splice(i + 1, 0, zlevel); - - layersMap[zlevel] = layer; + if (i > 0) { + prevLayer = ensureLayerListInZLevel(internal, layerStack[i - 1].zl)[layerStack[i - 1].zl2]; + } + layerStack.splice(i, 0, {zl: zlevel, zl2: zlevel2}); + ensureLayerListInZLevel(internal, zlevel)[zlevel2] = layer; // Virtual layer will not directly show on the screen. // (It can be a WebGL layer and assigned to a ZRImage element) // But it still under management of zrender. - if (!layer.virtual) { + if (!suppressDOMInsert && !layer.virtual) { if (prevLayer) { const prevDom = prevLayer.dom; if (prevDom.nextSibling) { @@ -626,175 +823,344 @@ export default class CanvasPainter implements PainterBase { layer.painter || (layer.painter = this); } - // Iterate each layer - eachLayer(cb: (this: T, layer: Layer, z: number) => void, context?: T) { - const zlevelList = this._zlevelList; - for (let i = 0; i < zlevelList.length; i++) { - const z = zlevelList[i]; - cb.call(context, this._layers[z], z); - } + /** + * @deprecated + */ + eachLayer(cb: (this: T, layer: Layer, zlevel: number) => void, context?: T) { + return eachLayer(this._i, function (layer, zlevel) { + cb.call(context, layer, zlevel); // zlevel2 should not be exposed. + }); } - // Iterate each buildin layer - eachBuiltinLayer(cb: (this: T, layer: Layer, z: number) => void, context?: T) { - const zlevelList = this._zlevelList; - for (let i = 0; i < zlevelList.length; i++) { - const z = zlevelList[i]; - const layer = this._layers[z]; - if (layer.__builtin__) { - cb.call(context, layer, z); - } - } + /** + * @deprecated + * FIXME: built-in layer should not be exposed. + * + * Iterate each built-in layer (including hover layer) + */ + eachBuiltinLayer(cb: (this: T, layer: Layer, zlevel: number) => void, context?: T) { + return eachLayer(this._i, function (layer, zlevel) { + cb.call(context, layer, zlevel); + }, EACH_LAYER_BUILTIN); } - // Iterate each other layer except buildin layer + /** + * Iterate each other layer except built-in layer + * e.g., get webGL layers by echarts-gl. + */ eachOtherLayer(cb: (this: T, layer: Layer, z: number) => void, context?: T) { - const zlevelList = this._zlevelList; - for (let i = 0; i < zlevelList.length; i++) { - const z = zlevelList[i]; - const layer = this._layers[z]; - if (!layer.__builtin__) { - cb.call(context, layer, z); - } - } + return eachLayer(this._i, function (layer, zlevel) { + cb.call(context, layer, zlevel); + }, EACH_LAYER_NOT_BUILTIN); } /** - * 获取所有已创建的层 - * @param prevLayer + * @deprecated + * NOTICE: Only for debugging or testing. */ getLayers() { - return this._layers; - } - - _updateLayerStatus(list: Displayable[]) { - - this.eachBuiltinLayer(function (layer, z) { - layer.__dirty = layer.__used = false; + const layers: Record = {}; + eachLayer(this._i, function (layer, zlevel, zlevel2) { + layers[layer.id] = layer; }); + return layers; + } - function updatePrevLayer(idx: number) { - if (prevLayer) { - if (prevLayer.__endIndex !== idx) { - prevLayer.__dirty = true; - } - prevLayer.__endIndex = idx; - } - } + /** + * @tutorial [CANVAS_INCREMENTAL_LAYER_USE_CASES] + * Two use patterns are covered per incremental layer: + * [CANVAS_INCREMENTAL_CASE_SINGLE_ELEMENT] + * An single incremental element with a customized `buildPath`, using `Displayable['notClear']` + * to retain the rendered content. + * [CANVAS_INCREMENTAL_CASE_MULTIPLE_ELEMENTS] + * A run of consecutive incremental elements, progressively drawing per frame in `_paintList`. This + * is not an optimal approach for rendering due to the increasing cost of updating and sorting + * `displayList`. However, it support varying styles and can balance the cost between rendering and + * hit testing during hover (which may degrade with excessive points in single shape). + * Notice, these two patterns can exist simultaneously in the same incremental layer. + * + * @tutorial [CANVAS_LAYER_STACKING] + * - An `Layer` instance represents a physical layer, typically a HTML Canvas. + * - Each `zlevel` will be splitted to 2 or 3 physical layers if incremental elements occur, + * designated by `zlevel2`. A full version can be like this: + * [[ layer_hover zlevel:100000 ]] + * [[ layer_normal_above zlevel:0, zlevel2:2 (normal el after incremental el) ]] + * [[ layer_incremental zlevel:0, zlevel2:1 (incremental el) ]] + * [[ layer_normal_below zlevel:0, zlevel2:0 (normal el before incremental el) ]] + * (But layer_normal_below may be omitted if not needed.) + * - Physical layers (HTML Canvas) should not be created excessively, therefore, within a single + * `zlevel`, multiple runs of incremental elements share one physical layer (i.e., `zlevel2: 1`). + * - Theoretically, a physical layer can switch bettween incremental or non-incremental. But currently + * we do not support it. + * - [LIMITED_TO_3_CANVAS_LAYERS_PER_ZLEVEL] + * To avoid excessive HTML Canvas creation, at most 3 layers can be created for a single `zlevel`. + * If two runs of consecutive incremental elements are separated by some normal elements, those normal + * elements are painted on `zlevel: 2`, and all incremental elements are painted on `zlevel: 1`, + * regardless of `el.z` and `el.z2` settings. + * Users can explicitly specify a higher `zlevel` to allow more incremental layers to be created. + * - NOTE: Elements do not necessarily have different z or z2 - even if all z or z2 are 0, z-order is + * determined by `add(el)` order. + * + * @tutorial [DISPLAY_LIST_SORTING_AND_LAYERING] + * Currently there are 5 parameters to determine the layer and z-order for each element: + * + * Only `zlevel`, `z` and `z2` are user specified. + * The `displayList` is sorted only by `zlevel`, `z` and `z2`. + * A <`zlevel`, `zlevel2`> pair determines a layer. + * A `incremental(LayerDrawCursor)` acts like a "soft layer", representing a run of consecutive + * incremental elements. Multiple `LayerDrawCursor`s share one layer. + * Users must use different `el.incremental` (a number) to distinguish different runs of consecutive + * incremental elements. And each `el.incremental` has its exclusive `LayerDrawCursor`. Take echarts + * as an example: if there are multiple "series" requiring incremental, e.g., a bar series and a + * candlestick series in a Cartesian, and their zlevel/z/z2 are typicall the same. + * See CANVAS_LAYER_SAMPLE_CASE_3 for more details. + * + * Consider sample cases below to check the implementation: + * - [CANVAS_LAYER_SAMPLE_CASE_1]: + * `zlevel:5` is explicitly specified by users. + * `zlevel:0` is the default. + * [[ layer_hover zlevel:100000 ]] + * [[ layer_normal_above_2 zlevel:5, zlevel2:2 ]] + * [[ layer_incremental_2 zlevel:5, zlevel2:1 ]] + * [[ layer_normal_below_2 zlevel:5, zlevel2:0 ]] + * [[ layer_normal_above_1 zlevel:0, zlevel2:2 ]] + * [[ layer_incremental_1 zlevel:0, zlevel2:1 ]] + * [[ layer_normal_below_1 zlevel:0, zlevel2:0 ]] + * - [CANVAS_LAYER_SAMPLE_CASE_2]: + * No elements are before incremental elements. + * [[ layer_hover zlevel: 100000 ]] + * [[ layer_normal_above zlevel:0, zlevel2:2 ]] + * [[ layer_incremental zlevel:0, zlevel2:1 ]] + * - [CANVAS_LAYER_SAMPLE_CASE_3]: + * Multiple runs of consecutive incremental elements, may (or not) be separated by some normal elements. + * Suppose a sorted `displayList` is: + * `[{a_nor}, {b_inc:7}, {c_inc:7}, {d_nor}, {e_inc:9}, {f_inc:9}, {g_nor}]`. + * Then both incremental:7 and incremental:9 have new elements added. + * The sorted `displayList` become: + * `[{a_nor}, {b_inc:7}, {c_inc:7}, {m_inc:7}, {d_nor}, {e_inc:9}, {f_inc:9}, {n_inc:9}, {g_nor}]`. + * The order can not match the original `displayList` - new elements are inserted in the middle rather + * than at the end. Therefore, multiple `layerDrawCursor`s are introduced to manage the pointers separately, + * enabling them to share one physical layer. + * They are arranged into layers like this: + * [[ layer_hover zlevel:100000 ]] + * [[ layer_normal_above zlevel:0, zlevel2:2 layerDrawCursor:0 {d_nor}, {g_nor} ]] + * [[ layer_incremental zlevel:0, zlevel2:1 layerDrawCursor:9 {e_inc:9}, {f_inc:9} {n_inc:9} ]] + * [[ layerDrawCursor:7 {b_inc:7}, {c_inc:7} {m_inc:7} ]] + * [[ layer_normal_below zlevel:0, zlevel2:0 layerDrawCursor:0 {a_nor} ]] + * + * @tutorial [CANVAS_LAYER_DIRTY_RULES]: + * Only dirty layer will be cleared and repaint later. `layer.__dirty` is set by: + * - REDRAW_BIT of every element. [CANVAS_LAYER_DIRTY_BY_REDRAW_BIT] + * For normal layers, currently REDRAW_BIT is the only reliable way to make sure repainting, since + * reorder is not checked. So we conservatively always dirty the layers if any REDRAW_BIT occur. + * For incremental layers, we aggressively dirty the layer only if drawn elements have REDRAW_BIT, + * since redorder of incremental elements hardly occurs. + * - Mismatching of `layerDrawCursor.first` and `layerDrawCursor.endIdx`. + * [CANVAS_LAYER_CONTENT_RETAINED]: + * This strategy is mainly required by progressive rendering, where typicall new elements are + * appended, and repaint from the start per frame should be prevented. Otherwise, increasing + * draw calls can significantly block rendering. Additionally, If displayList indices of incremental + * elements are changed due to preceding elements of other layers, the drawing should not be restarted. + * Therefore, we record the first element to shift indices for this case. + * [CANVAS_LAYER_FAIL_TO_DIRTY_IF_ONLY_REORDER]: + * This strategy has also been applied to normal layers to prevent them from repainting in progressive + * frames. Upstream applications should remain the order of elements unchanged if no REDRAW_BIT is + * set - no checking for this currently. Otherwise, layers fail to dirty unexpectedly. + * Take echarts as an example, consider common patterns: "remove some elements", "modify z/z2 typically + * via useState", "clear and recreate all elements per user interaction", "reuse elements if possible + * but update attributes and styles per user interaction". Layer dirty can be triggered. If bad cases + * occur, more mechanism can be introduced (e.g., record el ids in layerDrawCursor for checking). + * + * PENDING: + * - [PENDING_SEPARATE_DISPLAY_LIST]: + * In CANVAS_INCREMENTAL_CASE_MULTIPLE_ELEMENTS, displayList sorting and `_updateAndAddDisplayable` will be + * executed per frame and significantly consume time in high element counts (indicatively, 1e6 in + * certain environments). Perhaps displayList can be separated by Layer or by LayerDrawCursor, + * and perform targeted optimization - omitting unnecessary sorting and `update()`. + * - Also sort displayList by `el.incremental` to automatically ensure consecutive? + * Currently, the contiguity can only be ensured by the order of `add()` call. + */ + private _updateLayerStatus(list: Displayable[], paintAll: boolean): void { + const painter = this; - if (this._singleCanvas) { + if (painter._singleCanvas) { for (let i = 1; i < list.length; i++) { const el = list[i]; if (el.zlevel !== list[i - 1].zlevel || el.incremental) { - this._needsManuallyCompositing = true; + painter._needsManuallyCompositing = true; break; } } } - let prevLayer: Layer = null; - let incrementalLayerCount = 0; - let prevZlevel; - let i; + eachLayer(painter._i, function (layer) { // Reset flags + layer.__dirty = false; + eachCursorInLayer(layer, function (cursor) { + cursor.used = false; + cursor.endIdxNew = 0; + cursor.notClearIdx = -1; + }); + }, EACH_LAYER_BUILTIN_NOT_HOVER); + + let prevZLevel: ZLevel; + let currLayer: Layer = null; + let currCursor: LayerDrawCursor = null; + let aboveIncrementalInCurrZLevel = false; - for (i = 0; i < list.length; i++) { - const el = list[i]; + // NOTE: this loop is performance-sensitive, especially for large data. + for (let idx = 0, len = list.length; idx < len; idx++) { + const el = list[idx]; const zlevel = el.zlevel; - let layer; + const elIncremental = el.incremental; + let zlevel2: ZLevel2; - if (prevZlevel !== zlevel) { - prevZlevel = zlevel; - incrementalLayerCount = 0; + if (prevZLevel !== zlevel) { // Then `el` is the first element in this zlevel. + prevZLevel = zlevel; + aboveIncrementalInCurrZLevel = false; } - // TODO Not use magic number on zlevel. - - // Each layer with increment element can be separated to 3 layers. - // (Other Element drawn after incremental element) - // -----------------zlevel + EL_AFTER_INCREMENTAL_INC-------------------- - // (Incremental element) - // ----------------------zlevel + INCREMENTAL_INC------------------------ - // (Element drawn before incremental element) - // --------------------------------zlevel-------------------------------- - if (el.incremental) { - layer = this.getLayer(zlevel + INCREMENTAL_INC, this._needsManuallyCompositing); - layer.incremental = true; - incrementalLayerCount = 1; + if (elIncremental) { + aboveIncrementalInCurrZLevel = true; + zlevel2 = ZLEVEL2_INCREMENTAL; } else { - layer = this.getLayer( - zlevel + (incrementalLayerCount > 0 ? EL_AFTER_INCREMENTAL_INC : 0), - this._needsManuallyCompositing - ); + // See LIMITED_TO_3_CANVAS_LAYERS_PER_ZLEVEL + // If incremental elements appear, all subsequent normal elements use `zlevel2: 2`. + // else use `zlevel2: 0`. + zlevel2 = aboveIncrementalInCurrZLevel ? ZLEVEL2_NORMAL_ABOVE : ZLEVEL2_NORMAL_BELOW; } - if (!layer.__builtin__) { - util.logError('ZLevel ' + zlevel + ' has been used by unkown layer ' + layer.id); - } - - if (layer !== prevLayer) { - layer.__used = true; - if (layer.__startIndex !== i) { - layer.__dirty = true; + if (!currLayer || zlevel !== currLayer.zlevel || zlevel2 !== currLayer.zlevel2) { + // NOTE: now `el` is not necessarily the first element of `currLayer` in this pass, since + // `zlevel2` is not a sort key of `displayList`. See DISPLAY_LIST_SORTING_AND_LAYERING. + currLayer = painter._ensureLayer(zlevel, zlevel2); + currCursor = null; + if (!currLayer.__builtin__) { + util.logError('ZLevel ' + zlevel + ' has been used by unknown layer ' + currLayer.id); + continue; } - layer.__startIndex = i; - if (!layer.incremental) { - layer.__drawIndex = i; + } + // Else `currLayer` is not changed, keep using it. This is the most common case, + // so we retain this past path for performance. + + if (!currCursor || elIncremental !== currCursor.key) { + // NOTE: now `el` is not necessarily the first element of `currCursor` in this pass, since + // `incremental` is not a sort key of `displayList`. See DISPLAY_LIST_SORTING_AND_LAYERING. + currCursor = ensureLayerDrawCursor(currLayer, elIncremental); + + if (!currCursor.used) { // Now `el` is the first element in `currCursor` in this pass. + currCursor.used = true; + if (!paintAll && currCursor.first === el.id) { // See CANVAS_LAYER_CONTENT_RETAINED + const idxShift = idx - currCursor.startIdx; + currCursor.startIdx = idx; + currCursor.drawIdx += idxShift; // May be further modified at last. + currCursor.endIdx += idxShift; // May be further modified at last. + } + else { + currLayer.__dirty = true; + currCursor.first = el.id; + currCursor.startIdx = currCursor.drawIdx = idx; + currCursor.endIdx = idx + 1; + // Hereafter, `startIdx` should not changed in this pass. + } } - else { - // Mark layer draw index needs to update. - layer.__drawIndex = -1; + } + // Else `currCursor` is not changed, keep using it. This is the most common case, + // so we retain this past path for performance. + + // See CANVAS_LAYER_FAIL_TO_DIRTY_IF_ONLY_REORDER + // if (zlevel2 !== 1) { // Only for non-incremental layer + // const idxInCursor = idx - currCursor.startIdx; + // if (idxInCursor < LAYER_CURSOR_IDS_MAX && currCursor.ids[idxInCursor] !== el.id) { + // currLayer.__dirty = true; + // currCursor.idsLen = idxInCursor; + // } + // if (currCursor.idsLen < LAYER_CURSOR_IDS_MAX) { + // currCursor.ids[currCursor.idsLen++] = el.id; + // } + // } + + currCursor.endIdxNew = idx + 1; // Use `endIdxNew` to further check the retained render at last. + + // See CANVAS_LAYER_DIRTY_BY_REDRAW_BIT + if ((el.__dirty & REDRAW_BIT) + && !el.__inHover // Ignore dirty elements in hover layer. + ) { + if (!elIncremental // Always dirty the entire normal layer if any dirty occurs. + || (!el.notClear && idx < currCursor.drawIdx) + ) { + currLayer.__dirty = true; } - updatePrevLayer(i); - prevLayer = layer; - } - if ((el.__dirty & REDRAW_BIT) && !el.__inHover) { // Ignore dirty elements in hover layer. - layer.__dirty = true; - if (layer.incremental && layer.__drawIndex < 0) { - // Start draw from the first dirty element. - layer.__drawIndex = i; + if (elIncremental && el.notClear && currCursor.notClearIdx < 0) { + // If `notClear` elements are dirty, do not clear the layer, but they need to be repainted. + currCursor.notClearIdx = idx; } } - } + } // The end of displayList travel. + + eachLayer(painter._i, function (layer) { + const cursorStack = layer.__cursorStack; + const cursors = layer.__cursors; - updatePrevLayer(i); + for (let i = cursorStack.length - 1; i >= 0; i--) { + const cursor = cursors.get(cursorStack[i]); - this.eachBuiltinLayer(function (layer, z) { - // Used in last frame but not in this frame. Needs clear - if (!layer.__used && layer.getElementCount() > 0) { - layer.__dirty = true; - layer.__startIndex = layer.__endIndex = layer.__drawIndex = 0; + if (!cursor.used) { // `cursor` is used in the last pass but not in this pass - need clear. + layer.__dirty = true; + cursors.removeKey(cursorStack[i]); + cursorStack.splice(i, 1); + } + else { // `cursor` is newly created or is retained from the last pass. + // Layers with the same `zlevel` may be written alternately, and `layerDrawCursor` within + // the same layer may be writter alternately, since `zlevel2` and `incremental` are not + // sort keys of `displayList`. Therefore, their handling has to be finished at last. + const endIdxNew = cursor.endIdxNew; + if (isIncrementalLayer(layer) + ? endIdxNew < cursor.drawIdx + : ( // See CANVAS_LAYER_FAIL_TO_DIRTY_IF_ONLY_REORDER + endIdxNew !== cursor.endIdx + || !endIdxNew + || list[endIdxNew - 1].id !== cursor.last + ) + ) { + layer.__dirty = true; + } + // Otherwise, only drawn tail elements that are not drawn; preserve the drawn ones. + cursor.endIdx = cursor.endIdxNew; + cursor.last = endIdxNew ? list[endIdxNew - 1].id : NaN; + } } - // For incremental layer. In case start index changed and no elements are dirty. - if (layer.__dirty && layer.__drawIndex < 0) { - layer.__drawIndex = layer.__startIndex; + + if (layer.__dirty) { + // Once a layer is dirty, all of its layerDrawCursors need to be reset. + eachCursorInLayer(layer, function (cursor) { + // Once dirty, they need to be repainted from the start, since opacity and z-order + // should be respected. + cursor.drawIdx = cursor.startIdx; + }); + if (painter._hoverLayerDirty === HOVER_LAYER_DIRTY_NO) { + painter._hoverLayerDirty = HOVER_LAYER_DIRTY_REPAINT_IF_EXISTING; + } } - }); + + }, EACH_LAYER_BUILTIN_NOT_HOVER); } - /** - * 清除hover层外所有内容 - */ clear() { - this.eachBuiltinLayer(this._clearLayer); + eachLayer(this._i, function (layer) { + layer.clear(); + resetLayerDrawCursors(layer); + }, EACH_LAYER_BUILTIN); return this; } - _clearLayer(layer: Layer) { - layer.clear(); - } - setBackgroundColor(backgroundColor: string | GradientObject | ImagePatternObject) { this._backgroundColor = backgroundColor; - - util.each(this._layers, layer => { + eachLayer(this._i, function (layer) { layer.setUnpainted(); }); } - /** - * 修改指定zlevel的绘制参数 - */ configLayer(zlevel: number, config: LayerConfig) { if (config) { const layerConfig = this._layerConfig; @@ -805,32 +1171,35 @@ export default class CanvasPainter implements PainterBase { util.merge(layerConfig[zlevel], config, true); } - for (let i = 0; i < this._zlevelList.length; i++) { - const _zlevel = this._zlevelList[i]; - // TODO Remove EL_AFTER_INCREMENTAL_INC magic number - if (_zlevel === zlevel || _zlevel === zlevel + EL_AFTER_INCREMENTAL_INC) { - const layer = this._layers[_zlevel]; - util.merge(layer, layerConfig[zlevel], true); - } - } + eachLayer(this._i, function (layer, zlevel) { + util.merge(layer, layerConfig[zlevel], true); + }); } } /** - * 删除指定层 - * @param zlevel 层所在的zlevel + * Delete all layers of the specified zlevel. + * e.g., delete a webGL layer by echarts-gl. */ delLayer(zlevel: number) { - const layers = this._layers; - const zlevelList = this._zlevelList; - const layer = layers[zlevel]; - if (!layer) { - return; - } - layer.dom.parentNode.removeChild(layer.dom); - delete layers[zlevel]; + const layerStack = this._i.layerStack; + const layersMap = this._i.layers; - zlevelList.splice(util.indexOf(zlevelList, zlevel), 1); + for (let i = layerStack.length - 1; i >= 0; i--) { + const key = layerStack[i]; + if (key.zl === zlevel) { + const layer = layersMap[zlevel][key.zl2]; + if (layer.__builtin__) { + continue; + } + layerStack.splice(i, 1); + layersMap[zlevel][key.zl2] = undefined; + if (!layer.virtual) { + const parentNode = layer.dom.parentNode; + parentNode && parentNode.removeChild(layer.dom); + } + } + } } /** @@ -848,7 +1217,7 @@ export default class CanvasPainter implements PainterBase { this._width = width as number; this._height = height as number; - this.getLayer(CANVAS_ZLEVEL).resize(width as number, height as number); + this._ensureLayer(CANVAS_ZLEVEL).resize(width as number, height as number); } else { const domRoot = this._domRoot; @@ -871,13 +1240,11 @@ export default class CanvasPainter implements PainterBase { domRoot.style.width = width + 'px'; domRoot.style.height = height + 'px'; - for (let id in this._layers) { - if (this._layers.hasOwnProperty(id)) { - this._layers[id].resize(width, height); - } - } + eachLayer(this._i, function (layer) { + layer.resize(width as number, height as number); + }); - this.refresh(true); + this.refresh({paintAll: true}); } this._width = width; @@ -888,27 +1255,23 @@ export default class CanvasPainter implements PainterBase { } /** - * 清除单独的一个层 - * @param {number} zlevel + * @deprecated */ clearLayer(zlevel: number) { - const layer = this._layers[zlevel]; - if (layer) { - layer.clear(); - } + util.each(this._i.layers[zlevel], function (layer) { + if (layer && !layer.__builtin__) { + layer.clear(); + } + }); } - /** - * 释放 - */ dispose() { this.root.innerHTML = ''; this.root = this.storage = - this._domRoot = - this._layers = null; + this._i = null; } /** @@ -920,7 +1283,7 @@ export default class CanvasPainter implements PainterBase { }) { opts = opts || {}; if (this._singleCanvas && !this._compositeManually) { - return this._layers[CANVAS_ZLEVEL].dom; + return this._i.layers[CANVAS_ZLEVEL][0].dom; } const imageLayer = new Layer('image', this, opts.pixelRatio || this.dpr); @@ -934,7 +1297,7 @@ export default class CanvasPainter implements PainterBase { const width = imageLayer.dom.width; const height = imageLayer.dom.height; - this.eachLayer(function (layer) { + eachLayer(this._i, function (layer) { if (layer.__builtin__) { ctx.drawImage(layer.dom, 0, 0, width, height); } @@ -947,31 +1310,28 @@ export default class CanvasPainter implements PainterBase { } else { // PENDING, echarts-gl and incremental rendering. - const scope = { + const scope: BrushScope = { inHover: false, viewWidth: this._width, - viewHeight: this._height + viewHeight: this._height, + beforeBrushParam: {}, }; const displayList = this.storage.getDisplayList(true); for (let i = 0, len = displayList.length; i < len; i++) { const el = displayList[i]; - brush(ctx, el, scope, i === len - 1); + brush(ctx, el, scope); } + brushLoopFinalize(ctx, scope); } return imageLayer.dom; } - /** - * 获取绘图区域宽度 - */ + getWidth() { return this._width; } - /** - * 获取绘图区域高度 - */ getHeight() { return this._height; } -}; \ No newline at end of file +}; diff --git a/src/canvas/graphic.ts b/src/canvas/graphic.ts index 72d554fbf..59a22479c 100644 --- a/src/canvas/graphic.ts +++ b/src/canvas/graphic.ts @@ -1,4 +1,4 @@ -import Displayable, { DEFAULT_COMMON_STYLE } from '../graphic/Displayable'; +import Displayable, { BeforeBrushParam, DEFAULT_COMMON_STYLE } from '../graphic/Displayable'; import PathProxy from '../core/PathProxy'; import { GradientObject } from '../graphic/Gradient'; import { ImagePatternObject, InnerImagePatternObject } from '../graphic/Pattern'; @@ -17,6 +17,7 @@ import { REDRAW_BIT, SHAPE_CHANGED_BIT } from '../graphic/constants'; import type IncrementalDisplayable from '../graphic/IncrementalDisplayable'; import { DEFAULT_FONT } from '../core/platform'; + const pathProxyForDraw = new PathProxy(true); // Not use el#hasStroke because style may be different. @@ -88,7 +89,13 @@ export function createCanvasPattern( } // Draw Path Elements -function brushPath(ctx: CanvasRenderingContext2D, el: Path, style: PathStyleProps, inBatch: boolean) { +function brushPath( + ctx: CanvasRenderingContext2D, + el: Path, + style: PathStyleProps, + canBatch: boolean, + scope: BrushScope +) { let hasStroke = styleHasStroke(style); let hasFill = styleHasFill(style); @@ -107,7 +114,7 @@ function brushPath(ctx: CanvasRenderingContext2D, el: Path, style: PathStyleProp const path = el.path || pathProxyForDraw; const dirtyFlag = el.__dirty; - if (!inBatch) { + if (!canBatch) { const fill = style.fill; const stroke = style.stroke; @@ -206,7 +213,7 @@ function brushPath(ctx: CanvasRenderingContext2D, el: Path, style: PathStyleProp } path.reset(); - el.buildPath(path, el.shape, inBatch); + el.buildPath(path, el.shape, canBatch); path.toStatic(); // Clear path dirty flag @@ -223,7 +230,7 @@ function brushPath(ctx: CanvasRenderingContext2D, el: Path, style: PathStyleProp ctx.lineDashOffset = lineDashOffset; } - if (!inBatch) { + if (!canBatch) { if (style.strokeFirst) { if (hasStroke) { doStrokePath(ctx, style); @@ -241,6 +248,11 @@ function brushPath(ctx: CanvasRenderingContext2D, el: Path, style: PathStyleProp } } } + else { + // Note that flushPathDrawn has been executed if !canBatchPath . + scope.batchFill = hasFill; + scope.batchStroke = hasStroke; + } if (lineDash) { // PENDING @@ -422,10 +434,10 @@ function bindPathAndTextCommonStyle( forceSetAll: boolean, scope: BrushScope ) { - const style = getStyle(el, scope.inHover); + const style = el.style; const prevStyle = forceSetAll ? null - : (prevEl && getStyle(prevEl, scope.inHover) || {}); + : (prevEl && prevEl.style || {}); // Shared same style. prevStyle will be null if forceSetAll. if (style === prevStyle) { return false; @@ -495,8 +507,8 @@ function bindImageStyle( ) { return bindCommonProps( ctx, - getStyle(el, scope.inHover), - prevEl && getStyle(prevEl, scope.inHover), + el.style, + prevEl && prevEl.style, forceSetAll, scope ); @@ -557,15 +569,17 @@ export type BrushScope = { viewHeight: number // Status for clipping - prevElClipPaths?: Path[] + prevElClipPaths?: Path[] // Only paths with length > 0 can be assigned. prevEl?: Displayable allClipped?: boolean // If the whole element can be clipped // Status for batching - batchFill?: string - batchStroke?: string + batchFill?: boolean + batchStroke?: boolean lastDrawType?: number + + beforeBrushParam: BeforeBrushParam } // If path can be batched @@ -590,20 +604,22 @@ function canPathBatch(style: PathStyleProps) { ); } +// Should be idempotent - may be called more than necessary. function flushPathDrawn(ctx: CanvasRenderingContext2D, scope: BrushScope) { - // Force flush all after drawn last element - scope.batchFill && ctx.fill(); - scope.batchStroke && ctx.stroke(); - scope.batchFill = ''; - scope.batchStroke = ''; -} - -function getStyle(el: Displayable, inHover?: boolean) { - return inHover ? (el.__hoverStyle || el.style) : el.style; + if (scope.batchFill) { + scope.batchFill = false; + ctx.fill(); + } + if (scope.batchStroke) { + scope.batchStroke = false; + ctx.stroke(); + } } export function brushSingle(ctx: CanvasRenderingContext2D, el: Displayable) { - brush(ctx, el, { inHover: false, viewWidth: 0, viewHeight: 0 }, true); + const scope = { inHover: false, viewWidth: 0, viewHeight: 0, beforeBrushParam: {} }; + brush(ctx, el, scope); + brushLoopFinalize(ctx, scope); } // Brush different type of elements. @@ -611,7 +627,6 @@ export function brush( ctx: CanvasRenderingContext2D, el: Displayable, scope: BrushScope, - isLast: boolean ) { const m = el.transform; @@ -628,16 +643,16 @@ export function brush( // HANDLE CLIPPING const clipPaths = el.__clipPaths; const prevElClipPaths = scope.prevElClipPaths; + const style = el.style; let forceSetTransform = false; let forceSetStyle = false; // Optimize when clipping on group with several elements if (!prevElClipPaths || isClipPathChanged(clipPaths, prevElClipPaths)) { // If has previous clipping state, restore from it - if (prevElClipPaths && prevElClipPaths.length) { + if (prevElClipPaths) { // Flush restore flushPathDrawn(ctx, scope); - ctx.restore(); // Must set all style and transform because context changed by restore forceSetStyle = forceSetTransform = true; @@ -651,13 +666,12 @@ export function brush( if (clipPaths && clipPaths.length) { // Flush before clip flushPathDrawn(ctx, scope); - ctx.save(); updateClipStatus(clipPaths, ctx, scope); // Must set transform because it's changed when clip. forceSetTransform = true; + scope.prevElClipPaths = clipPaths; } - scope.prevElClipPaths = clipPaths; } // Not rendering elements if it's clipped by a zero area path. @@ -676,12 +690,17 @@ export function brush( // ctx.fill(); // ) if (scope.allClipped) { + // Needs to mark el rendered. + // Or this element will always been rendered in progressive rendering. + // But other dirty bit should not be cleared, otherwise it cause the shape + // can not be updated in this case. + el.__dirty &= ~REDRAW_BIT; el.__isRendered = false; return; } // START BRUSH - el.beforeBrush && el.beforeBrush(); + el.beforeBrush && el.beforeBrush(scope.beforeBrushParam); el.innerBeforeBrush(); const prevEl = scope.prevEl; @@ -692,7 +711,7 @@ export function brush( let canBatchPath = el instanceof Path // Only path supports batch && el.autoBatch - && canPathBatch(el.style); + && canPathBatch(style); if (forceSetTransform || isTransformChanged(m, prevEl.transform)) { // Flush @@ -704,7 +723,6 @@ export function brush( flushPathDrawn(ctx, scope); } - const style = getStyle(el, scope.inHover); if (el instanceof Path) { // PENDING do we need to rebind all style if displayable type changed? if (scope.lastDrawType !== DRAW_TYPE_PATH) { @@ -714,15 +732,11 @@ export function brush( bindPathAndTextCommonStyle(ctx, el as Path, prevEl as Path, forceSetStyle, scope); // Begin path at start + // (can be skipped only if this el can be batched and there are previous batched rendering). if (!canBatchPath || (!scope.batchFill && !scope.batchStroke)) { ctx.beginPath(); } - brushPath(ctx, el as Path, style, canBatchPath); - - if (canBatchPath) { - scope.batchFill = style.fill as string || ''; - scope.batchStroke = style.stroke as string || ''; - } + brushPath(ctx, el as Path, style, canBatchPath, scope); } else { if (el instanceof TSpan) { @@ -755,12 +769,15 @@ export function brush( } - if (canBatchPath && isLast) { - flushPathDrawn(ctx, scope); - } - el.innerAfterBrush(); - el.afterBrush && el.afterBrush(); + if (el.afterBrush) { + if (canBatchPath) { + flushPathDrawn(ctx, scope); + // If !canBatch, flushPathDrawn has been executed before. + // el.afterBrush may call methods such as ctx.fillRect . + } + el.afterBrush(); + } scope.prevEl = el; @@ -769,6 +786,26 @@ export function brush( el.__isRendered = true; } +/** + * Must be called after `brush()` iteration finished, regardless of whether + * reaching `layer.__endIndex`. + * + * NOTE: This method may be called with all `brush()` are skipped. + */ +export function brushLoopFinalize( + ctx: CanvasRenderingContext2D, + scope: BrushScope +) { + flushPathDrawn(ctx, scope); + if (scope.prevElClipPaths) { + // Needs restore the state. If last drawn element is in the clipping area. + // NOTE: It should not be called before el.afterBrush, since el.afterBrush + // may call methods such as ctx.fillRect. + ctx.restore(); + // No need to clear scope, since it should no longer be used. + } +} + function brushIncremental( ctx: CanvasRenderingContext2D, el: IncrementalDisplayable, @@ -785,30 +822,34 @@ function brushIncremental( allClipped: false, viewWidth: scope.viewWidth, viewHeight: scope.viewHeight, - inHover: scope.inHover + inHover: scope.inHover, + beforeBrushParam: {} }; let i; let len; // Render persistant displayables. for (i = el.getCursor(), len = displayables.length; i < len; i++) { const displayable = displayables[i]; - displayable.beforeBrush && displayable.beforeBrush(); + displayable.beforeBrush && displayable.beforeBrush(scope.beforeBrushParam); displayable.innerBeforeBrush(); - brush(ctx, displayable, innerScope, i === len - 1); + brush(ctx, displayable, innerScope); displayable.innerAfterBrush(); displayable.afterBrush && displayable.afterBrush(); innerScope.prevEl = displayable; } + brushLoopFinalize(ctx, innerScope); // Render temporary displayables. for (let i = 0, len = temporalDisplayables.length; i < len; i++) { const displayable = temporalDisplayables[i]; - displayable.beforeBrush && displayable.beforeBrush(); + displayable.beforeBrush && displayable.beforeBrush(scope.beforeBrushParam); displayable.innerBeforeBrush(); - brush(ctx, displayable, innerScope, i === len - 1); + brush(ctx, displayable, innerScope); displayable.innerAfterBrush(); displayable.afterBrush && displayable.afterBrush(); innerScope.prevEl = displayable; } + brushLoopFinalize(ctx, innerScope); + el.clearTemporalDisplayables(); el.notClear = true; diff --git a/src/canvas/helper.ts b/src/canvas/helper.ts index 29d0510aa..0db53adaf 100644 --- a/src/canvas/helper.ts +++ b/src/canvas/helper.ts @@ -123,5 +123,5 @@ export function getSize( (root[cwh] || parseInt10(stl[wh]) || parseInt10(root.style[wh])) - (parseInt10(stl[plt]) || 0) - (parseInt10(stl[prb]) || 0) - ) | 0; + ) || 0; } \ No newline at end of file diff --git a/src/core/BoundingRect.ts b/src/core/BoundingRect.ts index c660f7f97..d3af1bd49 100644 --- a/src/core/BoundingRect.ts +++ b/src/core/BoundingRect.ts @@ -1,4 +1,5 @@ import * as matrix from './matrix'; +import * as vector from './vector'; import Point, { PointLike } from './Point'; import { NullUndefined } from './types'; @@ -28,7 +29,7 @@ class BoundingRect { height: number constructor(x: number, y: number, width: number, height: number) { - BoundingRect.set(this, x, y, width, height); + boundingRectSet(this, x, y, width, height); } static set( @@ -86,17 +87,7 @@ class BoundingRect { } calculateTransform(b: RectLike): matrix.MatrixArray { - const a = this; - const sx = b.width / a.width; - const sy = b.height / a.height; - - const m = matrix.create(); - - matrix.translate(m, m, [-a.x, -a.y]); - matrix.scale(m, m, [sx, sy]); - matrix.translate(m, m, [b.x, b.y]); - - return m; + return boundingRectCalculateTransform(matrix.create(), this, b); } /** @@ -141,10 +132,10 @@ class BoundingRect { // Normalize negative width/height. if (!(a instanceof BoundingRect)) { - a = BoundingRect.set(_tmpIntersectA, a.x, a.y, a.width, a.height); + a = boundingRectSet(_tmpIntersectA, a.x, a.y, a.width, a.height); } if (!(b instanceof BoundingRect)) { - b = BoundingRect.set(_tmpIntersectB, b.x, b.y, b.width, b.height); + b = boundingRectSet(_tmpIntersectB, b.x, b.y, b.width, b.height); } const useMTV = !!mtv; @@ -208,7 +199,7 @@ class BoundingRect { * Copy from another rect */ copy(other: RectLike) { - BoundingRect.copy(this, other); + boundingRectCopy(this, other); } plain(): RectLike { @@ -234,8 +225,13 @@ class BoundingRect { return this.width === 0 || this.height === 0; } - static create(rect: RectLike): BoundingRect { - return new BoundingRect(rect.x, rect.y, rect.width, rect.height); + static create(rect?: RectLike | NullUndefined): BoundingRect { + return new BoundingRect( + rect ? rect.x : 0, + rect ? rect.y : 0, + rect ? rect.width : 0, + rect ? rect.height : 0 + ); } static copy(target: TTarget, source: RectLike): TTarget { @@ -253,7 +249,7 @@ class BoundingRect { // And element has no transform if (!m) { if (target !== source) { - BoundingRect.copy(target, source); + boundingRectCopy(target, source); } return; } @@ -296,10 +292,32 @@ class BoundingRect { target.width = maxX - target.x; target.height = maxY - target.y; } + + static calculateTransform(out: matrix.MatrixArray | NullUndefined, a: RectLike, b: RectLike): matrix.MatrixArray { + const sx = b.width / a.width; + const sy = b.height / a.height; + + out = matrix.identity(out || []); + + matrix.translate(out, out, vector.set(_tmpCalcTrans, -a.x, -a.y)); + matrix.scale(out, out, vector.set(_tmpCalcTrans, sx, sy)); + matrix.translate(out, out, vector.set(_tmpCalcTrans, b.x, b.y)); + + return out; + } + } +export const boundingRectCreate = BoundingRect.create; +export const boundingRectSet = BoundingRect.set; +export const boundingRectCopy = BoundingRect.copy; +export const boundingRectCalculateTransform = BoundingRect.calculateTransform; +export const boundingRectApplyTransform = BoundingRect.applyTransform; +export const boundingRectContain = BoundingRect.contain; + const _tmpIntersectA = new BoundingRect(0, 0, 0, 0); const _tmpIntersectB = new BoundingRect(0, 0, 0, 0); +const _tmpCalcTrans: vector.VectorArray = []; function intersectOneDim( diff --git a/src/core/Transformable.ts b/src/core/Transformable.ts index d2cda359c..0158de999 100644 --- a/src/core/Transformable.ts +++ b/src/core/Transformable.ts @@ -1,4 +1,5 @@ import * as matrix from './matrix'; +import { assignProps } from './util'; import * as vector from './vector'; const mIdentity = matrix.identity; @@ -54,7 +55,7 @@ class Transformable { * Get computed local transform */ getLocalTransform(m?: matrix.MatrixArray) { - return Transformable.getLocalTransform(this, m); + return transformableGetLocalTransform(this, m); } /** @@ -140,6 +141,9 @@ class Transformable { this.transform = m; this._resolveGlobalScaleRatio(m); + + this.invTransform = this.invTransform || matrix.create(); + matrix.invert(this.invTransform, m); } private _resolveGlobalScaleRatio(m: matrix.MatrixArray) { @@ -156,9 +160,6 @@ class Transformable { m[2] *= sy; m[3] *= sy; } - - this.invTransform = this.invTransform || matrix.create(); - matrix.invert(this.invTransform, m); } /** @@ -341,6 +342,7 @@ class Transformable { m[4] += ox + x; m[5] += oy + y; + // NOTICE: All of `m[0~5]` must be set, since `m` may be reused. return m; } @@ -361,20 +363,23 @@ class Transformable { })() }; +export const transformableGetLocalTransform = Transformable.getLocalTransform; + +export function transformableCreate(): Transformable { + return new Transformable(); +} + export const TRANSFORMABLE_PROPS = [ 'x', 'y', 'originX', 'originY', 'anchorX', 'anchorY', 'rotation', 'scaleX', 'scaleY', 'skewX', 'skewY' ] as const; export type TransformProp = (typeof TRANSFORMABLE_PROPS)[number] -export function copyTransform( - target: Partial>, +export function copyTransform>>( + target: TOut, source: Pick -) { - for (let i = 0; i < TRANSFORMABLE_PROPS.length; i++) { - const propName = TRANSFORMABLE_PROPS[i]; - target[propName] = source[propName]; - } +): TOut { + return assignProps(target, source, TRANSFORMABLE_PROPS); } export default Transformable; \ No newline at end of file diff --git a/src/core/platform.ts b/src/core/platform.ts index c89110246..0e997ea8a 100644 --- a/src/core/platform.ts +++ b/src/core/platform.ts @@ -11,6 +11,8 @@ interface Platform { onload: () => void | HTMLImageElement['onload'], onerror: () => void | HTMLImageElement['onerror'] ): HTMLImageElement + // Testing friendly to control frames + getTime(): number } // Text width map used for environment there is no canvas @@ -98,13 +100,18 @@ export const platformApi: Platform = { image.onerror = onerror; image.src = src; return image; + }, + + getTime(): number { + // Indicatively, Date.now can be executed in 13,025,305 ops/second in a certain env. + return Date.now ? Date.now() : +(new Date()); } }; export function setPlatformAPI(newPlatformApis: Partial) { for (let key in platformApi) { // Don't assign unknown methods. - if ((newPlatformApis as any)[key]) { + if (platformApi.hasOwnProperty(key) && (newPlatformApis as any)[key]) { (platformApi as any)[key] = (newPlatformApis as any)[key]; } } diff --git a/src/core/types.ts b/src/core/types.ts index c207eab40..3ca5988e0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -11,6 +11,13 @@ export type ArrayLike = { length: number } +/** + * NOTICE: For historical reason, zrender have not enabled TS config + * `strictNullChecks` yet. Therefore, a explicitly declared `NullUndefined` can + * indicate a variable can be `null` or `undefined` without more investigation, + * but a variable without `NullUndefined` may also be `null` or `undefined`, + * which has to be determined by the implementation. + */ export type NullUndefined = null | undefined; export type ImageLike = HTMLImageElement | HTMLCanvasElement | HTMLVideoElement @@ -98,3 +105,34 @@ export type KeyOfDistributive = T extends unknown ? keyof T : never; export type WithThisType any, This> = (this: This, ...args: Parameters) => ReturnType; + + +/** + * - `0` means incremental rendering is disabled. + * - A positive integer enables increamental rendering, + * And distinguish different runs of consecutive incremental elements. + * - `1` is preserved from backward compatibility - truthy value will be converted + * to `1`. + * + * @see DISPLAY_LIST_SORTING_AND_LAYERING for more details. + */ +export type IncrementalId = number; +// Previously `el.incremental` is boolean. This is only used +// for both TS type and value backward compatibility. +// Internal conversion: true => 1, false => 0. +export type IncrementalIdCompat = number | boolean; +export const INCREMENTAL_ID_FALSE = 0; +export const INCREMENTAL_ID_TRUE_COMPAT = 1; + + +export type ZLevel = number; +// zlevel2 can not be specified by users. It is assigned internally +// and always be 0, 1, 2; never be greater than 2. +export type ZLevel2 = + typeof ZLEVEL2_NORMAL_ABOVE + | typeof ZLEVEL2_INCREMENTAL + | typeof ZLEVEL2_NORMAL_BELOW + +export const ZLEVEL2_NORMAL_ABOVE = 2; +export const ZLEVEL2_INCREMENTAL = 1; +export const ZLEVEL2_NORMAL_BELOW = 0; diff --git a/src/core/util.ts b/src/core/util.ts index b36f7f714..7b5934262 100644 --- a/src/core/util.ts +++ b/src/core/util.ts @@ -1,5 +1,5 @@ /* global: defineProperty */ -import { Dictionary, ArrayLike, KeyOfDistributive } from './types'; +import { Dictionary, ArrayLike, KeyOfDistributive, NullUndefined } from './types'; import { GradientObject } from '../graphic/Gradient'; import { ImagePatternObject } from '../graphic/Pattern'; import { platformApi } from './platform'; @@ -50,10 +50,15 @@ const protoKey = '__proto__'; let idStart = 0x0907; +const MAX_SAFE_INTEGER = Math.pow(2, 53) - 1; + /** * Generate unique id */ export function guid(): number { + if (idStart >= MAX_SAFE_INTEGER) { + idStart = 0; + } return idStart++; } @@ -198,6 +203,36 @@ export function extend< return target as T & S; } +export function assignProps< + TSrc extends Dictionary, + TCommonKey extends keyof TSrc +>( + tar: NullUndefined, + src: TSrc, + props: readonly TCommonKey[] +): Pick; +export function assignProps< + TTar extends Dictionary, + TSrc extends Dictionary, + TCommonKey extends keyof TSrc & keyof TTar +>( + tar: TTar, + src: TSrc & { [P in TCommonKey]: TTar[P] }, + props: readonly TCommonKey[] +): TTar; +export function assignProps( + tar: any, + src: any, + props: readonly string[] +) { + tar = (tar || {}); + for (let idx = 0; idx < props.length; idx++) { + const prop = props[idx]; + tar[prop] = src[prop]; + } + return tar; +} + export function defaults< T extends Dictionary, S extends Dictionary diff --git a/src/graphic/Displayable.ts b/src/graphic/Displayable.ts index 839156f2a..fbf1d3e45 100644 --- a/src/graphic/Displayable.ts +++ b/src/graphic/Displayable.ts @@ -2,9 +2,12 @@ * Base class of all displayable graphic objects */ -import Element, {ElementProps, ElementStatePropNames, ElementAnimateConfig, ElementCommonState} from '../Element'; +import Element, { + ElementProps, ElementStatePropNames, ElementAnimateConfig, ElementCommonState, + IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE, +} from '../Element'; import BoundingRect from '../core/BoundingRect'; -import { PropType, Dictionary, MapToType } from '../core/types'; +import { PropType, Dictionary, MapToType, IncrementalIdCompat } from '../core/types'; import Path from './Path'; import { keys, extend, createObject } from '../core/util'; import Animator from '../animation/Animator'; @@ -64,7 +67,7 @@ export interface DisplayableProps extends ElementProps { progressive?: boolean - incremental?: boolean + incremental?: Displayable['incremental'] ignoreCoarsePointer?: boolean @@ -81,6 +84,12 @@ export type DisplayableState = Pick const PRIMARY_STATES_KEYS = ['z', 'z2', 'invisible'] as const; const PRIMARY_STATES_KEYS_IN_HOVER_LAYER = ['invisible'] as const; +export interface BeforeBrushParam { + // [EXPERIMENTAL] + // true means the layer is not cleared before this run of brush(). + contentRetained?: boolean +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Displayable { animate(key?: '', loop?: boolean): Animator @@ -124,16 +133,54 @@ class Displayable extends Ele * If hover area is bounding rect */ rectHover: boolean + + incremental: IncrementalIdCompat + /** - * For increamental rendering + * For an incremental element. + * `true` can prevent its incremental layer from clearing even when `REDRAW_BIT` is set. + * `false` is the normal behavior as other elements - can clear when `REDRAW_BIT` is set. + * + * NOTICE: The layer may be still cleared if marked as dirty by other incremental elements + * sharing the same layer. Therefore, `contentRetained` is used in indicate whether the + * content is retained, which enable the element to reset its internal draw index. + * + * Typical usage: + * ``` + * class LargePath extends Path { + * reset() { + * this._idx = 0; + * this.notClear = false; + * } + * beforeBrush(param) { + * if (!param.contentRetained) { this.reset(); } + * } + * buildPath() { + * for (this._idx; this._idx < this.shape.points.length; this._idx++) { + * // draw + * } + * this.notClear = true; + * } + * } + * function incrementalUpdate(el, incrementalPoints) { + * const allPoints = mergePoints(el.shape.points, incrementalPoints); + * el.setShape({points: allPoints}); + * // The REDRAW_BIT is set but need to retain the rendered content. + * } + * ``` */ - incremental: boolean + notClear?: boolean + /** + * See `notClear` + */ + __layerCleared?: boolean /** * Never increase to target size */ ignoreCoarsePointer?: boolean + // FIXME: do not use TS any. style: Dictionary protected _normalState: DisplayableState @@ -188,7 +235,7 @@ class Displayable extends Ele } // Hook provided to developers. - beforeBrush() {} + beforeBrush(param: BeforeBrushParam) {} afterBrush() {} // Hook provided to inherited classes. @@ -200,7 +247,7 @@ class Displayable extends Ele viewWidth: number, viewHeight: number, considerClipPath: boolean, - considerAncestors: boolean + considerAncestors: boolean, ) { const m = this.transform; if ( @@ -413,15 +460,22 @@ class Displayable extends Ele if (!obj[STYLE_MAGIC_KEY]) { obj = this.createStyle(obj); } - if (this.__inHover) { - this.__hoverStyle = obj; // Not affect exists style. - } - else { - this.style = obj; - } + // // See the comment `HOVER_LAYER_CONSTRAINTS` for `hoverStyle` case. + this.style = obj; this.dirtyStyle(); } + protected _useHoverStyle(obj: Props['style']) { + this.__hoverStyle = obj; + // this.dirtyStyle(); + // PENDING: + // Since HOVER_LAYER_CONSTRAINTS_TEXT is not supported, no need to call + // `this.dirtyStyle()` here. + // Sub texts updating requires `this.dirtyStyle()` to trigger them. + // But a STYLE_CHANGED_BIT may cause repaint of the original layer if new TSpan is + // created or updated, which is unexpected when hover layer is used. + } + /** * Determine if an object is a valid style object. * Which means it is created by `createStyle.` @@ -457,6 +511,10 @@ class Displayable extends Ele super._applyStateObj(stateName, state, normalState, keepCurrentStates, transition, animationCfg); const needsRestoreToNormal = !(state && keepCurrentStates); + const inHoverOnlyStyleChange = this.__inHover === IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE; + + // NOTE: `transition` has been garanteed `false` when `this.__inHover` is a truthy value. + let targetStyle: Props['style']; if (state && state.style) { // Only animate changed properties. @@ -482,7 +540,7 @@ class Displayable extends Ele } if (targetStyle) { - if (transition) { + if (transition) { // transition must be false if hoverLayer is used. // Clone a new style. Not affect the original one. const sourceStyle = this.style; @@ -518,23 +576,30 @@ class Displayable extends Ele } as Props, animationCfg, this.getAnimationStyleProps() as MapToType); } else { - this.useStyle(targetStyle); + if (inHoverOnlyStyleChange) { + this._useHoverStyle(targetStyle); + } + else { + this.useStyle(targetStyle); + } } } // Don't change z, z2 for element moved into hover layer. // It's not necessary and will cause paint list order changed. - const statesKeys = this.__inHover ? PRIMARY_STATES_KEYS_IN_HOVER_LAYER : PRIMARY_STATES_KEYS; - for (let i = 0; i < statesKeys.length; i++) { - let key = statesKeys[i]; - if (state && state[key] != null) { - // Replace if it exist in target state - (this as any)[key] = state[key]; - } - else if (needsRestoreToNormal) { - // Restore to normal state - if (normalState[key] != null) { - (this as any)[key] = normalState[key]; + if (!inHoverOnlyStyleChange) { + const statesKeys = this.__inHover ? PRIMARY_STATES_KEYS_IN_HOVER_LAYER : PRIMARY_STATES_KEYS; + for (let i = 0; i < statesKeys.length; i++) { + let key = statesKeys[i]; + if (state && state[key] != null) { + // Replace if it exist in target state + (this as any)[key] = state[key]; + } + else if (needsRestoreToNormal) { + // Restore to normal state + if (normalState[key] != null) { + (this as any)[key] = normalState[key]; + } } } } @@ -599,7 +664,7 @@ class Displayable extends Ele dispProto.culling = false; dispProto.cursor = 'pointer'; dispProto.rectHover = false; - dispProto.incremental = false; + dispProto.incremental = 0; dispProto._rect = null; dispProto.dirtyRectTolerance = 0; diff --git a/src/graphic/Group.ts b/src/graphic/Group.ts index 3d802bffa..dbf3afa13 100644 --- a/src/graphic/Group.ts +++ b/src/graphic/Group.ts @@ -156,6 +156,10 @@ class Group extends Element { child.addSelfToZr(zr); } + // NOTE: Group does not mark itself dirty when adding children. + // Otherwise, a dirty group will dirty all children in _updateAndAddDisplayable, + // which breaks incremental case. + zr && zr.refresh(); } diff --git a/src/graphic/IncrementalDisplayable.ts b/src/graphic/IncrementalDisplayable.ts index c1fd4dbc7..05051d192 100644 --- a/src/graphic/IncrementalDisplayable.ts +++ b/src/graphic/IncrementalDisplayable.ts @@ -11,6 +11,7 @@ import Displayble from './Displayable'; import BoundingRect from '../core/BoundingRect'; import { MatrixArray } from '../core/matrix'; import Group from './Group'; +import { INCREMENTAL_ID_TRUE_COMPAT } from '../core/types'; const m: MatrixArray = []; // TODO Style override ? @@ -19,7 +20,7 @@ export default class IncrementalDisplayable extends Displayble { notClear: boolean = true - incremental = true + incremental = INCREMENTAL_ID_TRUE_COMPAT private _displayables: Displayble[] = [] private _temporaryDisplayables: Displayble[] = [] @@ -39,6 +40,12 @@ export default class IncrementalDisplayable extends Displayble { this.style = {}; } + protected _useHoverStyle() { + // Use an empty style + // PENDING + this.__hoverStyle = null; + } + // getCurrentCursor / updateCursorAfterBrush // is used in graphic.ts. It's not provided for developers getCursor() { diff --git a/src/graphic/Path.ts b/src/graphic/Path.ts index b1b16d087..ac2098f17 100644 --- a/src/graphic/Path.ts +++ b/src/graphic/Path.ts @@ -4,7 +4,7 @@ import Displayable, { DisplayableProps, DisplayableStatePropNames, DEFAULT_COMMON_ANIMATION_PROPS } from './Displayable'; -import Element, { ElementAnimateConfig } from '../Element'; +import Element, {ElementAnimateConfig, ElementCommonState, IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE} from '../Element'; import PathProxy from '../core/PathProxy'; import * as pathContain from '../contain/path'; import { PatternObject } from './Pattern'; @@ -123,7 +123,7 @@ interface Path { export type PathStatePropNames = DisplayableStatePropNames | 'shape'; export type PathState = Pick & { - hoverLayer?: boolean + hoverLayer?: ElementCommonState['hoverLayer'] } const pathCopyParams = (TRANSFORMABLE_PROPS as readonly string[]).concat(['invisible', @@ -145,7 +145,9 @@ class Path extends Displayable { style: PathStyleProps /** - * If element can be batched automatically + * Whether elements can be batched (call `fill()` and `stroke()` only once) if possible. + * Users should guarantee batched elements are consecutive in display list (sorted by + * zlevel, z, z2) and has the same style, otherwise the effect may be unexpected. */ autoBatch: boolean @@ -527,6 +529,11 @@ class Path extends Displayable { animationCfg: ElementAnimateConfig ) { super._applyStateObj(stateName, state, normalState, keepCurrentStates, transition, animationCfg); + + if (this.__inHover === IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE) { + return; + } + const needsRestoreToNormal = !(state && keepCurrentStates); let targetShape: Props['shape']; if (state && state.shape) { diff --git a/src/graphic/Text.ts b/src/graphic/Text.ts index 6d0e8a59e..aaa1f31a3 100644 --- a/src/graphic/Text.ts +++ b/src/graphic/Text.ts @@ -318,7 +318,20 @@ class ZRText extends Displayable implements GroupLike { // Update children if (this.styleChanged()) { + + // PENDING: (See HOVER_LAYER_CONSTRAINTS_TEXT) + // let originalStyle; + // const hoverStyle = this.__hoverStyle; + // if (hoverStyle) { + // originalStyle = this.style; + // this.style = hoverStyle; + // } + this._updateSubTexts(); + + // if (hoverStyle) { + // this.style = originalStyle; + // } } for (let i = 0; i < this._children.length; i++) { @@ -330,6 +343,9 @@ class ZRText extends Displayable implements GroupLike { child.culling = this.culling; child.cursor = this.cursor; child.invisible = this.invisible; + + // PENDING: (See HOVER_LAYER_CONSTRAINTS_TEXT) + // child.__inHover = this.__inHover; } } diff --git a/src/svg-legacy/Painter.ts b/src/svg-legacy/Painter.ts index 3eccdf820..701100b0f 100644 --- a/src/svg-legacy/Painter.ts +++ b/src/svg-legacy/Painter.ts @@ -403,7 +403,6 @@ class SVGPainter implements PainterBase { const html = encodeURIComponent(outerHTML.replace(/>\n\r<')); return 'data:image/svg+xml;charset=UTF-8,' + html; } - refreshHover = createMethodNotSupport('refreshHover') as PainterBase['refreshHover']; configLayer = createMethodNotSupport('configLayer') as PainterBase['configLayer']; } diff --git a/src/svg/Painter.ts b/src/svg/Painter.ts index 5d1981c77..551ab29b5 100644 --- a/src/svg/Painter.ts +++ b/src/svg/Painter.ts @@ -354,7 +354,6 @@ class SVGPainter implements PainterBase { return prefix + 'charset=UTF-8,' + encodeURIComponent(str); } - refreshHover = createMethodNotSupport('refreshHover') as PainterBase['refreshHover']; configLayer = createMethodNotSupport('configLayer') as PainterBase['configLayer']; } diff --git a/src/zrender.ts b/src/zrender.ts index 0db33b9a0..65365492f 100644 --- a/src/zrender.ts +++ b/src/zrender.ts @@ -25,6 +25,7 @@ import Displayable from './graphic/Displayable'; import { lum } from './tool/color'; import { DARK_MODE_THRESHOLD } from './config'; import Group from './graphic/Group'; +import { CanvasPainterRefreshOpt } from './canvas/Painter'; type PainterBaseCtor = { @@ -80,7 +81,8 @@ class ZRender { private _stillFrameAccum = 0; private _needsRefresh = true - private _needsRefreshHover = true + // true can lead to creating a hover layer. Do not set true unless required. + private _needsRefreshHover = false private _disposed: boolean; /** * If theme is dark mode. It will determine the color strategy for labels. @@ -141,7 +143,7 @@ class ZRender { this.animation = new Animation({ stage: { - update: ssrMode ? null : () => this._flush(true) + update: ssrMode ? null : () => this._flush(false) } }); @@ -220,12 +222,23 @@ class ZRender { /** * Repaint the canvas immediately */ - refreshImmediately(fromInside?: boolean) { + refreshImmediately(noAnimationUpdate?: boolean) { if (this._disposed) { return; } - // const start = new Date(); - if (!fromInside) { + this._refresh({ + animUpdate: !noAnimationUpdate, + refresh: true, + refreshHover: false, + }); + } + + private _refresh(opt: { + animUpdate: boolean; + refresh: CanvasPainterRefreshOpt['refresh']; // work for only CanvasPainter + refreshHover: CanvasPainterRefreshOpt['refreshHover']; // work for only CanvasPainter + }) { + if (opt.animUpdate) { // Update animation if refreshImmediately is invoked from outside. // Not trigger stage update to call flush again. Which may refresh twice this.animation.update(true); @@ -233,10 +246,14 @@ class ZRender { // Clear needsRefresh ahead to avoid something wrong happens in refresh // Or it will cause zrender refreshes again and again. - this._needsRefresh = false; - this.painter.refresh(); - // Avoid trigger zr.refresh in Element#beforeUpdate hook - this._needsRefresh = false; + this._needsRefresh = this._needsRefreshHover = false; + this.painter.refresh({ + refresh: opt.refresh, + refreshHover: opt.refreshHover, + }); + // Avoid trigger zr.refresh in Element#beforeUpdate hook. + // Hover layer is always refreshed when refreshing normal layers. + this._needsRefresh = this._needsRefreshHover = false; } /** @@ -246,6 +263,7 @@ class ZRender { if (this._disposed) { return; } + this._needsRefresh = true; // Active the animation again. this.animation.start(); @@ -258,21 +276,23 @@ class ZRender { if (this._disposed) { return; } - this._flush(false); + this._flush(true); } - private _flush(fromInside?: boolean) { + private _flush(animationUpdate: boolean) { let triggerRendered; const start = getTime(); - if (this._needsRefresh) { - triggerRendered = true; - this.refreshImmediately(fromInside); - } + const needsRefresh = this._needsRefresh; + const needsRefreshHover = this._needsRefreshHover; - if (this._needsRefreshHover) { + if (needsRefresh || needsRefreshHover) { triggerRendered = true; - this.refreshHoverImmediately(); + this._refresh({ + animUpdate: animationUpdate, + refresh: needsRefresh, + refreshHover: needsRefreshHover, + }); } const end = getTime(); @@ -319,16 +339,18 @@ class ZRender { } /** + * @deprecated * Refresh hover immediately */ refreshHoverImmediately() { if (this._disposed) { return; } - this._needsRefreshHover = false; - if (this.painter.refreshHover && this.painter.getType() === 'canvas') { - this.painter.refreshHover(); - } + this._refresh({ + animUpdate: false, + refresh: false, + refreshHover: true + }); } /**