UNPKG

zrender

Version:

A lightweight graphic library providing 2d draw for Apache ECharts

677 lines (587 loc) 21.4 kB
import Displayable, { DisplayableProps, CommonStyleProps, DEFAULT_COMMON_STYLE, DisplayableStatePropNames, DEFAULT_COMMON_ANIMATION_PROPS } from './Displayable'; import Element, { ElementAnimateConfig } from '../Element'; import PathProxy from '../core/PathProxy'; import * as pathContain from '../contain/path'; import { PatternObject } from './Pattern'; import { Dictionary, PropType, MapToType } from '../core/types'; import BoundingRect from '../core/BoundingRect'; import { LinearGradientObject } from './LinearGradient'; import { RadialGradientObject } from './RadialGradient'; import { defaults, keys, extend, clone, isString, createObject } from '../core/util'; import Animator from '../animation/Animator'; import { lum } from '../tool/color'; import { DARK_LABEL_COLOR, LIGHT_LABEL_COLOR, DARK_MODE_THRESHOLD, LIGHTER_LABEL_COLOR } from '../config'; import { REDRAW_BIT, SHAPE_CHANGED_BIT, STYLE_CHANGED_BIT } from './constants'; import { TRANSFORMABLE_PROPS } from '../core/Transformable'; export interface PathStyleProps extends CommonStyleProps { fill?: string | PatternObject | LinearGradientObject | RadialGradientObject stroke?: string | PatternObject | LinearGradientObject | RadialGradientObject decal?: PatternObject /** * Still experimental, not works weel on arc with edge cases(large angle). */ strokePercent?: number strokeNoScale?: boolean fillOpacity?: number strokeOpacity?: number /** * `true` is not supported. * `false`/`null`/`undefined` are the same. * `false` is used to remove lineDash in some * case that `null`/`undefined` can not be set. * (e.g., emphasis.lineStyle in echarts) */ lineDash?: false | number[] | 'solid' | 'dashed' | 'dotted' lineDashOffset?: number lineWidth?: number lineCap?: CanvasLineCap lineJoin?: CanvasLineJoin miterLimit?: number /** * Paint order, if do stroke first. Similar to SVG paint-order * https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/paint-order */ strokeFirst?: boolean } export const DEFAULT_PATH_STYLE: PathStyleProps = defaults({ fill: '#000', stroke: null, strokePercent: 1, fillOpacity: 1, strokeOpacity: 1, lineDashOffset: 0, lineWidth: 1, lineCap: 'butt', miterLimit: 10, strokeNoScale: false, strokeFirst: false } as PathStyleProps, DEFAULT_COMMON_STYLE); export const DEFAULT_PATH_ANIMATION_PROPS: MapToType<PathProps, boolean> = { style: defaults<MapToType<PathStyleProps, boolean>, MapToType<PathStyleProps, boolean>>({ fill: true, stroke: true, strokePercent: true, fillOpacity: true, strokeOpacity: true, lineDashOffset: true, lineWidth: true, miterLimit: true } as MapToType<PathStyleProps, boolean>, DEFAULT_COMMON_ANIMATION_PROPS.style) }; export interface PathProps extends DisplayableProps { strokeContainThreshold?: number segmentIgnoreThreshold?: number subPixelOptimize?: boolean style?: PathStyleProps shape?: Dictionary<any> autoBatch?: boolean __value?: (string | number)[] | (string | number) buildPath?: ( ctx: PathProxy | CanvasRenderingContext2D, shapeCfg: Dictionary<any>, inBatch?: boolean ) => void } type PathKey = keyof PathProps type PathPropertyType = PropType<PathProps, PathKey> // eslint-disable-next-line @typescript-eslint/no-unused-vars interface Path<Props extends PathProps = PathProps> { animate(key?: '', loop?: boolean): Animator<this> animate(key: 'style', loop?: boolean): Animator<this['style']> animate(key: 'shape', loop?: boolean): Animator<this['shape']> getState(stateName: string): PathState ensureState(stateName: string): PathState states: Dictionary<PathState> stateProxy: (stateName: string) => PathState } export type PathStatePropNames = DisplayableStatePropNames | 'shape'; export type PathState = Pick<PathProps, PathStatePropNames> & { hoverLayer?: boolean } const pathCopyParams = (TRANSFORMABLE_PROPS as readonly string[]).concat(['invisible', 'culling', 'z', 'z2', 'zlevel', 'parent' ]) as (keyof Path)[]; class Path<Props extends PathProps = PathProps> extends Displayable<Props> { path: PathProxy strokeContainThreshold: number // This item default to be false. But in map series in echarts, // in order to improve performance, it should be set to true, // so the shorty segment won't draw. segmentIgnoreThreshold: number subPixelOptimize: boolean style: PathStyleProps /** * If element can be batched automatically */ autoBatch: boolean private _rectStroke: BoundingRect protected _normalState: PathState protected _decalEl: Path // Must have an initial value on shape. // It will be assigned by default value. shape: Dictionary<any> constructor(opts?: Props) { super(opts); } update() { super.update(); const style = this.style; if (style.decal) { const decalEl: Path = this._decalEl = this._decalEl || new Path(); if (decalEl.buildPath === Path.prototype.buildPath) { decalEl.buildPath = ctx => { this.buildPath(ctx, this.shape); }; } decalEl.silent = true; const decalElStyle = decalEl.style; for (let key in style) { if ((decalElStyle as any)[key] !== (style as any)[key]) { (decalElStyle as any)[key] = (style as any)[key]; } } decalElStyle.fill = style.fill ? style.decal : null; decalElStyle.decal = null; decalElStyle.shadowColor = null; style.strokeFirst && (decalElStyle.stroke = null); for (let i = 0; i < pathCopyParams.length; ++i) { (decalEl as any)[pathCopyParams[i]] = this[pathCopyParams[i]]; } decalEl.__dirty |= REDRAW_BIT; } else if (this._decalEl) { this._decalEl = null; } } getDecalElement() { return this._decalEl; } protected _init(props?: Props) { // Init default properties const keysArr = keys(props); this.shape = this.getDefaultShape(); const defaultStyle = this.getDefaultStyle(); if (defaultStyle) { this.useStyle(defaultStyle); } for (let i = 0; i < keysArr.length; i++) { const key = keysArr[i]; const value = props[key]; if (key === 'style') { if (!this.style) { // PENDING Reuse style object if possible? this.useStyle(value as Props['style']); } else { extend(this.style, value as Props['style']); } } else if (key === 'shape') { // this.shape = value; extend(this.shape, value as Props['shape']); } else { super.attrKV(key as any, value); } } // Create an empty one if no style object exists. if (!this.style) { this.useStyle({}); } // const defaultShape = this.getDefaultShape(); // if (!this.shape) { // this.shape = defaultShape; // } // else { // defaults(this.shape, defaultShape); // } } protected getDefaultStyle(): Props['style'] { return null; } // Needs to override protected getDefaultShape() { return {}; } protected canBeInsideText() { return this.hasFill(); } protected getInsideTextFill() { const pathFill = this.style.fill; if (pathFill !== 'none') { if (isString(pathFill)) { const fillLum = lum(pathFill, 0); // Determin text color based on the lum of path fill. // TODO use (1 - DARK_MODE_THRESHOLD)? if (fillLum > 0.5) { // TODO Consider background lum? return DARK_LABEL_COLOR; } else if (fillLum > 0.2) { return LIGHTER_LABEL_COLOR; } return LIGHT_LABEL_COLOR; } else if (pathFill) { return LIGHT_LABEL_COLOR; } } return DARK_LABEL_COLOR; } protected getInsideTextStroke(textFill?: string) { const pathFill = this.style.fill; // Not stroke on none fill object or gradient object if (isString(pathFill)) { const zr = this.__zr; const isDarkMode = !!(zr && zr.isDarkMode()); const isDarkLabel = lum(textFill, 0) < DARK_MODE_THRESHOLD; // All dark or all light. if (isDarkMode === isDarkLabel) { return pathFill; } } } // When bundling path, some shape may decide if use moveTo to begin a new subpath or closePath // Like in circle buildPath( ctx: PathProxy | CanvasRenderingContext2D, shapeCfg: Dictionary<any>, inBatch?: boolean ) {} pathUpdated() { this.__dirty &= ~SHAPE_CHANGED_BIT; } getUpdatedPathProxy(inBatch?: boolean) { // Update path proxy data to latest. !this.path && this.createPathProxy(); this.path.beginPath(); this.buildPath(this.path, this.shape, inBatch); return this.path; } createPathProxy() { this.path = new PathProxy(false); } hasStroke() { const style = this.style; const stroke = style.stroke; return !(stroke == null || stroke === 'none' || !(style.lineWidth > 0)); } hasFill() { const style = this.style; const fill = style.fill; return fill != null && fill !== 'none'; } getBoundingRect(): BoundingRect { let rect = this._rect; const style = this.style; const needsUpdateRect = !rect; if (needsUpdateRect) { let firstInvoke = false; if (!this.path) { firstInvoke = true; // Create path on demand. this.createPathProxy(); } let path = this.path; if (firstInvoke || (this.__dirty & SHAPE_CHANGED_BIT)) { path.beginPath(); this.buildPath(path, this.shape, false); this.pathUpdated(); } rect = path.getBoundingRect(); } this._rect = rect; if (this.hasStroke() && this.path && this.path.len() > 0) { // Needs update rect with stroke lineWidth when // 1. Element changes scale or lineWidth // 2. Shape is changed const rectStroke = this._rectStroke || (this._rectStroke = rect.clone()); if (this.__dirty || needsUpdateRect) { rectStroke.copy(rect); // PENDING, Min line width is needed when line is horizontal or vertical const lineScale = style.strokeNoScale ? this.getLineScale() : 1; // FIXME Must after updateTransform let w = style.lineWidth; // Only add extra hover lineWidth when there are no fill if (!this.hasFill()) { const strokeContainThreshold = this.strokeContainThreshold; w = Math.max(w, strokeContainThreshold == null ? 4 : strokeContainThreshold); } // Consider line width // Line scale can't be 0; if (lineScale > 1e-10) { rectStroke.width += w / lineScale; rectStroke.height += w / lineScale; rectStroke.x -= w / lineScale / 2; rectStroke.y -= w / lineScale / 2; } } // Return rect with stroke return rectStroke; } return rect; } contain(x: number, y: number): boolean { const localPos = this.transformCoordToLocal(x, y); const rect = this.getBoundingRect(); const style = this.style; x = localPos[0]; y = localPos[1]; if (rect.contain(x, y)) { const pathProxy = this.path; if (this.hasStroke()) { let lineWidth = style.lineWidth; let lineScale = style.strokeNoScale ? this.getLineScale() : 1; // Line scale can't be 0; if (lineScale > 1e-10) { // Only add extra hover lineWidth when there are no fill if (!this.hasFill()) { lineWidth = Math.max(lineWidth, this.strokeContainThreshold); } if (pathContain.containStroke( pathProxy, lineWidth / lineScale, x, y )) { return true; } } } if (this.hasFill()) { return pathContain.contain(pathProxy, x, y); } } return false; } /** * Shape changed */ dirtyShape() { this.__dirty |= SHAPE_CHANGED_BIT; if (this._rect) { this._rect = null; } if (this._decalEl) { this._decalEl.dirtyShape(); } this.markRedraw(); } dirty() { this.dirtyStyle(); this.dirtyShape(); } /** * Alias for animate('shape') * @param {boolean} loop */ animateShape(loop: boolean) { return this.animate('shape', loop); } // Override updateDuringAnimation updateDuringAnimation(targetKey: string) { if (targetKey === 'style') { this.dirtyStyle(); } else if (targetKey === 'shape') { this.dirtyShape(); } else { this.markRedraw(); } } // Overwrite attrKV attrKV(key: PathKey, value: PathPropertyType) { // FIXME if (key === 'shape') { this.setShape(value as Props['shape']); } else { super.attrKV(key as keyof DisplayableProps, value); } } setShape(obj: Props['shape']): this setShape<T extends keyof Props['shape']>(obj: T, value: Props['shape'][T]): this setShape(keyOrObj: keyof Props['shape'] | Props['shape'], value?: unknown): this { let shape = this.shape; if (!shape) { shape = this.shape = {}; } // Path from string may not have shape if (typeof keyOrObj === 'string') { shape[keyOrObj] = value; } else { extend(shape, keyOrObj as Props['shape']); } this.dirtyShape(); return this; } /** * If shape changed. used with dirtyShape */ shapeChanged() { return !!(this.__dirty & SHAPE_CHANGED_BIT); } /** * Create a path style object with default values in it's prototype. * @override */ createStyle(obj?: Props['style']) { return createObject(DEFAULT_PATH_STYLE, obj); } protected _innerSaveToNormal(toState: PathState) { super._innerSaveToNormal(toState); const normalState = this._normalState; // Clone a new one. DON'T share object reference between states and current using. // TODO: Clone array in shape?. // TODO: Only save changed shape. if (toState.shape && !normalState.shape) { normalState.shape = extend({}, this.shape); } } protected _applyStateObj( stateName: string, state: PathState, normalState: PathState, keepCurrentStates: boolean, transition: boolean, animationCfg: ElementAnimateConfig ) { super._applyStateObj(stateName, state, normalState, keepCurrentStates, transition, animationCfg); const needsRestoreToNormal = !(state && keepCurrentStates); let targetShape: Props['shape']; if (state && state.shape) { // Only animate changed properties. if (transition) { if (keepCurrentStates) { targetShape = state.shape; } else { // Inherits from normal state. targetShape = extend({}, normalState.shape); extend(targetShape, state.shape); } } else { // Because the shape will be replaced. So inherits from current shape. targetShape = extend({}, keepCurrentStates ? this.shape : normalState.shape); extend(targetShape, state.shape); } } else if (needsRestoreToNormal) { targetShape = normalState.shape; } if (targetShape) { if (transition) { // Clone a new shape. this.shape = extend({}, this.shape); // Only supports transition on primary props. Because shape is not deep cloned. const targetShapePrimaryProps: Props['shape'] = {}; const shapeKeys = keys(targetShape); for (let i = 0; i < shapeKeys.length; i++) { const key = shapeKeys[i]; if (typeof targetShape[key] === 'object') { (this.shape as Props['shape'])[key] = targetShape[key]; } else { targetShapePrimaryProps[key] = targetShape[key]; } } this._transitionState(stateName, { shape: targetShapePrimaryProps } as Props, animationCfg); } else { this.shape = targetShape; this.dirtyShape(); } } } protected _mergeStates(states: PathState[]) { const mergedState = super._mergeStates(states) as PathState; let mergedShape: Props['shape']; for (let i = 0; i < states.length; i++) { const state = states[i]; if (state.shape) { mergedShape = mergedShape || {}; this._mergeStyle(mergedShape, state.shape); } } if (mergedShape) { mergedState.shape = mergedShape; } return mergedState; } getAnimationStyleProps() { return DEFAULT_PATH_ANIMATION_PROPS; } /** * If path shape is zero area */ isZeroArea(): boolean { return false; } /** * 扩展一个 Path element, 比如星形,圆等。 * Extend a path element * @DEPRECATED Use class extends * @param props * @param props.type Path type * @param props.init Initialize * @param props.buildPath Overwrite buildPath method * @param props.style Extended default style config * @param props.shape Extended default shape config */ static extend<Shape extends Dictionary<any>>(defaultProps: { type?: string shape?: Shape style?: PathStyleProps beforeBrush?: Displayable['beforeBrush'] afterBrush?: Displayable['afterBrush'] getBoundingRect?: Displayable['getBoundingRect'] calculateTextPosition?: Element['calculateTextPosition'] buildPath(this: Path, ctx: CanvasRenderingContext2D | PathProxy, shape: Shape, inBatch?: boolean): void init?(this: Path, opts: PathProps): void // TODO Should be SubPathOption }): { new(opts?: PathProps & {shape: Shape}): Path } { interface SubPathOption extends PathProps { shape: Shape } class Sub extends Path { shape: Shape getDefaultStyle() { return clone(defaultProps.style); } getDefaultShape() { return clone(defaultProps.shape); } constructor(opts?: SubPathOption) { super(opts); defaultProps.init && defaultProps.init.call(this as any, opts); } } // TODO Legacy usage. Extend functions for (let key in defaultProps) { if (typeof (defaultProps as any)[key] === 'function') { (Sub.prototype as any)[key] = (defaultProps as any)[key]; } } // Sub.prototype.buildPath = defaultProps.buildPath; // Sub.prototype.beforeBrush = defaultProps.beforeBrush; // Sub.prototype.afterBrush = defaultProps.afterBrush; return Sub as any; } protected static initDefaultProps = (function () { const pathProto = Path.prototype; pathProto.type = 'path'; pathProto.strokeContainThreshold = 5; pathProto.segmentIgnoreThreshold = 0; pathProto.subPixelOptimize = false; pathProto.autoBatch = false; pathProto.__dirty = REDRAW_BIT | STYLE_CHANGED_BIT | SHAPE_CHANGED_BIT; })() } export default Path;