From 18abd768e0e9dca402aa28aa25d62ea90a41b96f Mon Sep 17 00:00:00 2001 From: 100pah Date: Thu, 12 Mar 2026 20:59:39 +0800 Subject: [PATCH 01/14] fix: (1) Fix that ctx.save() and ctx.restore() do not properly paired, which can cause canvas is not properly cleared due to incorrect transform. It can be reproduced when both hover layered and zlevel is used. Close apache/echarts#18688 . (2) Clarity el.autoBatch and fix that batched rendering may not be executed in some edge cases. --- src/canvas/Painter.ts | 28 ++++++------ src/canvas/graphic.ts | 102 +++++++++++++++++++++++++++++------------- src/core/types.ts | 7 +++ src/graphic/Path.ts | 4 +- 4 files changed, 94 insertions(+), 47 deletions(-) diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 568885692..1596b2b7a 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -4,11 +4,11 @@ import Layer, { LayerConfig } from './Layer'; import requestAnimationFrame from '../animation/requestAnimationFrame'; import env from '../core/env'; import Displayable from '../graphic/Displayable'; -import { WXCanvasRenderingContext } from '../core/types'; +import { NullUndefined, WXCanvasRenderingContext } 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, brushFinalize, BrushScope, brushSingle } from './graphic'; import { PainterBase } from '../PainterBase'; import BoundingRect from '../core/BoundingRect'; import { REDRAW_BIT } from '../graphic/constants'; @@ -291,10 +291,10 @@ export default class CanvasPainter implements PainterBase { ctx = hoverLayer.ctx; ctx.save(); } - - brush(ctx, el, scope, i === len - 1); + brush(ctx, el, scope); } } + brushFinalize(ctx, scope); if (ctx) { ctx.restore(); } @@ -410,7 +410,7 @@ export default class CanvasPainter implements PainterBase { } let i: number; /* eslint-disable-next-line */ - const repaint = (repaintRect?: BoundingRect) => { + const repaint = (repaintRect: BoundingRect | NullUndefined) => { const scope: BrushScope = { inHover: false, allClipped: false, @@ -426,7 +426,7 @@ export default class CanvasPainter implements PainterBase { needsRefreshHover = true; } - this._doPaintEl(el, layer, useDirtyRect, repaintRect, scope, i === layer.__endIndex - 1); + this._doPaintEl(el, layer, useDirtyRect, repaintRect, scope); if (useTimer) { // Date.now can be executed in 13,025,305 ops/second. @@ -439,10 +439,7 @@ export default class CanvasPainter implements PainterBase { } } - if (scope.prevElClipPaths) { - // Needs restore the state. If last drawn element is in the clipping area. - ctx.restore(); - } + brushFinalize(ctx, scope); }; if (repaintRects) { @@ -474,7 +471,7 @@ export default class CanvasPainter implements PainterBase { else { // Paint all once ctx.save(); - repaint(); + repaint(null); ctx.restore(); } @@ -512,12 +509,12 @@ export default class CanvasPainter implements PainterBase { if (useDirtyRect) { const paintRect = el.getPaintRect(); if (!repaintRect || paintRect && paintRect.intersect(repaintRect)) { - brush(ctx, el, scope, isLast); + brush(ctx, el, scope); el.setPrevPaintRect(paintRect); } } else { - brush(ctx, el, scope, isLast); + brush(ctx, el, scope); } } @@ -947,7 +944,7 @@ 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 @@ -955,8 +952,9 @@ export default class CanvasPainter implements PainterBase { 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); } + brushFinalize(ctx, scope); } return imageLayer.dom; diff --git a/src/canvas/graphic.ts b/src/canvas/graphic.ts index 72d554fbf..75a7c144e 100644 --- a/src/canvas/graphic.ts +++ b/src/canvas/graphic.ts @@ -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 @@ -557,13 +569,13 @@ 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 } @@ -590,12 +602,16 @@ 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 = ''; + if (scope.batchFill) { + scope.batchFill = false; + ctx.fill(); + } + if (scope.batchStroke) { + scope.batchStroke = false; + ctx.stroke(); + } } function getStyle(el: Displayable, inHover?: boolean) { @@ -603,7 +619,9 @@ function getStyle(el: Displayable, inHover?: boolean) { } 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 }; + brush(ctx, el, scope); + brushFinalize(ctx, scope); } // Brush different type of elements. @@ -611,7 +629,6 @@ export function brush( ctx: CanvasRenderingContext2D, el: Displayable, scope: BrushScope, - isLast: boolean ) { const m = el.transform; @@ -634,10 +651,9 @@ export function brush( // 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 +667,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,6 +691,11 @@ 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; } @@ -714,15 +734,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 +771,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 +788,24 @@ export function brush( el.__isRendered = true; } +/** + * Must be called after `brush()` iterations. + * NOTE: This method may be called with all `brush()` are skipped. + */ +export function brushFinalize( + 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, @@ -794,21 +831,24 @@ function brushIncremental( const displayable = displayables[i]; displayable.beforeBrush && displayable.beforeBrush(); displayable.innerBeforeBrush(); - brush(ctx, displayable, innerScope, i === len - 1); + brush(ctx, displayable, innerScope); displayable.innerAfterBrush(); displayable.afterBrush && displayable.afterBrush(); innerScope.prevEl = displayable; } + brushFinalize(ctx, innerScope); // Render temporary displayables. for (let i = 0, len = temporalDisplayables.length; i < len; i++) { const displayable = temporalDisplayables[i]; displayable.beforeBrush && displayable.beforeBrush(); displayable.innerBeforeBrush(); - brush(ctx, displayable, innerScope, i === len - 1); + brush(ctx, displayable, innerScope); displayable.innerAfterBrush(); displayable.afterBrush && displayable.afterBrush(); innerScope.prevEl = displayable; } + brushFinalize(ctx, innerScope); + el.clearTemporalDisplayables(); el.notClear = true; diff --git a/src/core/types.ts b/src/core/types.ts index c207eab40..45df7f9ec 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 diff --git a/src/graphic/Path.ts b/src/graphic/Path.ts index b1b16d087..664597652 100644 --- a/src/graphic/Path.ts +++ b/src/graphic/Path.ts @@ -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 From ba070327145422d7ffdba03119e7575de5caf3c3 Mon Sep 17 00:00:00 2001 From: 100pah Date: Thu, 12 Mar 2026 23:43:22 +0800 Subject: [PATCH 02/14] chore: Clarity the code; remove ts-ignore and unnecessary code. --- src/canvas/Painter.ts | 126 +++++++++++++++++++++--------------------- src/canvas/graphic.ts | 12 ++-- 2 files changed, 71 insertions(+), 67 deletions(-) diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 1596b2b7a..078cd3695 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -8,7 +8,7 @@ import { NullUndefined, WXCanvasRenderingContext } from '../core/types'; import { GradientObject } from '../graphic/Gradient'; import { ImagePatternObject } from '../graphic/Pattern'; import Storage from '../Storage'; -import { brush, brushFinalize, 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'; @@ -294,7 +294,7 @@ export default class CanvasPainter implements PainterBase { brush(ctx, el, scope); } } - brushFinalize(ctx, scope); + brushLoopFinalize(ctx, scope); if (ctx) { ctx.restore(); } @@ -377,7 +377,9 @@ export default class CanvasPainter implements PainterBase { } let finished = true; - let needsRefreshHover = false; + const layersPaintCtx = { + needsRefreshHover: false + }; for (let k = 0; k < layerList.length; k++) { const layer = layerList[k]; @@ -388,8 +390,7 @@ export default class CanvasPainter implements PainterBase { let start = paintAll ? layer.__startIndex : layer.__drawIndex; - const useTimer = !paintAll && layer.incremental && Date.now; - const startTime = useTimer && Date.now(); + const useTimer = !paintAll && layer.incremental && !!Date.now; const clearColor = layer.zlevel === this._zlevelList[0] ? this._backgroundColor : null; @@ -408,49 +409,16 @@ export default class CanvasPainter implements PainterBase { console.error('For some unknown reason. drawIndex is -1'); start = layer.__startIndex; } - let i: number; - /* eslint-disable-next-line */ - const repaint = (repaintRect: BoundingRect | NullUndefined) => { - 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); - - 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; - } - } - } - - brushFinalize(ctx, scope); - }; if (repaintRects) { if (repaintRects.length === 0) { // Nothing to repaint, mark as finished - i = layer.__endIndex; + layer.__drawIndex = layer.__endIndex; } else { const dpr = this.dpr; // Set repaintRect as clipPath - for (var r = 0; r < repaintRects.length; ++r) { + for (let r = 0; r < repaintRects.length; ++r) { const rect = repaintRects[r]; ctx.save(); @@ -463,7 +431,9 @@ export default class CanvasPainter implements PainterBase { ); ctx.clip(); - repaint(rect); + this._doPaintLayer( + layersPaintCtx, layer, list, start, useTimer, rect + ); ctx.restore(); } } @@ -471,12 +441,12 @@ export default class CanvasPainter implements PainterBase { else { // Paint all once ctx.save(); - repaint(null); + this._doPaintLayer( + layersPaintCtx, layer, list, start, useTimer, null + ); ctx.restore(); } - layer.__drawIndex = i; - if (layer.__drawIndex < layer.__endIndex) { finished = false; } @@ -493,29 +463,61 @@ export default class CanvasPainter implements PainterBase { return { finished, - needsRefreshHover + needsRefreshHover: layersPaintCtx.needsRefreshHover, }; } - 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)) { + private _doPaintLayer( + layersPaintCtx: { + needsRefreshHover: boolean; + }, + layer: Layer, + list: Displayable[], + idx: number, + useTimer: boolean, + repaintRect: BoundingRect | NullUndefined, + ): void { + const scope: BrushScope = { + inHover: false, + allClipped: false, + prevEl: null, + viewWidth: this._width, + viewHeight: this._height + }; + const ctx = layer.ctx; + const startTime = useTimer && Date.now(); + + for (; idx < layer.__endIndex; idx++) { + const el = list[idx]; + + if (el.__inHover) { + layersPaintCtx.needsRefreshHover = true; + } + + if (repaintRect != null) { + const paintRect = el.getPaintRect(); + if (paintRect && paintRect.intersect(repaintRect)) { + brush(ctx, el, scope); + el.setPrevPaintRect(paintRect); + } + } + else { brush(ctx, el, scope); - el.setPrevPaintRect(paintRect); + } + + 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; + } } } - else { - brush(ctx, el, scope); - } + brushLoopFinalize(ctx, scope); + + layer.__drawIndex = idx; } /** @@ -954,7 +956,7 @@ export default class CanvasPainter implements PainterBase { const el = displayList[i]; brush(ctx, el, scope); } - brushFinalize(ctx, scope); + brushLoopFinalize(ctx, scope); } return imageLayer.dom; diff --git a/src/canvas/graphic.ts b/src/canvas/graphic.ts index 75a7c144e..abd581fda 100644 --- a/src/canvas/graphic.ts +++ b/src/canvas/graphic.ts @@ -621,7 +621,7 @@ function getStyle(el: Displayable, inHover?: boolean) { export function brushSingle(ctx: CanvasRenderingContext2D, el: Displayable) { const scope = { inHover: false, viewWidth: 0, viewHeight: 0 }; brush(ctx, el, scope); - brushFinalize(ctx, scope); + brushLoopFinalize(ctx, scope); } // Brush different type of elements. @@ -789,10 +789,12 @@ export function brush( } /** - * Must be called after `brush()` iterations. + * 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 brushFinalize( +export function brushLoopFinalize( ctx: CanvasRenderingContext2D, scope: BrushScope ) { @@ -836,7 +838,7 @@ function brushIncremental( displayable.afterBrush && displayable.afterBrush(); innerScope.prevEl = displayable; } - brushFinalize(ctx, innerScope); + brushLoopFinalize(ctx, innerScope); // Render temporary displayables. for (let i = 0, len = temporalDisplayables.length; i < len; i++) { const displayable = temporalDisplayables[i]; @@ -847,7 +849,7 @@ function brushIncremental( displayable.afterBrush && displayable.afterBrush(); innerScope.prevEl = displayable; } - brushFinalize(ctx, innerScope); + brushLoopFinalize(ctx, innerScope); el.clearTemporalDisplayables(); el.notClear = true; From 9f69705e8e73f98f88563fc4d9b5b4eed5e8d33b Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 16 Mar 2026 04:03:41 +0800 Subject: [PATCH 03/14] fix(hoverLayer): Clarify the constraints of hover layer - restrict to only style modifying, and fix the omission of __hoverStyle usage. Previously the hover layer effect is inconsistent and not preferable - when the original layer is required to change by the subsequent user interactions, the final composited effect will change unexpectedly due to the modification of el props. See test case in `echarts/test/hover-layer.html`. --- src/Element.ts | 150 +++++++++++++++++++------- src/canvas/Painter.ts | 63 +++++++---- src/canvas/graphic.ts | 16 ++- src/graphic/Displayable.ts | 64 +++++++---- src/graphic/IncrementalDisplayable.ts | 6 ++ src/graphic/Path.ts | 9 +- src/graphic/Text.ts | 16 +++ 7 files changed, 231 insertions(+), 93 deletions(-) diff --git a/src/Element.ts b/src/Element.ts index 6b1377c50..7fcae0b84 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'; @@ -32,6 +32,7 @@ import { LIGHT_LABEL_COLOR, DARK_LABEL_COLOR } from './config'; import { parse, stringify } from './tool/color'; import { REDRAW_BIT } from './graphic/constants'; import { invert } from './core/matrix'; +import type { DisplayableProps } from './graphic/Displayable'; export interface ElementAnimateConfig { duration?: number @@ -288,7 +289,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 = ( @@ -391,11 +398,44 @@ 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_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. For example, echarts `axisPointer` or "legend hide/show series" may trigger full repaint + * while hover styles are displayed. 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), 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 common cases but is still problematic in some edge cases. + * + * PENDING: + * 1. Currently we simply implement (B), until some concrete scenarios require (A) in future. + * 2. [HOVER_LAYER_CONSTRAINTS_TEXT_CONTENT] + * 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 + * "only style" restriction. This is useful to the scenario "hover an element to show its _textContent". + * But this requires more precise dirty bit handling, since text style changing will create or update sub + * elements (TSpan), which require REDRAW_BIT to trigger displayList updating. That would introduce + * considerable complexity. (See `IN_HOVER_LAYER_KIND_NO_LIMIT`). + * We do not implement it util required. Currently "hover an element to show its _textContent" is not + * supported in hoverLayer - the `_textContent` simply does not appear in that case. + */ + __inHover: InHoverLayerKind __clipPaths?: Path[] @@ -926,11 +966,10 @@ class Element { this.saveCurrentToNormalState(state); } - const useHoverLayer = !!((state && state.hoverLayer) || forceUseHoverLayer); - - if (useHoverLayer) { + const useHoverLayer = shouldUseHoverLayer(this, state, forceUseHoverLayer); + if (useHoverLayer && !this.__inHover) { // Enter hover layer before states update. - this._toggleHoverLayerFlag(true); + this.__inHover = useHoverLayer; } this._applyStateObj( @@ -938,8 +977,8 @@ class Element { state, this._normalState, keepCurrentStates, - !noAnimation && !this.__inHover && animationCfg && animationCfg.duration > 0, - animationCfg + canTransition(this, noAnimation, animationCfg), + animationCfg, ); // Also set text content. @@ -947,10 +986,10 @@ class Element { 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 +1014,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 +1064,10 @@ class Element { } const lastStateObj = stateObjects[len - 1]; - const useHoverLayer = !!((lastStateObj && lastStateObj.hoverLayer) || forceUseHoverLayer); - if (useHoverLayer) { + const useHoverLayer = shouldUseHoverLayer(this, 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 +1080,17 @@ 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 +1101,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 +1215,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 +1488,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 +1724,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 +2111,47 @@ function animateToShallow( } } +function shouldUseHoverLayer( + el: Element, + nextState: ElementState, + forceUseHoverLayer: boolean +): InHoverLayerKind { + return !((nextState && nextState.hoverLayer) || forceUseHoverLayer) + ? IN_HOVER_LAYER_KIND_NO + // PENDING: IN_HOVER_LAYER_KIND_NO_LIMIT is not supported yet. + // See HOVER_LAYER_CONSTRAINTS_TEXT_CONTENT for more details. + // If using haver layer and previously it is not in a hover layer and invisible, + // the changes of element props can be no limit. + // : (!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; +} + +// 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; + + +function canTransition( + el: Element, + noAnimation: boolean, + animationCfg: ElementAnimateConfig +): boolean { + return !noAnimation && !el.__inHover && animationCfg && animationCfg.duration > 0; +} + + export default Element; diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 078cd3695..0fe89fd3e 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -259,15 +259,18 @@ export default class CanvasPainter implements PainterBase { refreshHover() { - this._paintHoverList(this.storage.getDisplayList(false)); + this._paintHoverList(this.storage.getDisplayList(false), true); } - private _paintHoverList(list: Displayable[]) { - let len = list.length; + private _paintHoverList(list: Displayable[], needsRefreshHover: boolean) { let hoverLayer = this._hoverlayer; - hoverLayer && hoverLayer.clear(); + if (hoverLayer && hoverLayer.__used) { + hoverLayer.clear(); + hoverLayer.__used = false; + } - if (!len) { + let len = list.length; + if (!needsRefreshHover || !len) { return; } @@ -286,16 +289,35 @@ export default class CanvasPainter implements PainterBase { if (!hoverLayer) { hoverLayer = this._hoverlayer = this.getLayer(HOVER_LAYER_ZLEVEL); } - 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; + } } } - brushLoopFinalize(ctx, scope); if (ctx) { + brushLoopFinalize(ctx, scope); + hoverLayer.__used = true; ctx.restore(); } } @@ -323,9 +345,7 @@ export default class CanvasPainter implements PainterBase { this._compositeManually(); } - if (needsRefreshHover) { - this._paintHoverList(list); - } + this._paintHoverList(list, needsRefreshHover); if (!finished) { const self = this; @@ -369,8 +389,6 @@ export default class CanvasPainter implements PainterBase { if (layer.__builtin__ && layer !== this._hoverlayer && (layer.__dirty || paintAll) - // Layer with hover elements can't be redrawn. - // && !layer.__hasHoverLayerELement ) { layerList.push(layer); } @@ -491,6 +509,8 @@ export default class CanvasPainter implements PainterBase { const el = list[idx]; if (el.__inHover) { + // NOTE: el is always painted to normal layers regardless of + // whether it will be painted to a hover layer. layersPaintCtx.needsRefreshHover = true; } @@ -634,7 +654,7 @@ export default class CanvasPainter implements PainterBase { } } - // Iterate each buildin layer + // Iterate each built-in layer, including hover layer by default. eachBuiltinLayer(cb: (this: T, layer: Layer, z: number) => void, context?: T) { const zlevelList = this._zlevelList; for (let i = 0; i < zlevelList.length; i++) { @@ -658,18 +678,17 @@ export default class CanvasPainter implements PainterBase { } } - /** - * 获取所有已创建的层 - * @param prevLayer - */ getLayers() { return this._layers; } _updateLayerStatus(list: Displayable[]) { + const hoverLayer = this._hoverlayer; this.eachBuiltinLayer(function (layer, z) { - layer.__dirty = layer.__used = false; + if (layer !== hoverLayer) { + layer.__dirty = layer.__used = false; + } }); function updatePrevLayer(idx: number) { @@ -728,7 +747,7 @@ export default class CanvasPainter implements PainterBase { } if (!layer.__builtin__) { - util.logError('ZLevel ' + zlevel + ' has been used by unkown layer ' + layer.id); + util.logError('ZLevel ' + zlevel + ' has been used by unknown layer ' + layer.id); } if (layer !== prevLayer) { @@ -759,6 +778,9 @@ export default class CanvasPainter implements PainterBase { updatePrevLayer(i); this.eachBuiltinLayer(function (layer, z) { + if (layer === hoverLayer) { + return; + } // Used in last frame but not in this frame. Needs clear if (!layer.__used && layer.getElementCount() > 0) { layer.__dirty = true; @@ -771,9 +793,6 @@ export default class CanvasPainter implements PainterBase { }); } - /** - * 清除hover层外所有内容 - */ clear() { this.eachBuiltinLayer(this._clearLayer); return this; diff --git a/src/canvas/graphic.ts b/src/canvas/graphic.ts index abd581fda..f7a5fe9f6 100644 --- a/src/canvas/graphic.ts +++ b/src/canvas/graphic.ts @@ -434,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; @@ -507,8 +507,8 @@ function bindImageStyle( ) { return bindCommonProps( ctx, - getStyle(el, scope.inHover), - prevEl && getStyle(prevEl, scope.inHover), + el.style, + prevEl && prevEl.style, forceSetAll, scope ); @@ -614,10 +614,6 @@ function flushPathDrawn(ctx: CanvasRenderingContext2D, scope: BrushScope) { } } -function getStyle(el: Displayable, inHover?: boolean) { - return inHover ? (el.__hoverStyle || el.style) : el.style; -} - export function brushSingle(ctx: CanvasRenderingContext2D, el: Displayable) { const scope = { inHover: false, viewWidth: 0, viewHeight: 0 }; brush(ctx, el, scope); @@ -645,6 +641,7 @@ export function brush( // HANDLE CLIPPING const clipPaths = el.__clipPaths; const prevElClipPaths = scope.prevElClipPaths; + const style = el.style; let forceSetTransform = false; let forceSetStyle = false; @@ -712,7 +709,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 @@ -724,7 +721,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) { diff --git a/src/graphic/Displayable.ts b/src/graphic/Displayable.ts index 839156f2a..624ba1bbe 100644 --- a/src/graphic/Displayable.ts +++ b/src/graphic/Displayable.ts @@ -2,7 +2,10 @@ * 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 Path from './Path'; @@ -134,6 +137,7 @@ class Displayable extends Ele */ ignoreCoarsePointer?: boolean + // FIXME: do not use TS any. style: Dictionary protected _normalState: DisplayableState @@ -200,7 +204,7 @@ class Displayable extends Ele viewWidth: number, viewHeight: number, considerClipPath: boolean, - considerAncestors: boolean + considerAncestors: boolean, ) { const m = this.transform; if ( @@ -413,15 +417,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_CONTENT 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 +468,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 +497,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 +533,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]; + } } } } diff --git a/src/graphic/IncrementalDisplayable.ts b/src/graphic/IncrementalDisplayable.ts index c1fd4dbc7..3fa043589 100644 --- a/src/graphic/IncrementalDisplayable.ts +++ b/src/graphic/IncrementalDisplayable.ts @@ -39,6 +39,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 664597652..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', @@ -529,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..6a002fca9 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_CONTENT) + // 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_CONTENT) + // child.__inHover = this.__inHover; } } From 128f7b7eb67f6352a88bc05ff1fd770536654f9d Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 16 Mar 2026 04:07:01 +0800 Subject: [PATCH 04/14] tweak --- src/Element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Element.ts b/src/Element.ts index 7fcae0b84..847420dca 100644 --- a/src/Element.ts +++ b/src/Element.ts @@ -32,7 +32,7 @@ import { LIGHT_LABEL_COLOR, DARK_LABEL_COLOR } from './config'; import { parse, stringify } from './tool/color'; import { REDRAW_BIT } from './graphic/constants'; import { invert } from './core/matrix'; -import type { DisplayableProps } from './graphic/Displayable'; +// import type { DisplayableProps } from './graphic/Displayable'; export interface ElementAnimateConfig { duration?: number From d68b53a072614f4741357ebff2237d3e7e55e654 Mon Sep 17 00:00:00 2001 From: 100pah Date: Wed, 18 Mar 2026 02:15:06 +0800 Subject: [PATCH 05/14] fix: (1) Tweak previous commit. (2) Performance optimize of hoverLayer for large data and progressive rendering - prevent unnecessary repeated hoverLayer rendering. --- src/Element.ts | 70 +++++---- src/PainterBase.ts | 7 +- src/canvas/Layer.ts | 4 +- src/canvas/Painter.ts | 286 ++++++++++++++++++++++--------------- src/graphic/Displayable.ts | 8 ++ src/svg-legacy/Painter.ts | 1 - src/svg/Painter.ts | 1 - src/zrender.ts | 61 +++++--- 8 files changed, 267 insertions(+), 171 deletions(-) diff --git a/src/Element.ts b/src/Element.ts index 847420dca..b15063518 100644 --- a/src/Element.ts +++ b/src/Element.ts @@ -32,7 +32,7 @@ import { LIGHT_LABEL_COLOR, DARK_LABEL_COLOR } from './config'; import { parse, stringify } from './tool/color'; import { REDRAW_BIT } from './graphic/constants'; import { invert } from './core/matrix'; -// import type { DisplayableProps } from './graphic/Displayable'; +import { DisplayableProps } from './graphic/Displayable'; export interface ElementAnimateConfig { duration?: number @@ -401,19 +401,22 @@ class Element { * Whether this element has been moved to the hover layer. * If so, dirty will only trigger the zrender refresh hover layer. * + * 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. For example, echarts `axisPointer` or "legend hide/show series" may trigger full repaint - * while hover styles are displayed. If the element props have been modified due to hover state switching, - * the final effect will differ unexpectedly after repainting. + * 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), and if it is rendered on the normal layer - * differently, the final composition is changed unexpectedly. + * 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 @@ -421,19 +424,22 @@ class Element { * (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 common cases but is still problematic in some edge cases. + * 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_CONTENT] - * 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 - * "only style" restriction. This is useful to the scenario "hover an element to show its _textContent". - * But this requires more precise dirty bit handling, since text style changing will create or update sub - * elements (TSpan), which require REDRAW_BIT to trigger displayList updating. That would introduce - * considerable complexity. (See `IN_HOVER_LAYER_KIND_NO_LIMIT`). - * We do not implement it util required. Currently "hover an element to show its _textContent" is not - * supported in hoverLayer - the `_textContent` simply does not appear in that case. + * 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 @@ -966,7 +972,8 @@ class Element { this.saveCurrentToNormalState(state); } - const useHoverLayer = shouldUseHoverLayer(this, state, forceUseHoverLayer); + const textContent = this._textContent; + const useHoverLayer = shouldUseHoverLayer(this, textContent, state, forceUseHoverLayer); if (useHoverLayer && !this.__inHover) { // Enter hover layer before states update. this.__inHover = useHoverLayer; @@ -982,7 +989,6 @@ class Element { ); // Also set text content. - const textContent = this._textContent; const textGuide = this._textGuide; if (textContent) { // Force textContent use hover layer if self is using it. @@ -1064,7 +1070,8 @@ class Element { } const lastStateObj = stateObjects[len - 1]; - const useHoverLayer = shouldUseHoverLayer(this, lastStateObj, forceUseHoverLayer); + const textContent = this._textContent; + const useHoverLayer = shouldUseHoverLayer(this, textContent, lastStateObj, forceUseHoverLayer); if (useHoverLayer && !this.__inHover) { // Enter hover layer before states update. this.__inHover = useHoverLayer; @@ -1084,7 +1091,6 @@ class Element { animationCfg, ); - const textContent = this._textContent; const textGuide = this._textGuide; if (textContent) { textContent.useStates(states, noAnimation, !!useHoverLayer); @@ -2113,15 +2119,19 @@ function animateToShallow( function shouldUseHoverLayer( el: Element, + textContent: Element, nextState: ElementState, forceUseHoverLayer: boolean ): InHoverLayerKind { - return !((nextState && nextState.hoverLayer) || forceUseHoverLayer) + return ( + !((nextState && nextState.hoverLayer) || forceUseHoverLayer) + // PENDING: See HOVER_LAYER_CONSTRAINTS_TEXT for the reasons. + || isTextRelatedEl(el) + || (textContent && isTextRelatedEl(textContent)) + ) ? IN_HOVER_LAYER_KIND_NO - // PENDING: IN_HOVER_LAYER_KIND_NO_LIMIT is not supported yet. - // See HOVER_LAYER_CONSTRAINTS_TEXT_CONTENT for more details. - // If using haver layer and previously it is not in a hover layer and invisible, - // the changes of element props can be no limit. + // 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), @@ -2129,20 +2139,24 @@ function shouldUseHoverLayer( : IN_HOVER_LAYER_KIND_ONLY_STYLE_CHANGE; } +function isTextRelatedEl(el: Element): boolean { + return el.type === 'text' || el.type === 'tspan'; +} + // 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; + | 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; +// export const IN_HOVER_LAYER_KIND_NO_LIMIT = 2; function canTransition( 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/canvas/Layer.ts b/src/canvas/Layer.ts index c887d884b..8d260fe1f 100644 --- a/src/canvas/Layer.ts +++ b/src/canvas/Layer.ts @@ -94,9 +94,9 @@ export default class Layer extends Eventful { __used = false - __drawIndex = 0 + __drawIndex = 0 // The next displayList index to be drawn. __startIndex = 0 - __endIndex = 0 + __endIndex = 0 // The max displayList index owned by this layer + 1. // indices in the previous frame __prevStartIndex: number = null diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 0fe89fd3e..7b907ecb8 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -13,7 +13,7 @@ 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'; + const HOVER_LAYER_ZLEVEL = 1e5; const CANVAS_ZLEVEL = 314159; @@ -21,6 +21,19 @@ 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) { if (!layer) { @@ -70,6 +83,19 @@ interface CanvasPainterOption { 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; +} + export default class CanvasPainter implements PainterBase { type = 'canvas' @@ -104,6 +130,16 @@ export default class CanvasPainter implements PainterBase { 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 private _backgroundColor: string | GradientObject | ImagePatternObject @@ -119,25 +155,15 @@ export default class CanvasPainter implements PainterBase { 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 = ''; } @@ -226,21 +252,31 @@ export default class CanvasPainter implements PainterBase { } } - /** - * 刷新 - * @param paintAll 强制绘制所有displayable - */ - refresh(paintAll?: boolean) { - const list = this.storage.getDisplayList(true); - const prevList = this._prevDisplayList; + refresh(opt?: CanvasPainterRefreshOpt) { + opt = opt || {}; + 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); + + this._redrawId = Math.random(); + const prevList = this._prevDisplayList; + this._paintList(list, prevList, opt.paintAll, this._redrawId); - // Paint custum layers + // Paint custom layers + const zlevelList = this._zlevelList; for (let i = 0; i < zlevelList.length; i++) { const z = zlevelList[i]; const layer = this._layers[z]; @@ -257,23 +293,28 @@ export default class CanvasPainter implements PainterBase { return this; } + private _paintHoverList(list: Displayable[]): void { + let hoverLayer = this._hoverlayer; + const hoverLayerDirty = this._hoverLayerDirty; + // Always clear dirty flag before return. + this._hoverLayerDirty = HOVER_LAYER_DIRTY_NO; - refreshHover() { - this._paintHoverList(this.storage.getDisplayList(false), true); - } + if (hoverLayerDirty === HOVER_LAYER_DIRTY_NO) { + return; + } - private _paintHoverList(list: Displayable[], needsRefreshHover: boolean) { - let hoverLayer = this._hoverlayer; - if (hoverLayer && hoverLayer.__used) { - hoverLayer.clear(); - hoverLayer.__used = false; + if (!hoverLayer && hoverLayerDirty === HOVER_LAYER_DIRTY_REPAINT) { + hoverLayer = this._hoverlayer = this.getLayer(HOVER_LAYER_ZLEVEL); } - let len = list.length; - if (!needsRefreshHover || !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, @@ -281,51 +322,52 @@ export default class CanvasPainter implements PainterBase { }; 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(); - } - // `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 (!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); - hoverLayer.__used = true; ctx.restore(); } } + /** + * @deprecated + */ getHoverLayer() { return this.getLayer(HOVER_LAYER_ZLEVEL); } + /** + * @deprecated + */ paintOne(ctx: CanvasRenderingContext2D, el: Displayable) { brushSingle(ctx, el); } @@ -335,18 +377,12 @@ export default class CanvasPainter implements PainterBase { return; } - paintAll = paintAll || false; - - this._updateLayerStatus(list); - - const {finished, needsRefreshHover} = this._doPaintList(list, prevList, paintAll); + const finished = this._doPaintList(list, prevList, paintAll); if (this._needsManuallyCompositing) { this._compositeManually(); } - this._paintHoverList(list, needsRefreshHover); - if (!finished) { const self = this; requestAnimationFrame(function () { @@ -354,9 +390,17 @@ export default class CanvasPainter implements PainterBase { }); } else { - this.eachLayer(layer => { - layer.afterBrush && layer.afterBrush(); + const hoverLayer = this._hoverlayer; + this.eachLayer(function (layer) { + if (layer !== hoverLayer) { + layer.afterBrush && layer.afterBrush(); + } }); + // Hover layer may be dirty during progressive rendering unfinished without + // affecting progressive rendering. Therefore we do NOT paint hover layer + // per frame following _doPaintList, instead, we simply repaint it once after + // finished. + this._paintHoverList(list); } } @@ -377,10 +421,8 @@ export default class CanvasPainter implements PainterBase { list: Displayable[], prevList: Displayable[], paintAll?: boolean - ): { - finished: boolean - needsRefreshHover: boolean - } { + // Return: finished + ): boolean { const layerList = []; const useDirtyRect = this._opts.useDirtyRect; for (let zi = 0; zi < this._zlevelList.length; zi++) { @@ -395,9 +437,6 @@ export default class CanvasPainter implements PainterBase { } let finished = true; - const layersPaintCtx = { - needsRefreshHover: false - }; for (let k = 0; k < layerList.length; k++) { const layer = layerList[k]; @@ -414,13 +453,16 @@ export default class CanvasPainter implements PainterBase { ? this._backgroundColor : null; // All elements in this layer are removed. + let layerCleared; if (layer.__startIndex === layer.__endIndex) { layer.clear(false, clearColor, repaintRects); + layerCleared = true; } else if (start === layer.__startIndex) { const firstEl = list[start]; - if (!firstEl.incremental || !(firstEl as IncrementalDisplayable).notClear || paintAll) { + if (!firstEl.incremental || !firstEl.notClear || paintAll) { layer.clear(false, clearColor, repaintRects); + layerCleared = true; } } if (start === -1) { @@ -428,6 +470,10 @@ export default class CanvasPainter implements PainterBase { start = layer.__startIndex; } + if (layerCleared && this._hoverLayerDirty === HOVER_LAYER_DIRTY_NO) { + this._hoverLayerDirty = HOVER_LAYER_DIRTY_REPAINT_IF_EXISTING; + } + if (repaintRects) { if (repaintRects.length === 0) { // Nothing to repaint, mark as finished @@ -450,7 +496,7 @@ export default class CanvasPainter implements PainterBase { ctx.clip(); this._doPaintLayer( - layersPaintCtx, layer, list, start, useTimer, rect + layer, list, start, useTimer, rect ); ctx.restore(); } @@ -460,7 +506,7 @@ export default class CanvasPainter implements PainterBase { // Paint all once ctx.save(); this._doPaintLayer( - layersPaintCtx, layer, list, start, useTimer, null + layer, list, start, useTimer, null ); ctx.restore(); } @@ -479,16 +525,10 @@ export default class CanvasPainter implements PainterBase { }); } - return { - finished, - needsRefreshHover: layersPaintCtx.needsRefreshHover, - }; + return finished; } private _doPaintLayer( - layersPaintCtx: { - needsRefreshHover: boolean; - }, layer: Layer, list: Displayable[], idx: number, @@ -505,13 +545,21 @@ export default class CanvasPainter implements PainterBase { const ctx = layer.ctx; const startTime = useTimer && Date.now(); - for (; idx < layer.__endIndex; idx++) { + const layerEndIdx = layer.__endIndex; + + for (; idx < layerEndIdx; idx++) { const el = list[idx]; if (el.__inHover) { - // NOTE: el is always painted to normal layers regardless of - // whether it will be painted to a hover layer. - layersPaintCtx.needsRefreshHover = true; + // 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 (repaintRect != null) { @@ -541,11 +589,10 @@ export default class CanvasPainter implements PainterBase { } /** - * 获取 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) { + // TODO: Need refactor - relaying on _needsManuallyCompositing is bug-prone. if (this._singleCanvas && !this._needsManuallyCompositing) { zlevel = CANVAS_ZLEVEL; } @@ -682,13 +729,10 @@ export default class CanvasPainter implements PainterBase { return this._layers; } - _updateLayerStatus(list: Displayable[]) { - const hoverLayer = this._hoverlayer; + private _updateLayerStatus(list: Displayable[]): void { - this.eachBuiltinLayer(function (layer, z) { - if (layer !== hoverLayer) { - layer.__dirty = layer.__used = false; - } + this.eachBuiltinLayer(function (layer) { + layer.__dirty = layer.__used = false; }); function updatePrevLayer(idx: number) { @@ -718,6 +762,7 @@ export default class CanvasPainter implements PainterBase { for (i = 0; i < list.length; i++) { const el = list[i]; const zlevel = el.zlevel; + let layer; if (prevZlevel !== zlevel) { @@ -734,6 +779,12 @@ export default class CanvasPainter implements PainterBase { // ----------------------zlevel + INCREMENTAL_INC------------------------ // (Element drawn before incremental element) // --------------------------------zlevel-------------------------------- + // These cases are covered per incremental layer: + // (A) An single incremental element (`IncrementalDisplayable`-ish) with + // `notClear` to control the progressive drawing. + // (B) Multiple consecutive incremental elements, progressively drawing + // per frame in `_paintList`. This is not an optimal way due to the + // more cost in updating and sorting, but can carry varying styles. if (el.incremental) { layer = this.getLayer(zlevel + INCREMENTAL_INC, this._needsManuallyCompositing); layer.incremental = true; @@ -751,21 +802,30 @@ export default class CanvasPainter implements PainterBase { } if (layer !== prevLayer) { + // Then this is the first element in this layer. layer.__used = true; if (layer.__startIndex !== i) { layer.__dirty = true; + // PENDING: If displayList indices of incremental elements are changed due + // to the previous but irrelevant elements, no need to restart the drawing. + // That might degrade the case "multiple consecutive incremental elements". } layer.__startIndex = i; if (!layer.incremental) { + // normal layers always redraw from the beginning. layer.__drawIndex = i; } else { // Mark layer draw index needs to update. + // __drawIndex will be set later as the first dirty element in this layer, + // rather than the first element in this layer. layer.__drawIndex = -1; } + 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) { @@ -778,30 +838,28 @@ export default class CanvasPainter implements PainterBase { updatePrevLayer(i); this.eachBuiltinLayer(function (layer, z) { - if (layer === hoverLayer) { + if (layer === this._hoverlayer) { return; } // 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; + layer.__startIndex = layer.__drawIndex = layer.__endIndex = 0; } // For incremental layer. In case start index changed and no elements are dirty. if (layer.__dirty && layer.__drawIndex < 0) { layer.__drawIndex = layer.__startIndex; } - }); + }, this); } clear() { - this.eachBuiltinLayer(this._clearLayer); + this.eachBuiltinLayer(function (layer) { + layer.clear(); + }); return this; } - _clearLayer(layer: Layer) { - layer.clear(); - } - setBackgroundColor(backgroundColor: string | GradientObject | ImagePatternObject) { this._backgroundColor = backgroundColor; @@ -895,7 +953,7 @@ export default class CanvasPainter implements PainterBase { } } - this.refresh(true); + this.refresh({paintAll: true}); } this._width = width; diff --git a/src/graphic/Displayable.ts b/src/graphic/Displayable.ts index 624ba1bbe..69bf5ae45 100644 --- a/src/graphic/Displayable.ts +++ b/src/graphic/Displayable.ts @@ -127,11 +127,19 @@ class Displayable extends Ele * If hover area is bounding rect */ rectHover: boolean + /** * For increamental rendering */ incremental: boolean + /** + * For an incremental element, it can prevent its incremental layer + * from clearing. Only the first incremental element on a layer can + * use `notClear`. + */ + notClear?: boolean + /** * Never increase to target size */ 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..984914d2c 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 = { @@ -141,7 +142,7 @@ class ZRender { this.animation = new Animation({ stage: { - update: ssrMode ? null : () => this._flush(true) + update: ssrMode ? null : () => this._flush(false) } }); @@ -220,12 +221,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 +245,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; } /** @@ -258,21 +274,22 @@ 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) { + const needsRefresh = this._needsRefresh; + const needsRefreshHover = this._needsRefreshHover; + if (needsRefresh || needsRefreshHover) { triggerRendered = true; - this.refreshImmediately(fromInside); - } - - if (this._needsRefreshHover) { - triggerRendered = true; - this.refreshHoverImmediately(); + this._refresh({ + animUpdate: animationUpdate, + refresh: needsRefresh, + refreshHover: needsRefreshHover, + }); } const end = getTime(); @@ -319,16 +336,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 + }); } /** From 77a7f00dfe5ef9dd0b6cfafdd216adf3c52dd56b Mon Sep 17 00:00:00 2001 From: 100pah Date: Wed, 18 Mar 2026 02:18:55 +0800 Subject: [PATCH 06/14] Tweak comments. --- src/graphic/Displayable.ts | 2 +- src/graphic/Text.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/graphic/Displayable.ts b/src/graphic/Displayable.ts index 69bf5ae45..0e2e0cd3b 100644 --- a/src/graphic/Displayable.ts +++ b/src/graphic/Displayable.ts @@ -434,7 +434,7 @@ class Displayable extends Ele this.__hoverStyle = obj; // this.dirtyStyle(); // PENDING: - // Since HOVER_LAYER_CONSTRAINTS_TEXT_CONTENT is not supported, no need to call + // 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 diff --git a/src/graphic/Text.ts b/src/graphic/Text.ts index 6a002fca9..aaa1f31a3 100644 --- a/src/graphic/Text.ts +++ b/src/graphic/Text.ts @@ -319,7 +319,7 @@ class ZRText extends Displayable implements GroupLike { // Update children if (this.styleChanged()) { - // PENDING: (See HOVER_LAYER_CONSTRAINTS_TEXT_CONTENT) + // PENDING: (See HOVER_LAYER_CONSTRAINTS_TEXT) // let originalStyle; // const hoverStyle = this.__hoverStyle; // if (hoverStyle) { @@ -344,7 +344,7 @@ class ZRText extends Displayable implements GroupLike { child.cursor = this.cursor; child.invisible = this.invisible; - // PENDING: (See HOVER_LAYER_CONSTRAINTS_TEXT_CONTENT) + // PENDING: (See HOVER_LAYER_CONSTRAINTS_TEXT) // child.__inHover = this.__inHover; } } From 8453d194f8568c44d84c0cc7050dda1f890e9bb7 Mon Sep 17 00:00:00 2001 From: 100pah Date: Tue, 24 Mar 2026 02:17:14 +0800 Subject: [PATCH 07/14] fix(incremental/progressive): In practice, previously incremental layer was likely to dirty and discard the rendered content, and restarted all drawing per frame, which probably caused the cumulative draw calls to significantly block the rendering in large data. This commit introduces more precise layer content reuse strategies: (1) Based on the recorded first element rather than the first displayList index of the last pass (which is likely to be changed by preceding irrelevant elements on underlying layers); (2) Support multiple runs of consecutive incremental elements to render into one singe layer without wrongly dirty each other - introduce data structure `LayerDrawCursor` per run of elements to render separately, and change `Displayable['incremental']` from boolean to number to designate different runs. The scenarios can be: echarts large bar and large candlestick series exist at the same time. (3) Previously hover layer was likely to be repeatedly rendered; fix that. --- src/Element.ts | 1 - src/Storage.ts | 2 + src/canvas/Layer.ts | 80 ++- src/canvas/Painter.ts | 971 ++++++++++++++++---------- src/canvas/graphic.ts | 15 +- src/canvas/helper.ts | 2 +- src/core/platform.ts | 9 +- src/core/types.ts | 31 + src/core/util.ts | 5 + src/graphic/Displayable.ts | 57 +- src/graphic/Group.ts | 4 + src/graphic/IncrementalDisplayable.ts | 3 +- src/zrender.ts | 5 +- 13 files changed, 785 insertions(+), 400 deletions(-) diff --git a/src/Element.ts b/src/Element.ts index b15063518..5e38d03f9 100644 --- a/src/Element.ts +++ b/src/Element.ts @@ -32,7 +32,6 @@ import { LIGHT_LABEL_COLOR, DARK_LABEL_COLOR } from './config'; import { parse, stringify } from './tool/color'; import { REDRAW_BIT } from './graphic/constants'; import { invert } from './core/matrix'; -import { DisplayableProps } from './graphic/Displayable'; export interface ElementAnimateConfig { duration?: number 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 8d260fe1f..7a29e1d48 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,29 @@ 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. + drawIdx: 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 +115,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 // The next displayList index to be drawn. - __startIndex = 0 - __endIndex = 0 // The max displayList index owned by this layer + 1. + // `__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 +177,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 +287,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 +333,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 7b907ecb8..454aa1fb2 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -1,10 +1,13 @@ 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 { NullUndefined, 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'; @@ -13,14 +16,13 @@ import { PainterBase } from '../PainterBase'; import BoundingRect from '../core/BoundingRect'; import { REDRAW_BIT } from '../graphic/constants'; import { getSize } from './helper'; +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 @@ -35,6 +37,7 @@ const HOVER_LAYER_DIRTY_REPAINT_IF_EXISTING = 1; const HOVER_LAYER_DIRTY_REPAINT = 2; + function isLayerValid(layer: Layer) { if (!layer) { return false; @@ -79,7 +82,7 @@ function createRoot(width: number, height: number) { interface CanvasPainterOption { devicePixelRatio?: number width?: number | string // Can be 10 / 10px / auto - height?: number | string, + height?: number | string useDirtyRect?: boolean } @@ -96,6 +99,99 @@ export type CanvasPainterRefreshOpt = { 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.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 LAYER_STACKING + // CAVEAT: + // Do not iterate `layers`; iterate `layerStack` instead. + layers: Layer[][]; + + hoverlayer?: Layer; +} + export default class CanvasPainter implements PainterBase { type = 'canvas' @@ -106,16 +202,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 /** @@ -128,8 +222,6 @@ 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. @@ -149,6 +241,11 @@ export default class CanvasPainter implements PainterBase { this.type = 'canvas'; + const internal: CanvasPainterInternal = this._i = { + layerStack: [], + layers: [], + }; + // In node environment using node-canvas const singleCanvas = !root.nodeName // In node ? || root.nodeName.toUpperCase() === 'CANVAS'; @@ -168,17 +265,10 @@ export default class CanvasPainter implements PainterBase { 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); @@ -217,16 +307,15 @@ export default class CanvasPainter implements PainterBase { mainLayer.initContext(); // FIXME Use canvas width and height // mainLayer.resize(width, height); - layers[CANVAS_ZLEVEL] = mainLayer; + ensureLayerListInZLevel(internal, CANVAS_ZLEVEL)[0] = mainLayer; mainLayer.zlevel = CANVAS_ZLEVEL; // Not use common zlevel. - zlevelList.push(CANVAS_ZLEVEL); + internal.layerStack.push({zl: CANVAS_ZLEVEL, zl2: 0}); this._domRoot = root; } } - getType() { return 'canvas'; } @@ -252,8 +341,14 @@ export default class CanvasPainter implements PainterBase { } } - refresh(opt?: CanvasPainterRefreshOpt) { - opt = opt || {}; + 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); @@ -269,22 +364,19 @@ export default class CanvasPainter implements PainterBase { } const list = this.storage.getDisplayList(true); - this._updateLayerStatus(list); + this._updateLayerStatus(list, opt.paintAll); this._redrawId = Math.random(); const prevList = this._prevDisplayList; - this._paintList(list, prevList, opt.paintAll, this._redrawId); + this._paintList(list, prevList, this._redrawId); // Paint custom layers - const zlevelList = this._zlevelList; - 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); + 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(); @@ -294,7 +386,7 @@ export default class CanvasPainter implements PainterBase { } private _paintHoverList(list: Displayable[]): void { - let hoverLayer = this._hoverlayer; + let hoverLayer = this._i.hoverlayer; const hoverLayerDirty = this._hoverLayerDirty; // Always clear dirty flag before return. this._hoverLayerDirty = HOVER_LAYER_DIRTY_NO; @@ -304,7 +396,7 @@ export default class CanvasPainter implements PainterBase { } if (!hoverLayer && hoverLayerDirty === HOVER_LAYER_DIRTY_REPAINT) { - hoverLayer = this._hoverlayer = this.getLayer(HOVER_LAYER_ZLEVEL); + hoverLayer = this._i.hoverlayer = this._ensureLayer(HOVER_LAYER_ZLEVEL); } if (!hoverLayer) { @@ -318,7 +410,8 @@ export default class CanvasPainter implements PainterBase { const scope: BrushScope = { inHover: true, viewWidth: this._width, - viewHeight: this._height + viewHeight: this._height, + beforeBrushParam: {}, }; let ctx; @@ -362,7 +455,7 @@ export default class CanvasPainter implements PainterBase { * @deprecated */ getHoverLayer() { - return this.getLayer(HOVER_LAYER_ZLEVEL); + return this._ensureLayer(HOVER_LAYER_ZLEVEL); } /** @@ -372,12 +465,12 @@ export default class CanvasPainter implements PainterBase { 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; } - const finished = this._doPaintList(list, prevList, paintAll); + const finished = this._doPaintList(list, prevList); if (this._needsManuallyCompositing) { this._compositeManually(); @@ -386,139 +479,78 @@ export default class CanvasPainter implements PainterBase { if (!finished) { const self = this; requestAnimationFrame(function () { - self._paintList(list, prevList, paintAll, redrawId); + self._paintList(list, prevList, redrawId); }); } else { - const hoverLayer = this._hoverlayer; - this.eachLayer(function (layer) { - if (layer !== hoverLayer) { - layer.afterBrush && layer.afterBrush(); - } - }); - // Hover layer may be dirty during progressive rendering unfinished without - // affecting progressive rendering. Therefore we do NOT paint hover layer - // per frame following _doPaintList, instead, we simply repaint it once after - // finished. + 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 - // Return: finished + // Return: `finished` ): 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) - ) { - layerList.push(layer); - } - } - + const painter = this; let finished = true; - 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) { + needDraw = true; + } + }); - let start = paintAll ? layer.__startIndex : layer.__drawIndex; + if (!needDraw && !layer.__dirty) { + return; + } - const useTimer = !paintAll && layer.incremental && !!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. - let layerCleared; - 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); - layerCleared = true; - } - else if (start === layer.__startIndex) { - const firstEl = list[start]; - if (!firstEl.incremental || !firstEl.notClear || paintAll) { - layer.clear(false, clearColor, repaintRects); - layerCleared = true; - } - } - if (start === -1) { - console.error('For some unknown reason. drawIndex is -1'); - start = layer.__startIndex; - } - - if (layerCleared && this._hoverLayerDirty === HOVER_LAYER_DIRTY_NO) { - this._hoverLayerDirty = HOVER_LAYER_DIRTY_REPAINT_IF_EXISTING; } - if (repaintRects) { - if (repaintRects.length === 0) { - // Nothing to repaint, mark as finished - layer.__drawIndex = layer.__endIndex; - } - else { - 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._doPaintLayer( - layer, list, start, useTimer, rect - ); - ctx.restore(); - } - } - } - else { - // Paint all once - ctx.save(); - this._doPaintLayer( - layer, list, start, useTimer, null + eachCursorInLayer(layer, function (cursor) { + const cursorFinished = painter._paintPerCursor( + layer, cursor, list, repaintRects, contentRetained ); - ctx.restore(); - } - - if (layer.__drawIndex < layer.__endIndex) { - finished = false; - } - } + finished = finished && cursorFinished; + }); + }, EACH_LAYER_BUILTIN_NOT_HOVER); if (env.wxa) { // Flush for weixin application - util.each(this._layers, function (layer) { + eachLayer(this._i, function (layer) { if (layer && layer.ctx && (layer.ctx as WXCanvasRenderingContext).draw) { (layer.ctx as WXCanvasRenderingContext).draw(); } @@ -528,26 +560,72 @@ export default class CanvasPainter implements PainterBase { return finished; } - private _doPaintLayer( + 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 { + 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[], - idx: number, - useTimer: boolean, repaintRect: BoundingRect | NullUndefined, + contentRetained: boolean, ): void { const scope: BrushScope = { inHover: false, allClipped: false, prevEl: null, viewWidth: this._width, - viewHeight: this._height + viewHeight: this._height, + beforeBrushParam: {contentRetained} }; const ctx = layer.ctx; - const startTime = useTimer && Date.now(); - - const layerEndIdx = layer.__endIndex; + const useTimer = isIncrementalLayer(layer); + const startTime = useTimer && platformApi.getTime(); - for (; idx < layerEndIdx; idx++) { + // NOTICE: This loop is performance-sensitive, especially for large data. + let idx = layerCursor.drawIdx; + for (; idx < layerCursor.endIdx; idx++) { const el = list[idx]; if (el.__inHover) { @@ -574,48 +652,75 @@ export default class CanvasPainter implements PainterBase { } if (useTimer) { - // Date.now can be executed in 13,025,305 ops/second. - const dTime = Date.now() - startTime; + 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); - layer.__drawIndex = idx; + layerCursor.drawIdx = idx; } /** + * 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: number, virtual?: boolean) { - // TODO: Need refactor - relaying on _needsManuallyCompositing is bug-prone. - if (this._singleCanvas && !this._needsManuallyCompositing) { + getLayer(zlevel: ZLevel, virtual?: boolean) { + return this._ensureLayer(zlevel, 0, virtual); + } + + /** + * Obtain a layer; create one if not exist. + */ + 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 = new Layer('zr_' + zlevel + '.' + zlevel2, this, this.dpr); layer.zlevel = zlevel; + layer.zlevel2 = zlevel2; layer.__builtin__ = true; + resetLayerDrawCursors(layer); 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); // Context is created after dom inserted to document // Or excanvas will get 0px clientWidth and clientHeight @@ -625,22 +730,28 @@ 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); + } - const layersMap = this._layers; - const zlevelList = this._zlevelList; - const len = zlevelList.length; + private _insertLayer(layer: Layer, zlevel: ZLevel, zlevel2: ZLevel2) { + 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'); @@ -648,20 +759,20 @@ 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) @@ -692,185 +803,336 @@ 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 built-in layer, including hover layer by default. - 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); } + /** + * @deprecated + * NOTICE: Only for debugging or testing. + */ getLayers() { - return this._layers; - } - - private _updateLayerStatus(list: Displayable[]): void { - - this.eachBuiltinLayer(function (layer) { - layer.__dirty = layer.__used = false; + // For backward compatibility + const layers: Record = {}; + eachLayer(this._i, function (layer, zlevel, zlevel2) { + layers[`zlevel:${zlevel},zlevel2:${zlevel2}`] = layer; }); + return layers; + } - function updatePrevLayer(idx: number) { - if (prevLayer) { - if (prevLayer.__endIndex !== idx) { - prevLayer.__dirty = true; - } - prevLayer.__endIndex = idx; - } - } + /** + * Two use patterns are covered per incremental layer: + * [INCREMENTAL_CASE_SINGLE_ELEMENT] + * An single incremental element with a customized `buildPath`, using `Displayable['notClear']` + * to retain the rendered content. + * [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). + * + * [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_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. + * + * [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 LAYER_SAMPLE_CASE_3 for more details. + * + * Consider sample cases below to check the implementation: + * - [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 ]] + * - [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 ]] + * - [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} ]] + * + * [LAYER_DIRTY_RULES]: + * Only dirty layer will be cleared and repaint later. `layer.__dirty` is set by: + * - REDRAW_BIT of every element. [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`. + * [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. + * [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 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; + }); + }, EACH_LAYER_BUILTIN_NOT_HOVER); - for (i = 0; i < list.length; i++) { - const el = list[i]; - const zlevel = el.zlevel; + let prevZLevel: ZLevel; + let currLayer: Layer = null; + let currCursor: LayerDrawCursor = null; + let aboveIncrementalInCurrZLevel = false; - let layer; + // 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; + 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-------------------------------- - // These cases are covered per incremental layer: - // (A) An single incremental element (`IncrementalDisplayable`-ish) with - // `notClear` to control the progressive drawing. - // (B) Multiple consecutive incremental elements, progressively drawing - // per frame in `_paintList`. This is not an optimal way due to the - // more cost in updating and sorting, but can carry varying styles. - 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_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 unknown layer ' + layer.id); + 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; + } + } + // 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 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 `currCursor` is not changed, keep using it. This is the most common case, + // so we retain this past path for performance. + + // See 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. + + if ((el.__dirty & REDRAW_BIT) + && !el.__inHover // Ignore dirty elements in hover layer. + && (!elIncremental + || (!el.notClear && idx < currCursor.drawIdx) + ) // See LAYER_DIRTY_BY_REDRAW_BIT + ) { + currLayer.__dirty = true; } + } // The end of displayList travel. + + eachLayer(painter._i, function (layer) { + const cursorStack = layer.__cursorStack; + const cursors = layer.__cursors; + + for (let i = cursorStack.length - 1; i >= 0; i--) { + const cursor = cursors.get(cursorStack[i]); - if (layer !== prevLayer) { - // Then this is the first element in this layer. - layer.__used = true; - if (layer.__startIndex !== i) { + if (!cursor.used) { // `cursor` is used in the last pass but not in this pass - need clear. layer.__dirty = true; - // PENDING: If displayList indices of incremental elements are changed due - // to the previous but irrelevant elements, no need to restart the drawing. - // That might degrade the case "multiple consecutive incremental elements". + cursors.removeKey(cursorStack[i]); + cursorStack.splice(i, 1); } - layer.__startIndex = i; - if (!layer.incremental) { - // normal layers always redraw from the beginning. - layer.__drawIndex = i; - } - else { - // Mark layer draw index needs to update. - // __drawIndex will be set later as the first dirty element in this layer, - // rather than the first element in this layer. - layer.__drawIndex = -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 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; } - - 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 (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; } } - } - - updatePrevLayer(i); - this.eachBuiltinLayer(function (layer, z) { - if (layer === this._hoverlayer) { - return; - } - // Used in last frame but not in this frame. Needs clear - if (!layer.__used && layer.getElementCount() > 0) { - layer.__dirty = true; - layer.__startIndex = layer.__drawIndex = layer.__endIndex = 0; - } - // For incremental layer. In case start index changed and no elements are dirty. - if (layer.__dirty && layer.__drawIndex < 0) { - layer.__drawIndex = layer.__startIndex; - } - }, this); + }, EACH_LAYER_BUILTIN_NOT_HOVER); } clear() { - this.eachBuiltinLayer(function (layer) { + eachLayer(this._i, function (layer) { layer.clear(); - }); + resetLayerDrawCursors(layer); + }, EACH_LAYER_BUILTIN); return this; } 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; @@ -881,32 +1143,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); + } + } + } } /** @@ -924,7 +1189,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; @@ -947,11 +1212,9 @@ 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({paintAll: true}); } @@ -964,27 +1227,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; } /** @@ -996,7 +1255,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); @@ -1010,7 +1269,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); } @@ -1026,7 +1285,8 @@ export default class CanvasPainter implements PainterBase { 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++) { @@ -1038,17 +1298,12 @@ export default class CanvasPainter implements PainterBase { 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 f7a5fe9f6..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'; @@ -578,6 +578,8 @@ export type BrushScope = { batchStroke?: boolean lastDrawType?: number + + beforeBrushParam: BeforeBrushParam } // If path can be batched @@ -615,7 +617,7 @@ function flushPathDrawn(ctx: CanvasRenderingContext2D, scope: BrushScope) { } export function brushSingle(ctx: CanvasRenderingContext2D, el: Displayable) { - const scope = { inHover: false, viewWidth: 0, viewHeight: 0 }; + const scope = { inHover: false, viewWidth: 0, viewHeight: 0, beforeBrushParam: {} }; brush(ctx, el, scope); brushLoopFinalize(ctx, scope); } @@ -698,7 +700,7 @@ export function brush( } // START BRUSH - el.beforeBrush && el.beforeBrush(); + el.beforeBrush && el.beforeBrush(scope.beforeBrushParam); el.innerBeforeBrush(); const prevEl = scope.prevEl; @@ -820,14 +822,15 @@ 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); displayable.innerAfterBrush(); @@ -838,7 +841,7 @@ function brushIncremental( // 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); displayable.innerAfterBrush(); 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/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 45df7f9ec..3ca5988e0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -105,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..d927d680c 100644 --- a/src/core/util.ts +++ b/src/core/util.ts @@ -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++; } diff --git a/src/graphic/Displayable.ts b/src/graphic/Displayable.ts index 0e2e0cd3b..fbf1d3e45 100644 --- a/src/graphic/Displayable.ts +++ b/src/graphic/Displayable.ts @@ -7,7 +7,7 @@ import Element, { 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'; @@ -67,7 +67,7 @@ export interface DisplayableProps extends ElementProps { progressive?: boolean - incremental?: boolean + incremental?: Displayable['incremental'] ignoreCoarsePointer?: boolean @@ -84,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 @@ -128,17 +134,46 @@ class Displayable extends Ele */ rectHover: boolean - /** - * For increamental rendering - */ - incremental: boolean + incremental: IncrementalIdCompat /** - * For an incremental element, it can prevent its incremental layer - * from clearing. Only the first incremental element on a layer can - * use `notClear`. + * 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. + * } + * ``` */ notClear?: boolean + /** + * See `notClear` + */ + __layerCleared?: boolean /** * Never increase to target size @@ -200,7 +235,7 @@ class Displayable extends Ele } // Hook provided to developers. - beforeBrush() {} + beforeBrush(param: BeforeBrushParam) {} afterBrush() {} // Hook provided to inherited classes. @@ -629,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 3fa043589..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[] = [] diff --git a/src/zrender.ts b/src/zrender.ts index 984914d2c..65365492f 100644 --- a/src/zrender.ts +++ b/src/zrender.ts @@ -81,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. @@ -262,6 +263,7 @@ class ZRender { if (this._disposed) { return; } + this._needsRefresh = true; // Active the animation again. this.animation.start(); @@ -283,6 +285,7 @@ class ZRender { const start = getTime(); const needsRefresh = this._needsRefresh; const needsRefreshHover = this._needsRefreshHover; + if (needsRefresh || needsRefreshHover) { triggerRendered = true; this._refresh({ From 716d480cba1d2fff722d0716cb6c188304bf8c5d Mon Sep 17 00:00:00 2001 From: 100pah Date: Tue, 24 Mar 2026 16:43:51 +0800 Subject: [PATCH 08/14] Fix the preceding commit (single canvas case). --- src/canvas/Painter.ts | 48 ++++++++++++++++++++++++++----------------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 454aa1fb2..42e3cff2f 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -79,6 +79,20 @@ 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 @@ -241,7 +255,7 @@ export default class CanvasPainter implements PainterBase { this.type = 'canvas'; - const internal: CanvasPainterInternal = this._i = { + this._i = { layerStack: [], layers: [], }; @@ -302,15 +316,11 @@ 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); - ensureLayerListInZLevel(internal, CANVAS_ZLEVEL)[0] = mainLayer; - mainLayer.zlevel = CANVAS_ZLEVEL; - // Not use common zlevel. - internal.layerStack.push({zl: CANVAS_ZLEVEL, zl2: 0}); + // singleLayer.resize(width, height); + this._insertLayer(singleLayer, CANVAS_ZLEVEL, ZLEVEL2_NORMAL_BELOW, true); this._domRoot = root; } @@ -703,12 +713,7 @@ export default class CanvasPainter implements PainterBase { let layer = ensureLayerListInZLevel(this._i, zlevel)[zlevel2]; if (!layer) { - // Create a new layer - layer = new Layer('zr_' + zlevel + '.' + zlevel2, this, this.dpr); - layer.zlevel = zlevel; - layer.zlevel2 = zlevel2; - layer.__builtin__ = true; - resetLayerDrawCursors(layer); + layer = createBuiltinLayer('zr_' + zlevel + '.' + zlevel2, this, zlevel, zlevel2); if (this._layerConfig[zlevel]) { util.merge(layer, this._layerConfig[zlevel], true); @@ -720,7 +725,7 @@ export default class CanvasPainter implements PainterBase { layer.virtual = true; } - this._insertLayer(layer, zlevel, zlevel2); + this._insertLayer(layer, zlevel, zlevel2, false); // Context is created after dom inserted to document // Or excanvas will get 0px clientWidth and clientHeight @@ -735,10 +740,15 @@ export default class CanvasPainter implements PainterBase { * e.g., insert a webGL layer by echarts-gl. */ insertLayer(zlevel: ZLevel, layer: Layer) { - this._insertLayer(layer, zlevel, 0); + this._insertLayer(layer, zlevel, 0, false); } - private _insertLayer(layer: Layer, zlevel: ZLevel, zlevel2: ZLevel2) { + private _insertLayer( + layer: Layer, + zlevel: ZLevel, + zlevel2: ZLevel2, + suppressDOMInsert: boolean + ) { const internal = this._i; const layersMap = internal.layers; const layerStack = internal.layerStack; @@ -777,7 +787,7 @@ export default class CanvasPainter implements PainterBase { // 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) { From 0051ea1936d89c51f72796e2fa53741d14688307 Mon Sep 17 00:00:00 2001 From: 100pah Date: Wed, 25 Mar 2026 14:08:14 +0800 Subject: [PATCH 09/14] Fix backward compatibility. --- src/canvas/Painter.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 42e3cff2f..8e5beaff2 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -849,10 +849,14 @@ export default class CanvasPainter implements PainterBase { * NOTICE: Only for debugging or testing. */ getLayers() { - // For backward compatibility const layers: Record = {}; eachLayer(this._i, function (layer, zlevel, zlevel2) { - layers[`zlevel:${zlevel},zlevel2:${zlevel2}`] = layer; + // This conversion is only for backward compatibility. + layers[ + zlevel2 === ZLEVEL2_NORMAL_BELOW ? zlevel + : zlevel2 === ZLEVEL2_INCREMENTAL ? `${zlevel}.0${zlevel2}` + : `${zlevel}.${zlevel2}` + ] = layer; }); return layers; } From 84df3219996df20ae0e72ce66bd1f4dc42d7eab3 Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 6 Apr 2026 18:20:01 +0800 Subject: [PATCH 10/14] fix regression: Incremental can not keep rendering for `notClear`. --- src/canvas/Layer.ts | 5 +++ src/canvas/Painter.ts | 75 +++++++++++++++++++++++++++---------------- 2 files changed, 52 insertions(+), 28 deletions(-) diff --git a/src/canvas/Layer.ts b/src/canvas/Layer.ts index 7a29e1d48..e1e3c3911 100644 --- a/src/canvas/Layer.ts +++ b/src/canvas/Layer.ts @@ -65,7 +65,12 @@ export interface LayerDrawCursor { // 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 diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 8e5beaff2..7257ccc82 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -129,6 +129,7 @@ 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; @@ -198,7 +199,7 @@ interface CanvasPainterInternal { // This list represents the existing layers and the actual z-order. layerStack: LayerKey[]; // structure: _layers[zlevel][zlevel2] - // See more details in LAYER_STACKING + // See more details in CANVAS_LAYER_STACKING // CAVEAT: // Do not iterate `layers`; iterate `layerStack` instead. layers: Layer[][]; @@ -527,7 +528,9 @@ export default class CanvasPainter implements PainterBase { eachLayer(this._i, function (layer) { let needDraw = false; eachCursorInLayer(layer, function (cursor) { - if (cursor.drawIdx < cursor.endIdx) { + if (cursor.drawIdx < cursor.endIdx + || cursor.notClearIdx >= 0 + ) { needDraw = true; } }); @@ -634,10 +637,17 @@ export default class CanvasPainter implements PainterBase { const startTime = useTimer && platformApi.getTime(); // NOTICE: This loop is performance-sensitive, especially for large data. - let idx = layerCursor.drawIdx; + 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; + } + if (el.__inHover) { // To avoid repeatedly repaint hover layer in progressive rendering, // set HOVER_LAYER_DIRTY_REPAINT only when needed. @@ -677,7 +687,7 @@ export default class CanvasPainter implements PainterBase { } brushLoopFinalize(ctx, scope); - layerCursor.drawIdx = idx; + layerCursor.drawIdx = Math.max(idx, drawIdxBegin); // `idx` may < `drawIdxBegin` due to `notClearIdx`. } /** @@ -855,24 +865,26 @@ export default class CanvasPainter implements PainterBase { layers[ zlevel2 === ZLEVEL2_NORMAL_BELOW ? zlevel : zlevel2 === ZLEVEL2_INCREMENTAL ? `${zlevel}.0${zlevel2}` - : `${zlevel}.${zlevel2}` + : `${zlevel}.${zlevel2}` // ZLEVEL2_NORMAL_ABOVE ] = layer; }); return layers; } /** - * Two use patterns are covered per incremental layer: - * [INCREMENTAL_CASE_SINGLE_ELEMENT] + * @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. - * [INCREMENTAL_CASE_MULTIPLE_ELEMENTS] + * [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. * - * [LAYER_STACKING] + * @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: @@ -885,7 +897,7 @@ export default class CanvasPainter implements PainterBase { * `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_LAYERS_PER_ZLEVEL] + * - [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`, @@ -894,7 +906,7 @@ export default class CanvasPainter implements PainterBase { * - 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. * - * [DISPLAY_LIST_SORTING_AND_LAYERING] + * @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. @@ -906,10 +918,10 @@ export default class CanvasPainter implements PainterBase { * 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 LAYER_SAMPLE_CASE_3 for more details. + * See CANVAS_LAYER_SAMPLE_CASE_3 for more details. * * Consider sample cases below to check the implementation: - * - [LAYER_SAMPLE_CASE_1]: + * - [CANVAS_LAYER_SAMPLE_CASE_1]: * `zlevel:5` is explicitly specified by users. * `zlevel:0` is the default. * [[ layer_hover zlevel:100000 ]] @@ -919,12 +931,12 @@ export default class CanvasPainter implements PainterBase { * [[ layer_normal_above_1 zlevel:0, zlevel2:2 ]] * [[ layer_incremental_1 zlevel:0, zlevel2:1 ]] * [[ layer_normal_below_1 zlevel:0, zlevel2:0 ]] - * - [LAYER_SAMPLE_CASE_2]: + * - [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 ]] - * - [LAYER_SAMPLE_CASE_3]: + * - [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}]`. @@ -941,21 +953,21 @@ export default class CanvasPainter implements PainterBase { * [[ layerDrawCursor:7 {b_inc:7}, {c_inc:7} {m_inc:7} ]] * [[ layer_normal_below zlevel:0, zlevel2:0 layerDrawCursor:0 {a_nor} ]] * - * [LAYER_DIRTY_RULES]: + * @tutorial [CANVAS_LAYER_DIRTY_RULES]: * Only dirty layer will be cleared and repaint later. `layer.__dirty` is set by: - * - REDRAW_BIT of every element. [LAYER_DIRTY_BY_REDRAW_BIT] + * - 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`. - * [LAYER_CONTENT_RETAINED]: + * [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. - * [LAYER_FAIL_TO_DIRTY_IF_ONLY_REORDER]: + * [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. @@ -966,7 +978,7 @@ export default class CanvasPainter implements PainterBase { * * PENDING: * - [PENDING_SEPARATE_DISPLAY_LIST]: - * In INCREMENTAL_CASE_MULTIPLE_ELEMENTS, displayList sorting and `_updateAndAddDisplayable` will be + * 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()`. @@ -991,6 +1003,7 @@ export default class CanvasPainter implements PainterBase { eachCursorInLayer(layer, function (cursor) { cursor.used = false; cursor.endIdxNew = 0; + cursor.notClearIdx = -1; }); }, EACH_LAYER_BUILTIN_NOT_HOVER); @@ -1016,7 +1029,7 @@ export default class CanvasPainter implements PainterBase { zlevel2 = ZLEVEL2_INCREMENTAL; } else { - // See LIMITED_TO_3_LAYERS_PER_ZLEVEL + // 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; @@ -1042,7 +1055,7 @@ export default class CanvasPainter implements PainterBase { if (!currCursor.used) { // Now `el` is the first element in `currCursor` in this pass. currCursor.used = true; - if (!paintAll && currCursor.first === el.id) { // See LAYER_CONTENT_RETAINED + 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. @@ -1060,7 +1073,7 @@ export default class CanvasPainter implements PainterBase { // Else `currCursor` is not changed, keep using it. This is the most common case, // so we retain this past path for performance. - // See LAYER_FAIL_TO_DIRTY_IF_ONLY_REORDER + // 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) { @@ -1074,13 +1087,19 @@ export default class CanvasPainter implements PainterBase { 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. - && (!elIncremental - || (!el.notClear && idx < currCursor.drawIdx) - ) // See LAYER_DIRTY_BY_REDRAW_BIT ) { - currLayer.__dirty = true; + if (!elIncremental // Always dirty the entire normal layer if any dirty occurs. + || (!el.notClear && idx < currCursor.drawIdx) + ) { + currLayer.__dirty = true; + } + 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. @@ -1103,7 +1122,7 @@ export default class CanvasPainter implements PainterBase { const endIdxNew = cursor.endIdxNew; if (isIncrementalLayer(layer) ? endIdxNew < cursor.drawIdx - : ( // See LAYER_FAIL_TO_DIRTY_IF_ONLY_REORDER + : ( // See CANVAS_LAYER_FAIL_TO_DIRTY_IF_ONLY_REORDER endIdxNew !== cursor.endIdx || !endIdxNew || list[endIdxNew - 1].id !== cursor.last From 5afadb72f21181a13cdf3ba03d99e2245277cea6 Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 6 Apr 2026 18:25:49 +0800 Subject: [PATCH 11/14] Fix REFERENCE_BEFORE_LEXICAL_DECL from DeepScan. --- src/Element.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/Element.ts b/src/Element.ts index 5e38d03f9..7237a7000 100644 --- a/src/Element.ts +++ b/src/Element.ts @@ -307,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<{ @@ -2142,21 +2159,6 @@ function isTextRelatedEl(el: Element): boolean { return el.type === 'text' || el.type === 'tspan'; } -// 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; - function canTransition( el: Element, From 5fd212a2be2c1fb66781566d964668b537551657 Mon Sep 17 00:00:00 2001 From: 100pah Date: Tue, 7 Apr 2026 17:25:55 +0800 Subject: [PATCH 12/14] test: tweak debug output. --- src/canvas/Painter.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 7257ccc82..07b6846a1 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -861,12 +861,7 @@ export default class CanvasPainter implements PainterBase { getLayers() { const layers: Record = {}; eachLayer(this._i, function (layer, zlevel, zlevel2) { - // This conversion is only for backward compatibility. - layers[ - zlevel2 === ZLEVEL2_NORMAL_BELOW ? zlevel - : zlevel2 === ZLEVEL2_INCREMENTAL ? `${zlevel}.0${zlevel2}` - : `${zlevel}.${zlevel2}` // ZLEVEL2_NORMAL_ABOVE - ] = layer; + layers[`${zlevel}|${zlevel2}`] = layer; }); return layers; } From 9b05600c7e1e6fb385960dea3e22eafd622f14fb Mon Sep 17 00:00:00 2001 From: 100pah Date: Fri, 10 Apr 2026 01:54:20 +0800 Subject: [PATCH 13/14] tweak --- src/canvas/Painter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/canvas/Painter.ts b/src/canvas/Painter.ts index 07b6846a1..c047dc292 100644 --- a/src/canvas/Painter.ts +++ b/src/canvas/Painter.ts @@ -861,7 +861,7 @@ export default class CanvasPainter implements PainterBase { getLayers() { const layers: Record = {}; eachLayer(this._i, function (layer, zlevel, zlevel2) { - layers[`${zlevel}|${zlevel2}`] = layer; + layers[layer.id] = layer; }); return layers; } From 0fe4f7fb6829ff03cde3c2834761663a52cddced Mon Sep 17 00:00:00 2001 From: 100pah Date: Mon, 27 Apr 2026 04:36:29 +0800 Subject: [PATCH 14/14] feat: (1) Export static methods - beneficial for users' code size. (2) Add utils. --- src/core/BoundingRect.ts | 54 ++++++++++++++++++++++++++------------- src/core/Transformable.ts | 27 ++++++++++++-------- src/core/util.ts | 32 ++++++++++++++++++++++- 3 files changed, 83 insertions(+), 30 deletions(-) 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/util.ts b/src/core/util.ts index d927d680c..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'; @@ -203,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