From fb44ba3a30139b688aa7d575307d8e305cc6a1d6 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Wed, 23 Apr 2025 11:29:58 +0800 Subject: [PATCH 1/6] refactor: depart interaction and renderer --- .../examples/data-zoom-preview-set-state.ts | 7 +- .../__tests__/browser/examples/data-zoom.ts | 2 +- .../src/data-zoom/data-zoom-3.ts | 1250 ++++++++++++++++ .../src/data-zoom/data-zoom-4.ts | 1272 +++++++++++++++++ .../src/data-zoom/data-zoom.ts | 1245 ++-------------- .../src/data-zoom/interaction.ts | 403 ++++++ .../src/data-zoom/renderer.ts | 797 +++++++++++ .../vrender-components/src/data-zoom/utils.ts | 37 + 8 files changed, 3890 insertions(+), 1123 deletions(-) create mode 100644 packages/vrender-components/src/data-zoom/data-zoom-3.ts create mode 100644 packages/vrender-components/src/data-zoom/data-zoom-4.ts create mode 100644 packages/vrender-components/src/data-zoom/interaction.ts create mode 100644 packages/vrender-components/src/data-zoom/renderer.ts create mode 100644 packages/vrender-components/src/data-zoom/utils.ts diff --git a/packages/vrender-components/__tests__/browser/examples/data-zoom-preview-set-state.ts b/packages/vrender-components/__tests__/browser/examples/data-zoom-preview-set-state.ts index badfcc219..ed314a716 100644 --- a/packages/vrender-components/__tests__/browser/examples/data-zoom-preview-set-state.ts +++ b/packages/vrender-components/__tests__/browser/examples/data-zoom-preview-set-state.ts @@ -26,7 +26,11 @@ export function run() { height: 30 }, realTime: false, - brushSelect: false, + brushSelect: true, + middleHandlerStyle: { + visible: true + }, + showDetail: 'auto', updateStateCallback: (start, end) => { console.log('setCallback', start, end); } @@ -40,4 +44,5 @@ export function run() { dataZoom.setPreviewPointsY1(d => 265); const stage = render([dataZoom], 'main'); + console.log('stage', stage); } diff --git a/packages/vrender-components/__tests__/browser/examples/data-zoom.ts b/packages/vrender-components/__tests__/browser/examples/data-zoom.ts index d7165eb48..17ec1a3d1 100644 --- a/packages/vrender-components/__tests__/browser/examples/data-zoom.ts +++ b/packages/vrender-components/__tests__/browser/examples/data-zoom.ts @@ -20,7 +20,7 @@ export function run() { }, showDetail: false, delayTime: 1000, - // brushSelect: false, + brushSelect: true, backgroundChartStyle: { line: { visible: false diff --git a/packages/vrender-components/src/data-zoom/data-zoom-3.ts b/packages/vrender-components/src/data-zoom/data-zoom-3.ts new file mode 100644 index 000000000..1b40f8672 --- /dev/null +++ b/packages/vrender-components/src/data-zoom/data-zoom-3.ts @@ -0,0 +1,1250 @@ +import type { FederatedPointerEvent, IArea, IGroup, ILine, IRect, ISymbol, INode } from '@visactor/vrender-core'; +// eslint-disable-next-line no-duplicate-imports +import { flatten_simplify, vglobal } from '@visactor/vrender-core'; +import type { IBoundsLike, IPointLike } from '@visactor/vutils'; +// eslint-disable-next-line no-duplicate-imports +import { Bounds, array, clamp, debounce, isFunction, isValid, merge, throttle } from '@visactor/vutils'; +import { AbstractComponent } from '../core/base'; +import type { TagAttributes } from '../tag'; +// eslint-disable-next-line no-duplicate-imports +import { Tag } from '../tag'; +import { DEFAULT_DATA_ZOOM_ATTRIBUTES, DEFAULT_HANDLER_ATTR_MAP } from './config'; +import { DataZoomActiveTag } from './type'; +// eslint-disable-next-line no-duplicate-imports +import type { DataZoomAttributes } from './type'; +import type { ComponentOptions } from '../interface'; +import { loadDataZoomComponent } from './register'; +import { getEndTriggersOfDrag } from '../util/event'; + +const delayMap = { + debounce: debounce, + throttle: throttle +}; + +loadDataZoomComponent(); +export class DataZoom extends AbstractComponent> { + name = 'dataZoom'; + static defaultAttributes = DEFAULT_DATA_ZOOM_ATTRIBUTES; + + /** 容器相关 */ + private _container!: IGroup; + + private _background!: IRect; + + /** 中间量 */ + private _isHorizontal: boolean; + private _layoutCacheFromConfig: any; + + /** 手柄 */ + private _startHandlerMask!: IRect; + private _startHandler!: ISymbol; + private _middleHandlerSymbol!: ISymbol; + private _middleHandlerRect!: IRect; + private _endHandlerMask!: IRect; + private _endHandler!: ISymbol; + private _selectedBackground!: IRect; + private _dragMask!: IRect; + private _startText!: Tag; + private _endText!: Tag; + private _startValue!: string | number; + private _endValue!: string | number; + private _showText!: boolean; + + /** 背景图表 */ + private _previewData: any[] = []; + private _previewGroup!: IGroup; + private _previewLine!: ILine; + private _previewArea!: IArea; + private _selectedPreviewGroupClip!: IGroup; + private _selectedPreviewGroup!: IGroup; + private _selectedPreviewLine!: ILine; + private _selectedPreviewArea!: IArea; + + /** 交互状态 */ + private _activeTag!: DataZoomActiveTag; + private _activeItem!: any; + private _activeState = false; + private _activeCache: { + startPos: IPointLike; + lastPos: IPointLike; + } = { + startPos: { x: 0, y: 0 }, + lastPos: { x: 0, y: 0 } + }; + private _layoutCache: { + attPos: 'x' | 'y'; + attSize: 'width' | 'height'; + size: number; + } = { + attPos: 'x', + attSize: 'width', + size: 0 + }; + private _spanCache: number; + /** 起始状态 */ + private state = { + start: 0, + end: 1 + }; + + /** 回调函数 */ + private _previewPointsX!: (datum: any) => number; + private _previewPointsY!: (datum: any) => number; + private _previewPointsX1!: (datum: any) => number; + private _previewPointsY1!: (datum: any) => number; + private _statePointToData: (state: number) => any = state => state; + + constructor(attributes: DataZoomAttributes, options?: ComponentOptions) { + super(options?.skipDefault ? attributes : merge({}, DataZoom.defaultAttributes, attributes)); + this._initStates(); + } + + setAttributes(params: Partial>, forceUpdateTag?: boolean): void { + super.setAttributes(params, forceUpdateTag); + this._initStates(); + } + + private _initStates() { + const { + start, + end, + orient, + previewData, + showDetail, + previewPointsX, + previewPointsY, + previewPointsX1, + previewPointsY1 + } = this.attribute as DataZoomAttributes; + if (showDetail === 'auto') { + this._showText = false as boolean; + } else { + this._showText = showDetail as boolean; + } + start && (this.state.start = start); + end && (this.state.end = end); + const { width, height } = this._getLayoutAttrFromConfig(); + this._spanCache = this.state.end - this.state.start; + this._isHorizontal = orient === 'top' || orient === 'bottom'; + this._layoutCache.size = this._isHorizontal ? width : height; + this._layoutCache.attPos = this._isHorizontal ? 'x' : 'y'; + this._layoutCache.attSize = this._isHorizontal ? 'width' : 'height'; + previewData && (this._previewData = previewData); + isFunction(previewPointsX) && (this._previewPointsX = previewPointsX); + isFunction(previewPointsY) && (this._previewPointsY = previewPointsY); + isFunction(previewPointsX1) && (this._previewPointsX1 = previewPointsX1); + isFunction(previewPointsY1) && (this._previewPointsY1 = previewPointsY1); + } + + protected bindEvents(): void { + if (this.attribute.disableTriggerEvent) { + this.setAttribute('childrenPickable', false); + return; + } + const { showDetail, brushSelect } = this.attribute as DataZoomAttributes; + // 拖拽开始 + this._startHandlerMask?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'start') as unknown as EventListener + ); + this._endHandlerMask?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'end') as unknown as EventListener + ); + this._middleHandlerSymbol?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleSymbol') as unknown as EventListener + ); + this._middleHandlerRect?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleRect') as unknown as EventListener + ); + const selectedTag = brushSelect ? 'background' : 'middleRect'; + this._selectedBackground?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener + ); + brushSelect && + this._background?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener + ); + brushSelect && + this._previewGroup?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener + ); + this._selectedPreviewGroup?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener + ); + // hover + if (showDetail === 'auto') { + (this as unknown as IGroup).addEventListener('pointerenter', this._onHandlerPointerEnter as EventListener); + (this as unknown as IGroup).addEventListener('pointerleave', this._onHandlerPointerLeave as EventListener); + } + (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { + passive: false + }); + } + private _handleTouchMove = (e: TouchEvent) => { + if (this._activeState) { + /** + * https://developer.mozilla.org/zh-CN/docs/Web/CSS/overscroll-behavior + * 由于浏览器的overscroll-behavior属性,需要在move的时候阻止浏览器默认行为,否则会因为浏览器检测到scroll行为,阻止pointer事件, + * 抛出pointercancel事件,导致拖拽行为中断。 + */ + e.preventDefault(); + } + }; + + /** + * 拖拽开始事件 + * @description 开启activeState + 通过tag判断事件在哪个元素上触发 并 更新交互坐标 + */ + private _onHandlerPointerDown = (e: FederatedPointerEvent, tag: string) => { + // 清除之前的事件,防止没有被清除掉 + this._clearDragEvents(); + if (tag === 'start') { + this._activeTag = DataZoomActiveTag.startHandler; + this._activeItem = this._startHandlerMask; + } else if (tag === 'end') { + this._activeTag = DataZoomActiveTag.endHandler; + this._activeItem = this._endHandlerMask; + } else if (tag === 'middleRect') { + this._activeTag = DataZoomActiveTag.middleHandler; + this._activeItem = this._middleHandlerRect; + } else if (tag === 'middleSymbol') { + this._activeTag = DataZoomActiveTag.middleHandler; + this._activeItem = this._middleHandlerSymbol; + } else if (tag === 'background') { + this._activeTag = DataZoomActiveTag.background; + this._activeItem = this._background; + } + this._activeState = true; + this._activeCache.startPos = this._eventPosToStagePos(e); + this._activeCache.lastPos = this._eventPosToStagePos(e); + const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; + const triggers = getEndTriggersOfDrag(); + + /** + * move的时候,需要通过 capture: true,能够在捕获截断被拦截, + */ + evtTarget.addEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); + (this as unknown as IGroup).addEventListener('pointermove', this._onHandlerPointerMove, { + capture: true + }); + + triggers.forEach((trigger: string) => { + evtTarget.addEventListener(trigger, this._onHandlerPointerUp); + }); + }; + + /** + * 拖拽进行事件 + * @description 分为以下四种情况: + * 1. 在背景 or 背景图表上拖拽 (activeTag === 'background'): 改变lastPos => only renderDragMask + * 2. 在middleHandler上拖拽 (activeTag === 'middleHandler'): 改变lastPos、start & end + 边界处理: 防止拖拽结果超出背景 => render + * 3. 在startHandler上拖拽 (activeTag === 'startHandler'): 改变lastPos、start & end + 边界处理: startHandler和endHandler交换 => render + * 4. 在endHandler上拖拽,同上 + */ + private _pointerMove = (e: FederatedPointerEvent) => { + const { brushSelect, realTime = true } = this.attribute as DataZoomAttributes; + const pos = this._eventPosToStagePos(e); + const { attPos, size } = this._layoutCache; + const dis = (pos[attPos] - this._activeCache.lastPos[attPos]) / size; + + let { start, end } = this.state; + let shouldRender = true; + if (this._activeState) { + if (this._activeTag === DataZoomActiveTag.middleHandler) { + ({ start, end } = this._moveZoomWithMiddle(dis)); + } else if (this._activeTag === DataZoomActiveTag.startHandler) { + ({ start, end } = this._moveZoomWithHandler('start', dis)); + } else if (this._activeTag === DataZoomActiveTag.endHandler) { + ({ start, end } = this._moveZoomWithHandler('end', dis)); + } else if (this._activeTag === DataZoomActiveTag.background && brushSelect) { + ({ start, end } = this._renderDragMask(pos)); + shouldRender = false; + } + this._activeCache.lastPos = pos; + } + + // 避免attributes相同时, 重复渲染 + if (this.state.start !== start || this.state.end !== end) { + this._setStateAttr(start, end, shouldRender); + if (realTime) { + this._dispatchEvent('change', { + start, + end, + tag: this._activeTag + }); + } + } + }; + private _onHandlerPointerMove = + this.attribute.delayTime === 0 + ? this._pointerMove + : delayMap[this.attribute.delayType](this._pointerMove, this.attribute.delayTime); + + /** + * @description 拖拽middleHandler, 改变start和end + */ + private _moveZoomWithMiddle(dis: number) { + // 拖拽middleHandler时,限制在background范围内 + if (dis > 0 && this.state.end + dis > 1) { + dis = 1 - this.state.end; + } else if (dis < 0 && this.state.start + dis < 0) { + dis = -this.state.start; + } + return { + start: clamp(this.state.start + dis, 0, 1), + end: clamp(this.state.end + dis, 0, 1) + }; + } + + /** + * @description 拖拽startHandler/endHandler, 改变start和end + */ + private _moveZoomWithHandler(handler: 'start' | 'end', dis: number) { + const { start, end } = this.state; + let newStart = start; + let newEnd = end; + if (handler === 'start') { + if (start + dis > end) { + newStart = end; + newEnd = start + dis; + this._activeTag = DataZoomActiveTag.endHandler; + } else { + newStart = start + dis; + } + } else if (handler === 'end') { + if (end + dis < start) { + newEnd = start; + newStart = end + dis; + this._activeTag = DataZoomActiveTag.startHandler; + } else { + newEnd = end + dis; + } + } + return { + start: clamp(newStart, 0, 1), + end: clamp(newEnd, 0, 1) + }; + } + + /** + * @description 绘制拖拽时的mask + */ + private _renderDragMask(pos?: IPointLike) { + const { dragMaskStyle } = this.attribute as DataZoomAttributes; + const { position, width, height } = this._getLayoutAttrFromConfig(); + const currentPos = pos ?? this._activeCache.lastPos; + let start = clamp( + (this._activeCache.startPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / width, + 0, + 1 + ); + let end = clamp((currentPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / width, 0, 1); + if (start > end) { + [start, end] = [end, start]; + } + + // drag部分 + if (this._isHorizontal) { + this._dragMask = this._container.createOrUpdateChild( + 'dragMask', + { + x: position.x + start * width, + y: position.y, + width: (end - start) * width, + height: height, + ...dragMaskStyle + }, + 'rect' + ) as IRect; + } else { + this._dragMask = this._container.createOrUpdateChild( + 'dragMask', + { + x: position.x, + y: position.y + start * height, + width, + height: (end - start) * height, + ...dragMaskStyle + }, + 'rect' + ) as IRect; + } + return { start, end }; + } + /** + * 拖拽结束事件 + * @description 关闭activeState + 边界情况处理: 防止拖拽后start和end过近 + */ + private _onHandlerPointerUp = (e: FederatedPointerEvent) => { + if (this._activeState) { + // brush的时候, 只改变了state, 没有触发重新渲染, 在抬起鼠标时触发 + if (this._activeTag === DataZoomActiveTag.background) { + this._setStateAttr(this.state.start, this.state.end, true); + } + } + this._activeState = false; + // 此次dispatch不能被省略 + // 因为pointermove时, 已经将状态更新至最新, 所以在pointerup时, 必定start = state.start & end = state.end + // 而realTime = false时, 需要依赖这次dispatch来更新图表图元 + this._dispatchEvent('change', { + start: this.state.start, + end: this.state.end, + tag: this._activeTag + }); + this._clearDragEvents(); + }; + + /** + * 鼠标进入事件 + * @description 鼠标进入选中部分出现start和end文字 + */ + private _onHandlerPointerEnter(e: FederatedPointerEvent) { + this._showText = true; + this._renderText(); + } + + /** + * 鼠标移出事件 + * @description 鼠标移出选中部分不出现start和end文字 + */ + private _onHandlerPointerLeave(e: FederatedPointerEvent) { + this._showText = false; + this._renderText(); + } + + /** + * 判断文字是否超出datazoom范围 + */ + private _isTextOverflow(componentBoundsLike: IBoundsLike, textBounds: IBoundsLike | null, layout: 'start' | 'end') { + if (!textBounds) { + return false; + } + if (this._isHorizontal) { + if (layout === 'start') { + if (textBounds.x1 < componentBoundsLike.x1) { + return true; + } + } else { + if (textBounds.x2 > componentBoundsLike.x2) { + return true; + } + } + } else { + if (layout === 'start') { + if (textBounds.y1 < componentBoundsLike.y1) { + return true; + } + } else { + if (textBounds.y2 > componentBoundsLike.y2) { + return true; + } + } + } + return false; + } + + private _setTextAttr(startTextBounds: IBoundsLike, endTextBounds: IBoundsLike) { + const { startTextStyle, endTextStyle } = this.attribute as DataZoomAttributes; + const { formatMethod: startTextFormat, ...restStartTextStyle } = startTextStyle; + const { formatMethod: endTextFormat, ...restEndTextStyle } = endTextStyle; + const { start, end } = this.state; + this._startValue = this._statePointToData(start); + this._endValue = this._statePointToData(end); + const { position, width, height } = this._getLayoutAttrFromConfig(); + + const startTextValue = startTextFormat ? startTextFormat(this._startValue) : this._startValue; + const endTextValue = endTextFormat ? endTextFormat(this._endValue) : this._endValue; + const componentBoundsLike = { + x1: position.x, + y1: position.y, + x2: position.x + width, + y2: position.y + height + }; + let startTextPosition: IPointLike; + let endTextPosition: IPointLike; + let startTextAlignStyle: any; + let endTextAlignStyle: any; + if (this._isHorizontal) { + startTextPosition = { + x: position.x + start * width, + y: position.y + height / 2 + }; + endTextPosition = { + x: position.x + end * width, + y: position.y + height / 2 + }; + startTextAlignStyle = { + textAlign: this._isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'left' : 'right', + textBaseline: restStartTextStyle?.textStyle?.textBaseline ?? 'middle' + }; + endTextAlignStyle = { + textAlign: this._isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'right' : 'left', + textBaseline: restEndTextStyle?.textStyle?.textBaseline ?? 'middle' + }; + } else { + startTextPosition = { + x: position.x + width / 2, + y: position.y + start * height + }; + endTextPosition = { + x: position.x + width / 2, + y: position.y + end * height + }; + startTextAlignStyle = { + textAlign: restStartTextStyle?.textStyle?.textAlign ?? 'center', + textBaseline: this._isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'top' : 'bottom' + }; + endTextAlignStyle = { + textAlign: restEndTextStyle?.textStyle?.textAlign ?? 'center', + textBaseline: this._isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'bottom' : 'top' + }; + } + + this._startText = this._maybeAddLabel( + this._container, + merge({}, restStartTextStyle, { + text: startTextValue, + x: startTextPosition.x, + y: startTextPosition.y, + visible: this._showText, + pickable: false, + childrenPickable: false, + textStyle: startTextAlignStyle + }), + `data-zoom-start-text-${position}` + ); + this._endText = this._maybeAddLabel( + this._container, + merge({}, restEndTextStyle, { + text: endTextValue, + x: endTextPosition.x, + y: endTextPosition.y, + visible: this._showText, + pickable: false, + childrenPickable: false, + textStyle: endTextAlignStyle + }), + `data-zoom-end-text-${position}` + ); + } + + private _renderText() { + let startTextBounds: IBoundsLike | null = null; + let endTextBounds: IBoundsLike | null = null; + + // 第一次绘制 + this._setTextAttr(startTextBounds, endTextBounds); + // 得到bounds + startTextBounds = this._startText.AABBBounds; + endTextBounds = this._endText.AABBBounds; + + // 第二次绘制: 将text限制在组件bounds内 + this._setTextAttr(startTextBounds, endTextBounds); + // 得到bounds + startTextBounds = this._startText.AABBBounds; + endTextBounds = this._endText.AABBBounds; + const { x1, x2, y1, y2 } = startTextBounds; + const { dx: startTextDx = 0, dy: startTextDy = 0 } = this.attribute.startTextStyle; + + // 第三次绘制: 避免startText和endText重叠, 如果重叠了, 对startText做位置调整(考虑到调整的最小化,只单独调整startText而不调整endText) + if (new Bounds().set(x1, y1, x2, y2).intersects(endTextBounds)) { + const direction = this.attribute.orient === 'bottom' || this.attribute.orient === 'right' ? -1 : 1; + if (this._isHorizontal) { + this._startText.setAttribute('dy', startTextDy + direction * Math.abs(endTextBounds.y1 - endTextBounds.y2)); + } else { + this._startText.setAttribute('dx', startTextDx + direction * Math.abs(endTextBounds.x1 - endTextBounds.x2)); + } + } else { + if (this._isHorizontal) { + this._startText.setAttribute('dy', startTextDy); + } else { + this._startText.setAttribute('dx', startTextDx); + } + } + } + + /** + * 获取背景框中的位置和宽高 + * @description 实际绘制的背景框中的高度或宽度 减去 中间手柄的高度或宽度 + */ + private _getLayoutAttrFromConfig() { + if (this._layoutCacheFromConfig) { + return this._layoutCacheFromConfig; + } + const { + position: positionConfig, + size, + orient, + middleHandlerStyle = {}, + startHandlerStyle = {}, + endHandlerStyle = {}, + backgroundStyle = {} + } = this.attribute as DataZoomAttributes; + const { width: widthConfig, height: heightConfig } = size; + const middleHandlerSize = middleHandlerStyle.background?.size ?? 10; + + // 如果middleHandler显示的话,要将其宽高计入datazoom宽高 + let width; + let height; + let position; + if (middleHandlerStyle.visible) { + if (this._isHorizontal) { + width = widthConfig; + height = heightConfig - middleHandlerSize; + position = { + x: positionConfig.x, + y: positionConfig.y + middleHandlerSize + }; + } else { + width = widthConfig - middleHandlerSize; + height = heightConfig; + position = { + x: positionConfig.x + (orient === 'left' ? middleHandlerSize : 0), + y: positionConfig.y + }; + } + } else { + width = widthConfig; + height = heightConfig; + position = positionConfig; + } + + const startHandlerSize = (startHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); + const endHandlerSize = (endHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); + // 如果startHandler显示的话,要将其宽高计入dataZoom宽高 + if (startHandlerStyle.visible) { + if (this._isHorizontal) { + width -= (startHandlerSize + endHandlerSize) / 2; + position = { + x: position.x + startHandlerSize / 2, + y: position.y + }; + } else { + height -= (startHandlerSize + endHandlerSize) / 2; + position = { + x: position.x, + y: position.y + startHandlerSize / 2 + }; + } + } + + // stroke 需计入宽高, 否则dataZoom在画布边缘会被裁剪lineWidth / 2 + height += backgroundStyle.lineWidth / 2 ?? 1; + width += backgroundStyle.lineWidth / 2 ?? 1; + + this._layoutCacheFromConfig = { + position, + width, + height + }; + return this._layoutCacheFromConfig; + } + + /** state 边界处理 */ + private _setStateAttr(start: number, end: number, shouldRender: boolean) { + const { zoomLock = false, minSpan = 0, maxSpan = 1 } = this.attribute as DataZoomAttributes; + const span = end - start; + if (span !== this._spanCache && (zoomLock || span < minSpan || span > maxSpan)) { + return; + } + this._spanCache = span; + this.state.start = start; + this.state.end = end; + shouldRender && this.setAttributes({ start, end }); + } + + /** 事件系统坐标转换为stage坐标 */ + private _eventPosToStagePos(e: FederatedPointerEvent) { + // updateSpec过程中交互的话, stage可能为空 + return this.stage?.eventPointTransform(e) ?? { x: 0, y: 0 }; + } + + private _clearDragEvents() { + const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; + const triggers = getEndTriggersOfDrag(); + + evtTarget.removeEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); + triggers.forEach((trigger: string) => { + evtTarget.removeEventListener(trigger, this._onHandlerPointerUp); + }); + + (this as unknown as IGroup).removeEventListener('pointermove', this._onHandlerPointerMove, { + capture: true + }); + } + + protected render() { + this._layoutCacheFromConfig = null; + const { + // start, + // end, + orient, + backgroundStyle, + backgroundChartStyle = {}, + selectedBackgroundStyle = {}, + selectedBackgroundChartStyle = {}, + middleHandlerStyle = {}, + startHandlerStyle = {}, + endHandlerStyle = {}, + brushSelect, + zoomLock + } = this.attribute as DataZoomAttributes; + const { start, end } = this.state; + + // console.log('state, start, end', start, end); + + const { position, width, height } = this._getLayoutAttrFromConfig(); + const startHandlerMinSize = startHandlerStyle.triggerMinSize ?? 40; + const endHandlerMinSize = endHandlerStyle.triggerMinSize ?? 40; + const group = (this as unknown as IGroup).createOrUpdateChild('dataZoom-container', {}, 'group') as IGroup; + this._container = group; + this._background = group.createOrUpdateChild( + 'background', + { + x: position.x, + y: position.y, + width, + height, + cursor: brushSelect ? 'crosshair' : 'auto', + ...backgroundStyle, + pickable: zoomLock ? false : (backgroundStyle.pickable ?? true) + }, + 'rect' + ) as IRect; + + /** 背景图表 */ + backgroundChartStyle.line?.visible && this._setPreviewAttributes('line', group); + backgroundChartStyle.area?.visible && this._setPreviewAttributes('area', group); + + /** drag mask */ + brushSelect && this._renderDragMask(); + + /** 选中背景 */ + if (this._isHorizontal) { + // 选中部分 + this._selectedBackground = group.createOrUpdateChild( + 'selectedBackground', + { + x: position.x + start * width, + y: position.y, + width: (end - start) * width, + height: height, + cursor: brushSelect ? 'crosshair' : 'move', + ...selectedBackgroundStyle, + pickable: zoomLock ? false : ((selectedBackgroundChartStyle as any).pickable ?? true) + }, + 'rect' + ) as IRect; + } else { + // 选中部分 + this._selectedBackground = group.createOrUpdateChild( + 'selectedBackground', + { + x: position.x, + y: position.y + start * height, + width, + height: (end - start) * height, + cursor: brushSelect ? 'crosshair' : 'move', + ...selectedBackgroundStyle, + pickable: zoomLock ? false : (selectedBackgroundStyle.pickable ?? true) + }, + 'rect' + ) as IRect; + } + + /** 选中的背景图表 */ + selectedBackgroundChartStyle.line?.visible && this._setSelectedPreviewAttributes('line', group); + selectedBackgroundChartStyle.area?.visible && this._setSelectedPreviewAttributes('area', group); + + /** 左右 和 中间手柄 */ + if (this._isHorizontal) { + if (middleHandlerStyle.visible) { + const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; + this._middleHandlerRect = group.createOrUpdateChild( + 'middleHandlerRect', + { + x: position.x + start * width, + y: position.y - middleHandlerBackgroundSize, + width: (end - start) * width, + height: middleHandlerBackgroundSize, + ...middleHandlerStyle.background?.style, + pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) + }, + 'rect' + ) as IRect; + this._middleHandlerSymbol = group.createOrUpdateChild( + 'middleHandlerSymbol', + { + x: position.x + ((start + end) / 2) * width, + y: position.y - middleHandlerBackgroundSize / 2, + strokeBoundsBuffer: 0, + angle: 0, + symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', + ...middleHandlerStyle.icon, + pickable: zoomLock ? false : (middleHandlerStyle.icon.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + } + this._startHandler = group.createOrUpdateChild( + 'startHandler', + { + x: position.x + start * width, + y: position.y + height / 2, + size: height, + symbolType: startHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + ...startHandlerStyle, + pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + this._endHandler = group.createOrUpdateChild( + 'endHandler', + { + x: position.x + end * width, + y: position.y + height / 2, + size: height, + symbolType: endHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + ...endHandlerStyle, + pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + // 透明mask构造热区, 热区大小配置来自handler bounds + const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); + const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); + const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); + const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); + + this._startHandlerMask = group.createOrUpdateChild( + 'startHandlerMask', + { + x: position.x + start * width - startHandlerWidth / 2, + y: position.y + height / 2 - startHandlerHeight / 2, + width: startHandlerWidth, + height: startHandlerHeight, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + this._endHandlerMask = group.createOrUpdateChild( + 'endHandlerMask', + { + x: position.x + end * width - endHandlerWidth / 2, + y: position.y + height / 2 - endHandlerHeight / 2, + width: endHandlerWidth, + height: endHandlerHeight, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + } else { + if (middleHandlerStyle.visible) { + const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; + + this._middleHandlerRect = group.createOrUpdateChild( + 'middleHandlerRect', + { + x: orient === 'left' ? position.x - middleHandlerBackgroundSize : position.x + width, + y: position.y + start * height, + width: middleHandlerBackgroundSize, + height: (end - start) * height, + ...middleHandlerStyle.background?.style, + pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) + }, + 'rect' + ) as IRect; + this._middleHandlerSymbol = group.createOrUpdateChild( + 'middleHandlerSymbol', + { + x: + orient === 'left' + ? position.x - middleHandlerBackgroundSize / 2 + : position.x + width + middleHandlerBackgroundSize / 2, + y: position.y + ((start + end) / 2) * height, + // size: height, + angle: 90 * (Math.PI / 180), + symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', + strokeBoundsBuffer: 0, + ...middleHandlerStyle.icon, + pickable: zoomLock ? false : (middleHandlerStyle.icon?.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + } + this._startHandler = group.createOrUpdateChild( + 'startHandler', + { + x: position.x + width / 2, + y: position.y + start * height, + size: width, + symbolType: startHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + ...startHandlerStyle, + pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + this._endHandler = group.createOrUpdateChild( + 'endHandler', + { + x: position.x + width / 2, + y: position.y + end * height, + size: width, + symbolType: endHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + ...endHandlerStyle, + pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + // 透明mask构造热区, 热区大小配置来自handler bounds + const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); + const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); + const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); + const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); + + this._startHandlerMask = group.createOrUpdateChild( + 'startHandlerMask', + { + x: position.x + width / 2 + startHandlerWidth / 2, + y: position.y + start * height - startHandlerHeight / 2, + width: endHandlerHeight, + height: endHandlerWidth, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + this._endHandlerMask = group.createOrUpdateChild( + 'endHandlerMask', + { + x: position.x + width / 2 + endHandlerWidth / 2, + y: position.y + end * height - endHandlerHeight / 2, + width: endHandlerHeight, + height: endHandlerWidth, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + } + + /** 左右文字 */ + if (this._showText) { + this._renderText(); + } + } + + private _computeBasePoints() { + const { orient } = this.attribute as DataZoomAttributes; + const { position, width, height } = this._getLayoutAttrFromConfig(); + let basePointStart: any; + let basePointEnd: any; + if (this._isHorizontal) { + basePointStart = [ + { + x: position.x, + y: position.y + height + } + ]; + basePointEnd = [ + { + x: position.x + width, + y: position.y + height + } + ]; + } else if (orient === 'left') { + basePointStart = [ + { + x: position.x + width, + y: position.y + } + ]; + basePointEnd = [ + { + x: position.x + width, + y: position.y + height + } + ]; + } else { + basePointStart = [ + { + x: position.x, + y: position.y + height + } + ]; + basePointEnd = [ + { + x: position.x, + y: position.y + } + ]; + } + return { + basePointStart, + basePointEnd + }; + } + + private _simplifyPoints(points: IPointLike[]) { + // 采样压缩率策略: 如果没做任何配置, 那么限制在niceCount内, 如果做了配置, 则按照配置计算 + const niceCount = 10000; // 经验值 + if (points.length > niceCount) { + const tolerance = this.attribute.tolerance ?? this._previewData.length / niceCount; + return flatten_simplify(points, tolerance, false); + } + return points; + } + + private _getPreviewLinePoints() { + let previewPoints = this._previewData.map(d => { + return { + x: this._previewPointsX && this._previewPointsX(d), + y: this._previewPointsY && this._previewPointsY(d) + }; + }); + // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 + if (previewPoints.length === 0) { + return previewPoints; + } + + // 采样 + previewPoints = this._simplifyPoints(previewPoints); + + const { basePointStart, basePointEnd } = this._computeBasePoints(); + return basePointStart.concat(previewPoints).concat(basePointEnd); + } + + private _getPreviewAreaPoints() { + let previewPoints: IPointLike[] = this._previewData.map(d => { + return { + x: this._previewPointsX && this._previewPointsX(d), + y: this._previewPointsY && this._previewPointsY(d), + x1: this._previewPointsX1 && this._previewPointsX1(d), + y1: this._previewPointsY1 && this._previewPointsY1(d) + }; + }); + // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 + if (previewPoints.length === 0) { + return previewPoints; + } + + // 采样 + previewPoints = this._simplifyPoints(previewPoints); + + const { basePointStart, basePointEnd } = this._computeBasePoints(); + return basePointStart.concat(previewPoints).concat(basePointEnd); + } + + /** 使用callback绘制背景图表 (数据和数据映射从外部传进来) */ + private _setPreviewAttributes(type: 'line' | 'area', group: IGroup) { + if (!this._previewGroup) { + this._previewGroup = group.createOrUpdateChild('previewGroup', { pickable: false }, 'group') as IGroup; + } + if (type === 'line') { + this._previewLine = this._previewGroup.createOrUpdateChild('previewLine', {}, 'line') as ILine; + } else { + this._previewArea = this._previewGroup.createOrUpdateChild( + 'previewArea', + { curveType: 'basis' }, + 'area' + ) as IArea; + } + + const { backgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + + type === 'line' && + this._previewLine.setAttributes({ + points: this._getPreviewLinePoints(), + curveType: 'basis', + pickable: false, + ...backgroundChartStyle.line + }); + type === 'area' && + this._previewArea.setAttributes({ + points: this._getPreviewAreaPoints(), + curveType: 'basis', + pickable: false, + ...backgroundChartStyle.area + }); + } + + /** 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) */ + private _setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { + if (!this._selectedPreviewGroupClip) { + this._selectedPreviewGroupClip = group.createOrUpdateChild( + 'selectedPreviewGroupClip', + { pickable: false }, + 'group' + ) as IGroup; + this._selectedPreviewGroup = this._selectedPreviewGroupClip.createOrUpdateChild( + 'selectedPreviewGroup', + {}, + 'group' + ) as IGroup; + } + + if (type === 'line') { + this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewLine', + {}, + 'line' + ) as ILine; + } else { + this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewArea', + { curveType: 'basis' }, + 'area' + ) as IArea; + } + + const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + + const { start, end } = this.state; + const { position, width, height } = this._getLayoutAttrFromConfig(); + this._selectedPreviewGroupClip.setAttributes({ + x: this._isHorizontal ? position.x + start * width : position.x, + y: this._isHorizontal ? position.y : position.y + start * height, + width: this._isHorizontal ? (end - start) * width : width, + height: this._isHorizontal ? height : (end - start) * height, + clip: true, + pickable: false + } as any); + this._selectedPreviewGroup.setAttributes({ + x: -(this._isHorizontal ? position.x + start * width : position.x), + y: -(this._isHorizontal ? position.y : position.y + start * height), + width: this._isHorizontal ? (end - start) * width : width, + height: this._isHorizontal ? height : (end - start) * height, + pickable: false + } as any); + type === 'line' && + this._selectedPreviewLine.setAttributes({ + points: this._getPreviewLinePoints(), + curveType: 'basis', + pickable: false, + ...selectedBackgroundChartStyle.line + }); + type === 'area' && + this._selectedPreviewArea.setAttributes({ + points: this._getPreviewAreaPoints(), + curveType: 'basis', + pickable: false, + ...selectedBackgroundChartStyle.area + }); + } + + private _maybeAddLabel(container: IGroup, attributes: TagAttributes, name: string): Tag { + let labelShape = (this as unknown as IGroup).find(node => node.name === name, true) as unknown as Tag; + if (labelShape) { + labelShape.setAttributes(attributes); + } else { + labelShape = new Tag(attributes); + labelShape.name = name; + } + + container.add(labelShape as unknown as INode); + return labelShape; + } + + /** 外部重置组件的起始状态 */ + setStartAndEnd(start?: number, end?: number) { + const { start: startAttr, end: endAttr } = this.attribute as DataZoomAttributes; + if (isValid(start) && isValid(end) && (start !== this.state.start || end !== this.state.end)) { + this.state.start = start; + this.state.end = end; + if (startAttr !== this.state.start || endAttr !== this.state.end) { + this._setStateAttr(start, end, true); + this._dispatchEvent('change', { + start, + end, + tag: this._activeTag + }); + } + } + } + + /** 外部更新背景图表的数据 */ + setPreviewData(data: any[]) { + this._previewData = data; + } + + /** 外部更新手柄文字 */ + setText(text: string, tag: 'start' | 'end') { + if (tag === 'start') { + this._startText.setAttribute('text', text); + } else { + this._endText.setAttribute('text', text); + } + } + + /** 外部获取起始点数据值 */ + getStartValue() { + return this._startValue; + } + + getEndTextValue() { + return this._endValue; + } + + getMiddleHandlerSize() { + const { middleHandlerStyle = {} } = this.attribute as DataZoomAttributes; + const middleHandlerRectSize = middleHandlerStyle.background?.size ?? 10; + const middleHandlerSymbolSize = middleHandlerStyle.icon?.size ?? 10; + return Math.max(middleHandlerRectSize, ...array(middleHandlerSymbolSize)); + } + + /** 外部传入数据映射 */ + setPreviewPointsX(callback: (d: any) => number) { + isFunction(callback) && (this._previewPointsX = callback); + } + setPreviewPointsY(callback: (d: any) => number) { + isFunction(callback) && (this._previewPointsY = callback); + } + setPreviewPointsX1(callback: (d: any) => number) { + isFunction(callback) && (this._previewPointsX1 = callback); + } + setPreviewPointsY1(callback: (d: any) => number) { + isFunction(callback) && (this._previewPointsY1 = callback); + } + setStatePointToData(callback: (state: number) => any) { + isFunction(callback) && (this._statePointToData = callback); + } + + release(all?: boolean): void { + /** + * 浏览器上的事件必须解绑,防止内存泄漏,场景树上的事件会自动解绑 + */ + super.release(all); + (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { + passive: false + }); + this._clearDragEvents(); + } +} diff --git a/packages/vrender-components/src/data-zoom/data-zoom-4.ts b/packages/vrender-components/src/data-zoom/data-zoom-4.ts new file mode 100644 index 000000000..cda4eeafd --- /dev/null +++ b/packages/vrender-components/src/data-zoom/data-zoom-4.ts @@ -0,0 +1,1272 @@ +import type { FederatedPointerEvent, IArea, IGroup, ILine, IRect, ISymbol, INode } from '@visactor/vrender-core'; +// eslint-disable-next-line no-duplicate-imports +import { flatten_simplify, vglobal } from '@visactor/vrender-core'; +import type { IBoundsLike, IPointLike } from '@visactor/vutils'; +// eslint-disable-next-line no-duplicate-imports +import { Bounds, array, clamp, debounce, isFunction, isValid, merge, throttle } from '@visactor/vutils'; +import { AbstractComponent } from '../core/base'; +import type { TagAttributes } from '../tag'; +// eslint-disable-next-line no-duplicate-imports +import { Tag } from '../tag'; +import { DEFAULT_DATA_ZOOM_ATTRIBUTES, DEFAULT_HANDLER_ATTR_MAP } from './config'; +import { DataZoomActiveTag } from './type'; +// eslint-disable-next-line no-duplicate-imports +import type { DataZoomAttributes } from './type'; +import type { ComponentOptions } from '../interface'; +import { loadDataZoomComponent } from './register'; +import { getEndTriggersOfDrag } from '../util/event'; + +const delayMap = { + debounce: debounce, + throttle: throttle +}; +loadDataZoomComponent(); +export class DataZoom extends AbstractComponent> { + name = 'dataZoom'; + static defaultAttributes = DEFAULT_DATA_ZOOM_ATTRIBUTES; + + private _isHorizontal: boolean; + + private _background!: IRect; + + private _container!: IGroup; + + /** 手柄 */ + private _startHandlerMask!: IRect; + private _startHandler!: ISymbol; + private _middleHandlerSymbol!: ISymbol; + private _middleHandlerRect!: IRect; + private _endHandlerMask!: IRect; + private _endHandler!: ISymbol; + private _selectedBackground!: IRect; + private _dragMask!: IRect; + private _startText!: Tag; + private _endText!: Tag; + private _startValue!: string | number; + private _endValue!: string | number; + private _showText!: boolean; + + /** 背景图表 */ + private _previewData: any[] = []; + private _previewGroup!: IGroup; + private _previewLine!: ILine; + private _previewArea!: IArea; + private _selectedPreviewGroupClip!: IGroup; + private _selectedPreviewGroup!: IGroup; + private _selectedPreviewLine!: ILine; + private _selectedPreviewArea!: IArea; + + /** 交互状态 */ + protected _activeTag!: DataZoomActiveTag; + protected _activeItem!: any; + protected _activeState = false; + protected _activeCache: { + startPos: IPointLike; + lastPos: IPointLike; + } = { + startPos: { x: 0, y: 0 }, + lastPos: { x: 0, y: 0 } + }; + protected _layoutCache: { + attPos: 'x' | 'y'; + attSize: 'width' | 'height'; + max: number; + } = { + attPos: 'x', + attSize: 'width', + max: 0 + }; + /** 起始状态 */ + readonly state = { + start: 0, + end: 1 + }; + protected _spanCache: number; + + /** 回调函数 */ + private _previewPointsX!: (datum: any) => number; + private _previewPointsY!: (datum: any) => number; + private _previewPointsX1!: (datum: any) => number; + private _previewPointsY1!: (datum: any) => number; + private _statePointToData: (state: number) => any = state => state; + private _layoutAttrFromConfig: any; // 用于缓存 + + setPropsFromAttrs() { + const { start, end, orient, previewData, previewPointsX, previewPointsY, previewPointsX1, previewPointsY1 } = this + .attribute as DataZoomAttributes; + start && (this.state.start = start); + end && (this.state.end = end); + const { width, height } = this.getLayoutAttrFromConfig(); + this._spanCache = this.state.end - this.state.start; + this._isHorizontal = orient === 'top' || orient === 'bottom'; + this._layoutCache.max = this._isHorizontal ? width : height; + this._layoutCache.attPos = this._isHorizontal ? 'x' : 'y'; + this._layoutCache.attSize = this._isHorizontal ? 'width' : 'height'; + previewData && (this._previewData = previewData); + isFunction(previewPointsX) && (this._previewPointsX = previewPointsX); + isFunction(previewPointsY) && (this._previewPointsY = previewPointsY); + isFunction(previewPointsX1) && (this._previewPointsX1 = previewPointsX1); + isFunction(previewPointsY1) && (this._previewPointsY1 = previewPointsY1); + } + + constructor(attributes: DataZoomAttributes, options?: ComponentOptions) { + super(options?.skipDefault ? attributes : merge({}, DataZoom.defaultAttributes, attributes)); + const { position, showDetail } = attributes; + // 这些属性在事件交互过程中会改变,所以不能在setAttrs里面动态更改 + this._activeCache.startPos = position; + this._activeCache.lastPos = position; + if (showDetail === 'auto') { + this._showText = false as boolean; + } else { + this._showText = showDetail as boolean; + } + this.setPropsFromAttrs(); + } + + setAttributes(params: Partial>, forceUpdateTag?: boolean): void { + super.setAttributes(params, forceUpdateTag); + this.setPropsFromAttrs(); + } + + protected bindEvents(): void { + if (this.attribute.disableTriggerEvent) { + this.setAttribute('childrenPickable', false); + return; + } + const { showDetail, brushSelect } = this.attribute as DataZoomAttributes; + // 拖拽开始 + if (this._startHandlerMask) { + this._startHandlerMask.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'start') as unknown as EventListener + ); + } + if (this._endHandlerMask) { + this._endHandlerMask.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'end') as unknown as EventListener + ); + } + if (this._middleHandlerSymbol) { + this._middleHandlerSymbol.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleSymbol') as unknown as EventListener + ); + } + if (this._middleHandlerRect) { + this._middleHandlerRect.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleRect') as unknown as EventListener + ); + } + + const selectedTag = brushSelect ? 'background' : 'middleRect'; + if (this._selectedBackground) { + this._selectedBackground.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener + ); + } + if (brushSelect && this._background) { + this._background.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener + ); + } + if (brushSelect && this._previewGroup) { + this._previewGroup.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener + ); + } + if (this._selectedPreviewGroup) { + this._selectedPreviewGroup.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener + ); + } + + // hover + if (showDetail === 'auto') { + (this as unknown as IGroup).addEventListener('pointerenter', this._onHandlerPointerEnter as EventListener); + (this as unknown as IGroup).addEventListener('pointerleave', this._onHandlerPointerLeave as EventListener); + } + + (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { + passive: false + }); + } + private _handleTouchMove = (e: TouchEvent) => { + if (this._activeState) { + /** + * https://developer.mozilla.org/zh-CN/docs/Web/CSS/overscroll-behavior + * 由于浏览器的overscroll-behavior属性,需要在move的时候阻止浏览器默认行为,否则会因为浏览器检测到scroll行为,阻止pointer事件, + * 抛出pointercancel事件,导致拖拽行为中断。 + */ + e.preventDefault(); + } + }; + + /** dragMask size边界处理 */ + protected dragMaskSize() { + const { position } = this.attribute as DataZoomAttributes; + const { attPos, max } = this._layoutCache; + if (this._activeCache.lastPos[attPos] - position[attPos] > max) { + return max + position[attPos] - this._activeCache.startPos[attPos]; + } else if (this._activeCache.lastPos[attPos] - position[attPos] < 0) { + return position[attPos] - this._activeCache.startPos[attPos]; + } + return this._activeCache.lastPos[attPos] - this._activeCache.startPos[attPos]; + } + + /** state 边界处理 */ + protected setStateAttr(start: number, end: number, shouldRender: boolean) { + const { zoomLock = false, minSpan = 0, maxSpan = 1 } = this.attribute as DataZoomAttributes; + const span = end - start; + if (span !== this._spanCache && (zoomLock || span < minSpan || span > maxSpan)) { + return; + } + this._spanCache = span; + this.state.start = start; + this.state.end = end; + shouldRender && this.setAttributes({ start, end }); + } + + /** 事件系统坐标转换为stage坐标 */ + protected eventPosToStagePos(e: FederatedPointerEvent) { + // updateSpec过程中交互的话, stage可能为空 + return this.stage?.eventPointTransform(e) ?? { x: 0, y: 0 }; + } + + private _clearDragEvents() { + const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; + const triggers = getEndTriggersOfDrag(); + + evtTarget.removeEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); + triggers.forEach((trigger: string) => { + evtTarget.removeEventListener(trigger, this._onHandlerPointerUp); + }); + + (this as unknown as IGroup).removeEventListener('pointermove', this._onHandlerPointerMove, { + capture: true + }); + } + + /** + * 拖拽开始事件 + * @description 开启activeState + 通过tag判断事件在哪个元素上触发 并 更新交互坐标 + */ + private _onHandlerPointerDown = (e: FederatedPointerEvent, tag: string) => { + // 清除之前的事件,防止没有被清除掉 + this._clearDragEvents(); + if (tag === 'start') { + this._activeTag = DataZoomActiveTag.startHandler; + this._activeItem = this._startHandlerMask; + } else if (tag === 'end') { + this._activeTag = DataZoomActiveTag.endHandler; + this._activeItem = this._endHandlerMask; + } else if (tag === 'middleRect') { + this._activeTag = DataZoomActiveTag.middleHandler; + this._activeItem = this._middleHandlerRect; + } else if (tag === 'middleSymbol') { + this._activeTag = DataZoomActiveTag.middleHandler; + this._activeItem = this._middleHandlerSymbol; + } else if (tag === 'background') { + this._activeTag = DataZoomActiveTag.background; + this._activeItem = this._background; + } + this._activeState = true; + this._activeCache.startPos = this.eventPosToStagePos(e); + this._activeCache.lastPos = this.eventPosToStagePos(e); + const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; + const triggers = getEndTriggersOfDrag(); + + /** + * move的时候,需要通过 capture: true,能够在捕获截断被拦截, + */ + evtTarget.addEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); + (this as unknown as IGroup).addEventListener('pointermove', this._onHandlerPointerMove, { + capture: true + }); + + triggers.forEach((trigger: string) => { + evtTarget.addEventListener(trigger, this._onHandlerPointerUp); + }); + }; + + /** + * 拖拽进行事件 + * @description 分为以下四种情况: + * 1. 在背景 or 背景图表上拖拽 (activeTag === 'background'): 改变lastPos => dragMask的宽 or 高被改变 + * 2. 在middleHandler上拖拽 (activeTag === 'middleHandler'): 改变lastPos、start & end + 边界处理: 防止拖拽结果超出背景 => 所有handler的位置被改变 + * 3. 在startHandler上拖拽 (activeTag === 'startHandler'): 改变lastPos、start & end + 边界处理: startHandler和endHandler交换 => 所有handler的位置被改变 + * 4. 在endHandler上拖拽,同上 + */ + private _pointerMove = (e: FederatedPointerEvent) => { + const { start: startAttr, end: endAttr, brushSelect, realTime = true } = this.attribute as DataZoomAttributes; + const pos = this.eventPosToStagePos(e); + const { attPos, max } = this._layoutCache; + const dis = (pos[attPos] - this._activeCache.lastPos[attPos]) / max; + + let { start, end } = this.state; + // this._activeState= false; + if (this._activeState) { + // if (this._activeTag === DataZoomActiveTag.background) { + // } else + if (this._activeTag === DataZoomActiveTag.middleHandler) { + this.moveZoomWithMiddle((this.state.start + this.state.end) / 2 + dis); + } else if (this._activeTag === DataZoomActiveTag.startHandler) { + if (start + dis > end) { + start = end; + end = start + dis; + this._activeTag = DataZoomActiveTag.endHandler; + } else { + start = start + dis; + } + } else if (this._activeTag === DataZoomActiveTag.endHandler) { + if (end + dis < start) { + end = start; + start = end + dis; + this._activeTag = DataZoomActiveTag.startHandler; + } else { + end = end + dis; + } + } + this._activeCache.lastPos = pos; + brushSelect && this.renderDragMask(); + } + start = Math.min(Math.max(start, 0), 1); + end = Math.min(Math.max(end, 0), 1); + + // 避免attributes相同时, 重复渲染 + if (startAttr !== start || endAttr !== end) { + this.setStateAttr(start, end, true); + if (realTime) { + this._dispatchEvent('change', { + start, + end, + tag: this._activeTag + }); + } + } + }; + private _onHandlerPointerMove = + this.attribute.delayTime === 0 + ? this._pointerMove + : delayMap[this.attribute.delayType](this._pointerMove, this.attribute.delayTime); + + /** + * 拖拽结束事件 + * @description 关闭activeState + 边界情况处理: 防止拖拽后start和end过近 + */ + private _onHandlerPointerUp = (e: FederatedPointerEvent) => { + const { start, end, brushSelect, realTime = true } = this.attribute as DataZoomAttributes; + if (this._activeState) { + if (this._activeTag === DataZoomActiveTag.background) { + const pos = this.eventPosToStagePos(e); + this.backgroundDragZoom(this._activeCache.startPos, pos); + } + } + this._activeState = false; + + // dragMask不依赖于state更新 + brushSelect && this.renderDragMask(); + + // 此次dispatch不能被省略 + // 因为pointermove时, 已经将状态更新至最新, 所以在pointerup时, 必定start = state.start & end = state.end + // 而realTime = false时, 需要依赖这次dispatch来更新图表图元 + this._dispatchEvent('change', { + start: this.state.start, + end: this.state.end, + tag: this._activeTag + }); + this._clearDragEvents(); + }; + + /** + * 鼠标进入事件 + * @description 鼠标进入选中部分出现start和end文字 + */ + private _onHandlerPointerEnter(e: FederatedPointerEvent) { + this._showText = true; + this.renderText(); + } + + /** + * 鼠标移出事件 + * @description 鼠标移出选中部分不出现start和end文字 + */ + private _onHandlerPointerLeave(e: FederatedPointerEvent) { + this._showText = false; + this.renderText(); + } + + protected backgroundDragZoom(startPos: IPointLike, endPos: IPointLike) { + const { attPos, max } = this._layoutCache; + const { position } = this.attribute as DataZoomAttributes; + const startPosInComponent = startPos[attPos] - position[attPos]; + const endPosInComponent = endPos[attPos] - position[attPos]; + const start = Math.min(Math.max(Math.min(startPosInComponent, endPosInComponent) / max, 0), 1); + const end = Math.min(Math.max(Math.max(startPosInComponent, endPosInComponent) / max, 0), 1); + if (Math.abs(start - end) < 0.01) { + this.moveZoomWithMiddle(start); + } else { + this.setStateAttr(start, end, false); + } + } + + protected moveZoomWithMiddle(middle: number) { + const currentMiddle = (this.state.start + this.state.end) / 2; + let offset = middle - currentMiddle; + // 拖拽middleHandler时,限制在background范围内 + if (offset === 0) { + return; + } else if (offset > 0) { + if (this.state.end + offset > 1) { + offset = 1 - this.state.end; + } + } else if (offset < 0) { + if (this.state.start + offset < 0) { + offset = -this.state.start; + } + } + this.setStateAttr(this.state.start + offset, this.state.end + offset, false); + } + + protected renderDragMask() { + const { dragMaskStyle } = this.attribute as DataZoomAttributes; + const { position, width, height } = this.getLayoutAttrFromConfig(); + // drag部分 + if (this._isHorizontal) { + this._dragMask = this._container.createOrUpdateChild( + 'dragMask', + { + x: clamp( + this.dragMaskSize() < 0 ? this._activeCache.lastPos.x : this._activeCache.startPos.x, + position.x, + position.x + width + ), + y: position.y, + width: + (this._activeState && this._activeTag === DataZoomActiveTag.background && Math.abs(this.dragMaskSize())) || + 0, + height, + ...dragMaskStyle + }, + 'rect' + ) as IRect; + } else { + this._dragMask = this._container.createOrUpdateChild( + 'dragMask', + { + x: position.x, + y: clamp( + this.dragMaskSize() < 0 ? this._activeCache.lastPos.y : this._activeCache.startPos.y, + position.y, + position.y + height + ), + width, + height: + (this._activeState && this._activeTag === DataZoomActiveTag.background && Math.abs(this.dragMaskSize())) || + 0, + ...dragMaskStyle + }, + 'rect' + ) as IRect; + } + } + + /** + * 判断文字是否超出datazoom范围 + */ + protected isTextOverflow(componentBoundsLike: IBoundsLike, textBounds: IBoundsLike | null, layout: 'start' | 'end') { + if (!textBounds) { + return false; + } + if (this._isHorizontal) { + if (layout === 'start') { + if (textBounds.x1 < componentBoundsLike.x1) { + return true; + } + } else { + if (textBounds.x2 > componentBoundsLike.x2) { + return true; + } + } + } else { + if (layout === 'start') { + if (textBounds.y1 < componentBoundsLike.y1) { + return true; + } + } else { + if (textBounds.y2 > componentBoundsLike.y2) { + return true; + } + } + } + return false; + } + + protected setTextAttr(startTextBounds: IBoundsLike, endTextBounds: IBoundsLike) { + const { startTextStyle, endTextStyle } = this.attribute as DataZoomAttributes; + const { formatMethod: startTextFormat, ...restStartTextStyle } = startTextStyle; + const { formatMethod: endTextFormat, ...restEndTextStyle } = endTextStyle; + const { start, end } = this.state; + this._startValue = this._statePointToData(start); + this._endValue = this._statePointToData(end); + const { position, width, height } = this.getLayoutAttrFromConfig(); + + const startTextValue = startTextFormat ? startTextFormat(this._startValue) : this._startValue; + const endTextValue = endTextFormat ? endTextFormat(this._endValue) : this._endValue; + const componentBoundsLike = { + x1: position.x, + y1: position.y, + x2: position.x + width, + y2: position.y + height + }; + let startTextPosition: IPointLike; + let endTextPosition: IPointLike; + let startTextAlignStyle: any; + let endTextAlignStyle: any; + if (this._isHorizontal) { + startTextPosition = { + x: position.x + start * width, + y: position.y + height / 2 + }; + endTextPosition = { + x: position.x + end * width, + y: position.y + height / 2 + }; + startTextAlignStyle = { + textAlign: this.isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'left' : 'right', + textBaseline: restStartTextStyle?.textStyle?.textBaseline ?? 'middle' + }; + endTextAlignStyle = { + textAlign: this.isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'right' : 'left', + textBaseline: restEndTextStyle?.textStyle?.textBaseline ?? 'middle' + }; + } else { + startTextPosition = { + x: position.x + width / 2, + y: position.y + start * height + }; + endTextPosition = { + x: position.x + width / 2, + y: position.y + end * height + }; + startTextAlignStyle = { + textAlign: restStartTextStyle?.textStyle?.textAlign ?? 'center', + textBaseline: this.isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'top' : 'bottom' + }; + endTextAlignStyle = { + textAlign: restEndTextStyle?.textStyle?.textAlign ?? 'center', + textBaseline: this.isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'bottom' : 'top' + }; + } + + this._startText = this.maybeAddLabel( + this._container, + merge({}, restStartTextStyle, { + text: startTextValue, + x: startTextPosition.x, + y: startTextPosition.y, + visible: this._showText, + pickable: false, + childrenPickable: false, + textStyle: startTextAlignStyle + }), + `data-zoom-start-text-${position}` + ); + this._endText = this.maybeAddLabel( + this._container, + merge({}, restEndTextStyle, { + text: endTextValue, + x: endTextPosition.x, + y: endTextPosition.y, + visible: this._showText, + pickable: false, + childrenPickable: false, + textStyle: endTextAlignStyle + }), + `data-zoom-end-text-${position}` + ); + } + + protected renderText() { + let startTextBounds: IBoundsLike | null = null; + let endTextBounds: IBoundsLike | null = null; + + // 第一次绘制 + this.setTextAttr(startTextBounds, endTextBounds); + // 得到bounds + startTextBounds = this._startText.AABBBounds; + endTextBounds = this._endText.AABBBounds; + + // 第二次绘制: 将text限制在组件bounds内 + this.setTextAttr(startTextBounds, endTextBounds); + // 得到bounds + startTextBounds = this._startText.AABBBounds; + endTextBounds = this._endText.AABBBounds; + const { x1, x2, y1, y2 } = startTextBounds; + const { dx: startTextDx = 0, dy: startTextDy = 0 } = this.attribute.startTextStyle; + + // 第三次绘制: 避免startText和endText重叠, 如果重叠了, 对startText做位置调整(考虑到调整的最小化,只单独调整startText而不调整endText) + if (new Bounds().set(x1, y1, x2, y2).intersects(endTextBounds)) { + const direction = this.attribute.orient === 'bottom' || this.attribute.orient === 'right' ? -1 : 1; + if (this._isHorizontal) { + this._startText.setAttribute('dy', startTextDy + direction * Math.abs(endTextBounds.y1 - endTextBounds.y2)); + } else { + this._startText.setAttribute('dx', startTextDx + direction * Math.abs(endTextBounds.x1 - endTextBounds.x2)); + } + } else { + if (this._isHorizontal) { + this._startText.setAttribute('dy', startTextDy); + } else { + this._startText.setAttribute('dx', startTextDx); + } + } + } + + /** + * 获取背景框中的位置和宽高 + * @description 实际绘制的背景框中的高度或宽度 减去 中间手柄的高度或宽度 + */ + protected getLayoutAttrFromConfig() { + if (this._layoutAttrFromConfig) { + return this._layoutAttrFromConfig; + } + const { + position: positionConfig, + size, + orient, + middleHandlerStyle = {}, + startHandlerStyle = {}, + endHandlerStyle = {}, + backgroundStyle = {} + } = this.attribute as DataZoomAttributes; + const { width: widthConfig, height: heightConfig } = size; + const middleHandlerSize = middleHandlerStyle.background?.size ?? 10; + + // 如果middleHandler显示的话,要将其宽高计入datazoom宽高 + let width; + let height; + let position; + if (middleHandlerStyle.visible) { + if (this._isHorizontal) { + width = widthConfig; + height = heightConfig - middleHandlerSize; + position = { + x: positionConfig.x, + y: positionConfig.y + middleHandlerSize + }; + } else { + width = widthConfig - middleHandlerSize; + height = heightConfig; + position = { + x: positionConfig.x + (orient === 'left' ? middleHandlerSize : 0), + y: positionConfig.y + }; + } + } else { + width = widthConfig; + height = heightConfig; + position = positionConfig; + } + + const startHandlerSize = (startHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); + const endHandlerSize = (endHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); + // 如果startHandler显示的话,要将其宽高计入dataZoom宽高 + if (startHandlerStyle.visible) { + if (this._isHorizontal) { + width -= (startHandlerSize + endHandlerSize) / 2; + position = { + x: position.x + startHandlerSize / 2, + y: position.y + }; + } else { + height -= (startHandlerSize + endHandlerSize) / 2; + position = { + x: position.x, + y: position.y + startHandlerSize / 2 + }; + } + } + + // stroke 需计入宽高, 否则dataZoom在画布边缘会被裁剪lineWidth / 2 + height += backgroundStyle.lineWidth / 2 ?? 1; + width += backgroundStyle.lineWidth / 2 ?? 1; + + this._layoutAttrFromConfig = { + position, + width, + height + }; + return this._layoutAttrFromConfig; + } + + protected render() { + this._layoutAttrFromConfig = null; + const { + // start, + // end, + orient, + backgroundStyle, + backgroundChartStyle = {}, + selectedBackgroundStyle = {}, + selectedBackgroundChartStyle = {}, + middleHandlerStyle = {}, + startHandlerStyle = {}, + endHandlerStyle = {}, + brushSelect, + zoomLock + } = this.attribute as DataZoomAttributes; + const { start, end } = this.state; + + const { position, width, height } = this.getLayoutAttrFromConfig(); + const startHandlerMinSize = startHandlerStyle.triggerMinSize ?? 40; + const endHandlerMinSize = endHandlerStyle.triggerMinSize ?? 40; + const group = (this as unknown as IGroup).createOrUpdateChild('dataZoom-container', {}, 'group') as IGroup; + this._container = group; + this._background = group.createOrUpdateChild( + 'background', + { + x: position.x, + y: position.y, + width, + height, + cursor: brushSelect ? 'crosshair' : 'auto', + ...backgroundStyle, + pickable: zoomLock ? false : (backgroundStyle.pickable ?? true) + }, + 'rect' + ) as IRect; + + /** 背景图表 */ + backgroundChartStyle.line?.visible && this.setPreviewAttributes('line', group); + backgroundChartStyle.area?.visible && this.setPreviewAttributes('area', group); + + /** drag mask */ + brushSelect && this.renderDragMask(); + + /** 选中背景 */ + if (this._isHorizontal) { + // 选中部分 + this._selectedBackground = group.createOrUpdateChild( + 'selectedBackground', + { + x: position.x + start * width, + y: position.y, + width: (end - start) * width, + height: height, + cursor: brushSelect ? 'crosshair' : 'move', + ...selectedBackgroundStyle, + pickable: zoomLock ? false : ((selectedBackgroundChartStyle as any).pickable ?? true) + }, + 'rect' + ) as IRect; + } else { + // 选中部分 + this._selectedBackground = group.createOrUpdateChild( + 'selectedBackground', + { + x: position.x, + y: position.y + start * height, + width, + height: (end - start) * height, + cursor: brushSelect ? 'crosshair' : 'move', + ...selectedBackgroundStyle, + pickable: zoomLock ? false : (selectedBackgroundStyle.pickable ?? true) + }, + 'rect' + ) as IRect; + } + + /** 选中的背景图表 */ + selectedBackgroundChartStyle.line?.visible && this.setSelectedPreviewAttributes('line', group); + selectedBackgroundChartStyle.area?.visible && this.setSelectedPreviewAttributes('area', group); + + /** 左右 和 中间手柄 */ + if (this._isHorizontal) { + if (middleHandlerStyle.visible) { + const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; + this._middleHandlerRect = group.createOrUpdateChild( + 'middleHandlerRect', + { + x: position.x + start * width, + y: position.y - middleHandlerBackgroundSize, + width: (end - start) * width, + height: middleHandlerBackgroundSize, + ...middleHandlerStyle.background?.style, + pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) + }, + 'rect' + ) as IRect; + this._middleHandlerSymbol = group.createOrUpdateChild( + 'middleHandlerSymbol', + { + x: position.x + ((start + end) / 2) * width, + y: position.y - middleHandlerBackgroundSize / 2, + strokeBoundsBuffer: 0, + angle: 0, + symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', + ...middleHandlerStyle.icon, + pickable: zoomLock ? false : (middleHandlerStyle.icon.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + } + this._startHandler = group.createOrUpdateChild( + 'startHandler', + { + x: position.x + start * width, + y: position.y + height / 2, + size: height, + symbolType: startHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + ...startHandlerStyle, + pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + this._endHandler = group.createOrUpdateChild( + 'endHandler', + { + x: position.x + end * width, + y: position.y + height / 2, + size: height, + symbolType: endHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + ...endHandlerStyle, + pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + // 透明mask构造热区, 热区大小配置来自handler bounds + const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); + const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); + const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); + const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); + + this._startHandlerMask = group.createOrUpdateChild( + 'startHandlerMask', + { + x: position.x + start * width - startHandlerWidth / 2, + y: position.y + height / 2 - startHandlerHeight / 2, + width: startHandlerWidth, + height: startHandlerHeight, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + this._endHandlerMask = group.createOrUpdateChild( + 'endHandlerMask', + { + x: position.x + end * width - endHandlerWidth / 2, + y: position.y + height / 2 - endHandlerHeight / 2, + width: endHandlerWidth, + height: endHandlerHeight, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + } else { + if (middleHandlerStyle.visible) { + const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; + + this._middleHandlerRect = group.createOrUpdateChild( + 'middleHandlerRect', + { + x: orient === 'left' ? position.x - middleHandlerBackgroundSize : position.x + width, + y: position.y + start * height, + width: middleHandlerBackgroundSize, + height: (end - start) * height, + ...middleHandlerStyle.background?.style, + pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) + }, + 'rect' + ) as IRect; + this._middleHandlerSymbol = group.createOrUpdateChild( + 'middleHandlerSymbol', + { + x: + orient === 'left' + ? position.x - middleHandlerBackgroundSize / 2 + : position.x + width + middleHandlerBackgroundSize / 2, + y: position.y + ((start + end) / 2) * height, + // size: height, + angle: 90 * (Math.PI / 180), + symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', + strokeBoundsBuffer: 0, + ...middleHandlerStyle.icon, + pickable: zoomLock ? false : (middleHandlerStyle.icon?.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + } + this._startHandler = group.createOrUpdateChild( + 'startHandler', + { + x: position.x + width / 2, + y: position.y + start * height, + size: width, + symbolType: startHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + ...startHandlerStyle, + pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + this._endHandler = group.createOrUpdateChild( + 'endHandler', + { + x: position.x + width / 2, + y: position.y + end * height, + size: width, + symbolType: endHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + ...endHandlerStyle, + pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + // 透明mask构造热区, 热区大小配置来自handler bounds + const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); + const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); + const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); + const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); + + this._startHandlerMask = group.createOrUpdateChild( + 'startHandlerMask', + { + x: position.x + width / 2 + startHandlerWidth / 2, + y: position.y + start * height - startHandlerHeight / 2, + width: endHandlerHeight, + height: endHandlerWidth, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + this._endHandlerMask = group.createOrUpdateChild( + 'endHandlerMask', + { + x: position.x + width / 2 + endHandlerWidth / 2, + y: position.y + end * height - endHandlerHeight / 2, + width: endHandlerHeight, + height: endHandlerWidth, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + } + + /** 左右文字 */ + if (this._showText) { + this.renderText(); + } + } + + computeBasePoints() { + const { orient } = this.attribute as DataZoomAttributes; + const { position, width, height } = this.getLayoutAttrFromConfig(); + let basePointStart: any; + let basePointEnd: any; + if (this._isHorizontal) { + basePointStart = [ + { + x: position.x, + y: position.y + height + } + ]; + basePointEnd = [ + { + x: position.x + width, + y: position.y + height + } + ]; + } else if (orient === 'left') { + basePointStart = [ + { + x: position.x + width, + y: position.y + } + ]; + basePointEnd = [ + { + x: position.x + width, + y: position.y + height + } + ]; + } else { + basePointStart = [ + { + x: position.x, + y: position.y + height + } + ]; + basePointEnd = [ + { + x: position.x, + y: position.y + } + ]; + } + return { + basePointStart, + basePointEnd + }; + } + + protected simplifyPoints(points: IPointLike[]) { + // 采样压缩率策略: 如果没做任何配置, 那么限制在niceCount内, 如果做了配置, 则按照配置计算 + const niceCount = 10000; // 经验值 + if (points.length > niceCount) { + const tolerance = this.attribute.tolerance ?? this._previewData.length / niceCount; + return flatten_simplify(points, tolerance, false); + } + return points; + } + + protected getPreviewLinePoints() { + let previewPoints = this._previewData.map(d => { + return { + x: this._previewPointsX && this._previewPointsX(d), + y: this._previewPointsY && this._previewPointsY(d) + }; + }); + // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 + if (previewPoints.length === 0) { + return previewPoints; + } + + // 采样 + previewPoints = this.simplifyPoints(previewPoints); + + const { basePointStart, basePointEnd } = this.computeBasePoints(); + return basePointStart.concat(previewPoints).concat(basePointEnd); + } + + protected getPreviewAreaPoints() { + let previewPoints: IPointLike[] = this._previewData.map(d => { + return { + x: this._previewPointsX && this._previewPointsX(d), + y: this._previewPointsY && this._previewPointsY(d), + x1: this._previewPointsX1 && this._previewPointsX1(d), + y1: this._previewPointsY1 && this._previewPointsY1(d) + }; + }); + // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 + if (previewPoints.length === 0) { + return previewPoints; + } + + // 采样 + previewPoints = this.simplifyPoints(previewPoints); + + const { basePointStart, basePointEnd } = this.computeBasePoints(); + return basePointStart.concat(previewPoints).concat(basePointEnd); + } + + /** 使用callback绘制背景图表 (数据和数据映射从外部传进来) */ + protected setPreviewAttributes(type: 'line' | 'area', group: IGroup) { + if (!this._previewGroup) { + this._previewGroup = group.createOrUpdateChild('previewGroup', { pickable: false }, 'group') as IGroup; + } + if (type === 'line') { + this._previewLine = this._previewGroup.createOrUpdateChild('previewLine', {}, 'line') as ILine; + } else { + this._previewArea = this._previewGroup.createOrUpdateChild( + 'previewArea', + { curveType: 'basis' }, + 'area' + ) as IArea; + } + + const { backgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + + type === 'line' && + this._previewLine.setAttributes({ + points: this.getPreviewLinePoints(), + curveType: 'basis', + pickable: false, + ...backgroundChartStyle.line + }); + type === 'area' && + this._previewArea.setAttributes({ + points: this.getPreviewAreaPoints(), + curveType: 'basis', + pickable: false, + ...backgroundChartStyle.area + }); + } + + /** 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) */ + protected setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { + if (!this._selectedPreviewGroupClip) { + this._selectedPreviewGroupClip = group.createOrUpdateChild( + 'selectedPreviewGroupClip', + { pickable: false }, + 'group' + ) as IGroup; + this._selectedPreviewGroup = this._selectedPreviewGroupClip.createOrUpdateChild( + 'selectedPreviewGroup', + {}, + 'group' + ) as IGroup; + } + + if (type === 'line') { + this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewLine', + {}, + 'line' + ) as ILine; + } else { + this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewArea', + { curveType: 'basis' }, + 'area' + ) as IArea; + } + + const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + + const { start, end } = this.state; + const { position, width, height } = this.getLayoutAttrFromConfig(); + this._selectedPreviewGroupClip.setAttributes({ + x: this._isHorizontal ? position.x + start * width : position.x, + y: this._isHorizontal ? position.y : position.y + start * height, + width: this._isHorizontal ? (end - start) * width : width, + height: this._isHorizontal ? height : (end - start) * height, + clip: true, + pickable: false + } as any); + this._selectedPreviewGroup.setAttributes({ + x: -(this._isHorizontal ? position.x + start * width : position.x), + y: -(this._isHorizontal ? position.y : position.y + start * height), + width: this._isHorizontal ? (end - start) * width : width, + height: this._isHorizontal ? height : (end - start) * height, + pickable: false + } as any); + type === 'line' && + this._selectedPreviewLine.setAttributes({ + points: this.getPreviewLinePoints(), + curveType: 'basis', + pickable: false, + ...selectedBackgroundChartStyle.line + }); + type === 'area' && + this._selectedPreviewArea.setAttributes({ + points: this.getPreviewAreaPoints(), + curveType: 'basis', + pickable: false, + ...selectedBackgroundChartStyle.area + }); + } + + protected maybeAddLabel(container: IGroup, attributes: TagAttributes, name: string): Tag { + let labelShape = (this as unknown as IGroup).find(node => node.name === name, true) as unknown as Tag; + if (labelShape) { + labelShape.setAttributes(attributes); + } else { + labelShape = new Tag(attributes); + labelShape.name = name; + } + + container.add(labelShape as unknown as INode); + return labelShape; + } + + /** 外部重置组件的起始状态 */ + setStartAndEnd(start?: number, end?: number) { + const { start: startAttr, end: endAttr } = this.attribute as DataZoomAttributes; + if (isValid(start) && isValid(end) && (start !== this.state.start || end !== this.state.end)) { + this.state.start = start; + this.state.end = end; + if (startAttr !== this.state.start || endAttr !== this.state.end) { + this.setStateAttr(start, end, true); + this._dispatchEvent('change', { + start, + end, + tag: this._activeTag + }); + } + } + } + + /** 外部更新背景图表的数据 */ + setPreviewData(data: any[]) { + this._previewData = data; + } + + /** 外部更新手柄文字 */ + setText(text: string, tag: 'start' | 'end') { + if (tag === 'start') { + this._startText.setAttribute('text', text); + } else { + this._endText.setAttribute('text', text); + } + } + + /** 外部获取起始点数据值 */ + getStartValue() { + return this._startValue; + } + + getEndTextValue() { + return this._endValue; + } + + getMiddleHandlerSize() { + const { middleHandlerStyle = {} } = this.attribute as DataZoomAttributes; + const middleHandlerRectSize = middleHandlerStyle.background?.size ?? 10; + const middleHandlerSymbolSize = middleHandlerStyle.icon?.size ?? 10; + return Math.max(middleHandlerRectSize, ...array(middleHandlerSymbolSize)); + } + + /** 外部传入数据映射 */ + setPreviewPointsX(callback: (d: any) => number) { + isFunction(callback) && (this._previewPointsX = callback); + } + setPreviewPointsY(callback: (d: any) => number) { + isFunction(callback) && (this._previewPointsY = callback); + } + setPreviewPointsX1(callback: (d: any) => number) { + isFunction(callback) && (this._previewPointsX1 = callback); + } + setPreviewPointsY1(callback: (d: any) => number) { + isFunction(callback) && (this._previewPointsY1 = callback); + } + setStatePointToData(callback: (state: number) => any) { + isFunction(callback) && (this._statePointToData = callback); + } + + release(all?: boolean): void { + /** + * 浏览器上的事件必须解绑,防止内存泄漏,场景树上的事件会自动解绑 + */ + super.release(all); + (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { + passive: false + }); + this._clearDragEvents(); + } +} diff --git a/packages/vrender-components/src/data-zoom/data-zoom.ts b/packages/vrender-components/src/data-zoom/data-zoom.ts index eb97228b3..cedd38469 100644 --- a/packages/vrender-components/src/data-zoom/data-zoom.ts +++ b/packages/vrender-components/src/data-zoom/data-zoom.ts @@ -1,639 +1,42 @@ -import type { FederatedPointerEvent, IArea, IGroup, ILine, IRect, ISymbol, INode } from '@visactor/vrender-core'; -// eslint-disable-next-line no-duplicate-imports -import { flatten_simplify, vglobal } from '@visactor/vrender-core'; -import type { IBoundsLike, IPointLike } from '@visactor/vutils'; -// eslint-disable-next-line no-duplicate-imports -import { Bounds, array, clamp, debounce, isFunction, isValid, merge, throttle } from '@visactor/vutils'; +import type { IGroup } from '@visactor/vrender-core'; +import { array, isFunction, isValid, merge } from '@visactor/vutils'; import { AbstractComponent } from '../core/base'; -import type { TagAttributes } from '../tag'; -// eslint-disable-next-line no-duplicate-imports -import { Tag } from '../tag'; -import { DEFAULT_DATA_ZOOM_ATTRIBUTES, DEFAULT_HANDLER_ATTR_MAP } from './config'; -import { DataZoomActiveTag } from './type'; -// eslint-disable-next-line no-duplicate-imports import type { DataZoomAttributes } from './type'; import type { ComponentOptions } from '../interface'; +import { Renderer, type IRenderer } from './renderer'; +import { InteractionManager, type InteractionManagerAttributes } from './interaction'; import { loadDataZoomComponent } from './register'; -import { getEndTriggersOfDrag } from '../util/event'; +import { DEFAULT_DATA_ZOOM_ATTRIBUTES } from './config'; -const delayMap = { - debounce: debounce, - throttle: throttle -}; loadDataZoomComponent(); export class DataZoom extends AbstractComponent> { name = 'dataZoom'; static defaultAttributes = DEFAULT_DATA_ZOOM_ATTRIBUTES; - - private _isHorizontal: boolean; - - private _background!: IRect; - - private _container!: IGroup; - - /** 手柄 */ - private _startHandlerMask!: IRect; - private _startHandler!: ISymbol; - private _middleHandlerSymbol!: ISymbol; - private _middleHandlerRect!: IRect; - private _endHandlerMask!: IRect; - private _endHandler!: ISymbol; - private _selectedBackground!: IRect; - private _dragMask!: IRect; - private _startText!: Tag; - private _endText!: Tag; - private _startValue!: string | number; - private _endValue!: string | number; - private _showText!: boolean; - - /** 背景图表 */ - private _previewData: any[] = []; - private _previewGroup!: IGroup; - private _previewLine!: ILine; - private _previewArea!: IArea; - private _selectedPreviewGroupClip!: IGroup; - private _selectedPreviewGroup!: IGroup; - private _selectedPreviewLine!: ILine; - private _selectedPreviewArea!: IArea; - - /** 交互状态 */ - protected _activeTag!: DataZoomActiveTag; - protected _activeItem!: any; - protected _activeState = false; - protected _activeCache: { - startPos: IPointLike; - lastPos: IPointLike; - } = { - startPos: { x: 0, y: 0 }, - lastPos: { x: 0, y: 0 } - }; - protected _layoutCache: { - attPos: 'x' | 'y'; - attSize: 'width' | 'height'; - max: number; - } = { - attPos: 'x', - attSize: 'width', - max: 0 - }; - /** 起始状态 */ - readonly state = { - start: 0, - end: 1 - }; - protected _spanCache: number; - - /** 回调函数 */ - private _previewPointsX!: (datum: any) => number; - private _previewPointsY!: (datum: any) => number; - private _previewPointsX1!: (datum: any) => number; - private _previewPointsY1!: (datum: any) => number; - private _statePointToData: (state: number) => any = state => state; - private _layoutAttrFromConfig: any; // 用于缓存 - - setPropsFromAttrs() { - const { start, end, orient, previewData, previewPointsX, previewPointsY, previewPointsX1, previewPointsY1 } = this - .attribute as DataZoomAttributes; - start && (this.state.start = start); - end && (this.state.end = end); - const { width, height } = this.getLayoutAttrFromConfig(); - this._spanCache = this.state.end - this.state.start; - this._isHorizontal = orient === 'top' || orient === 'bottom'; - this._layoutCache.max = this._isHorizontal ? width : height; - this._layoutCache.attPos = this._isHorizontal ? 'x' : 'y'; - this._layoutCache.attSize = this._isHorizontal ? 'width' : 'height'; - previewData && (this._previewData = previewData); - isFunction(previewPointsX) && (this._previewPointsX = previewPointsX); - isFunction(previewPointsY) && (this._previewPointsY = previewPointsY); - isFunction(previewPointsX1) && (this._previewPointsX1 = previewPointsX1); - isFunction(previewPointsY1) && (this._previewPointsY1 = previewPointsY1); - } + /** 交互控制 */ + private _interaction: InteractionManager; + /** 渲染控制 */ + private _renderer: Renderer; + /** 共享变量: 状态 */ + private _state: { start: number; end: number } = { start: 0, end: 1 }; + /** 共享变量: 布局 */ + private _layoutCacheFromConfig: any; constructor(attributes: DataZoomAttributes, options?: ComponentOptions) { super(options?.skipDefault ? attributes : merge({}, DataZoom.defaultAttributes, attributes)); - const { position, showDetail } = attributes; - // 这些属性在事件交互过程中会改变,所以不能在setAttrs里面动态更改 - this._activeCache.startPos = position; - this._activeCache.lastPos = position; - if (showDetail === 'auto') { - this._showText = false as boolean; - } else { - this._showText = showDetail as boolean; - } - this.setPropsFromAttrs(); - } - - setAttributes(params: Partial>, forceUpdateTag?: boolean): void { - super.setAttributes(params, forceUpdateTag); - this.setPropsFromAttrs(); - } - - protected bindEvents(): void { - if (this.attribute.disableTriggerEvent) { - this.setAttribute('childrenPickable', false); - return; - } - const { showDetail, brushSelect } = this.attribute as DataZoomAttributes; - // 拖拽开始 - if (this._startHandlerMask) { - this._startHandlerMask.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'start') as unknown as EventListener - ); - } - if (this._endHandlerMask) { - this._endHandlerMask.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'end') as unknown as EventListener - ); - } - if (this._middleHandlerSymbol) { - this._middleHandlerSymbol.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleSymbol') as unknown as EventListener - ); - } - if (this._middleHandlerRect) { - this._middleHandlerRect.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleRect') as unknown as EventListener - ); - } - - const selectedTag = brushSelect ? 'background' : 'middleRect'; - if (this._selectedBackground) { - this._selectedBackground.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener - ); - } - if (brushSelect && this._background) { - this._background.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener - ); - } - if (brushSelect && this._previewGroup) { - this._previewGroup.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener - ); - } - if (this._selectedPreviewGroup) { - this._selectedPreviewGroup.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener - ); - } - - // hover - if (showDetail === 'auto') { - (this as unknown as IGroup).addEventListener('pointerenter', this._onHandlerPointerEnter as EventListener); - (this as unknown as IGroup).addEventListener('pointerleave', this._onHandlerPointerLeave as EventListener); - } - - (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { - passive: false - }); - } - private _handleTouchMove = (e: TouchEvent) => { - if (this._activeState) { - /** - * https://developer.mozilla.org/zh-CN/docs/Web/CSS/overscroll-behavior - * 由于浏览器的overscroll-behavior属性,需要在move的时候阻止浏览器默认行为,否则会因为浏览器检测到scroll行为,阻止pointer事件, - * 抛出pointercancel事件,导致拖拽行为中断。 - */ - e.preventDefault(); - } - }; - - /** dragMask size边界处理 */ - protected dragMaskSize() { - const { position } = this.attribute as DataZoomAttributes; - const { attPos, max } = this._layoutCache; - if (this._activeCache.lastPos[attPos] - position[attPos] > max) { - return max + position[attPos] - this._activeCache.startPos[attPos]; - } else if (this._activeCache.lastPos[attPos] - position[attPos] < 0) { - return position[attPos] - this._activeCache.startPos[attPos]; - } - return this._activeCache.lastPos[attPos] - this._activeCache.startPos[attPos]; - } - - /** state 边界处理 */ - protected setStateAttr(start: number, end: number, shouldRender: boolean) { - const { zoomLock = false, minSpan = 0, maxSpan = 1 } = this.attribute as DataZoomAttributes; - const span = end - start; - if (span !== this._spanCache && (zoomLock || span < minSpan || span > maxSpan)) { - return; - } - this._spanCache = span; - this.state.start = start; - this.state.end = end; - shouldRender && this.setAttributes({ start, end }); - } - - /** 事件系统坐标转换为stage坐标 */ - protected eventPosToStagePos(e: FederatedPointerEvent) { - // updateSpec过程中交互的话, stage可能为空 - return this.stage?.eventPointTransform(e) ?? { x: 0, y: 0 }; - } - - private _clearDragEvents() { - const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; - const triggers = getEndTriggersOfDrag(); - - evtTarget.removeEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); - triggers.forEach((trigger: string) => { - evtTarget.removeEventListener(trigger, this._onHandlerPointerUp); - }); - - (this as unknown as IGroup).removeEventListener('pointermove', this._onHandlerPointerMove, { - capture: true - }); - } - - /** - * 拖拽开始事件 - * @description 开启activeState + 通过tag判断事件在哪个元素上触发 并 更新交互坐标 - */ - private _onHandlerPointerDown = (e: FederatedPointerEvent, tag: string) => { - // 清除之前的事件,防止没有被清除掉 - this._clearDragEvents(); - if (tag === 'start') { - this._activeTag = DataZoomActiveTag.startHandler; - this._activeItem = this._startHandlerMask; - } else if (tag === 'end') { - this._activeTag = DataZoomActiveTag.endHandler; - this._activeItem = this._endHandlerMask; - } else if (tag === 'middleRect') { - this._activeTag = DataZoomActiveTag.middleHandler; - this._activeItem = this._middleHandlerRect; - } else if (tag === 'middleSymbol') { - this._activeTag = DataZoomActiveTag.middleHandler; - this._activeItem = this._middleHandlerSymbol; - } else if (tag === 'background') { - this._activeTag = DataZoomActiveTag.background; - this._activeItem = this._background; - } - this._activeState = true; - this._activeCache.startPos = this.eventPosToStagePos(e); - this._activeCache.lastPos = this.eventPosToStagePos(e); - const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; - const triggers = getEndTriggersOfDrag(); - - /** - * move的时候,需要通过 capture: true,能够在捕获截断被拦截, - */ - evtTarget.addEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); - (this as unknown as IGroup).addEventListener('pointermove', this._onHandlerPointerMove, { - capture: true - }); - - triggers.forEach((trigger: string) => { - evtTarget.addEventListener(trigger, this._onHandlerPointerUp); - }); - }; - - /** - * 拖拽进行事件 - * @description 分为以下四种情况: - * 1. 在背景 or 背景图表上拖拽 (activeTag === 'background'): 改变lastPos => dragMask的宽 or 高被改变 - * 2. 在middleHandler上拖拽 (activeTag === 'middleHandler'): 改变lastPos、start & end + 边界处理: 防止拖拽结果超出背景 => 所有handler的位置被改变 - * 3. 在startHandler上拖拽 (activeTag === 'startHandler'): 改变lastPos、start & end + 边界处理: startHandler和endHandler交换 => 所有handler的位置被改变 - * 4. 在endHandler上拖拽,同上 - */ - private _pointerMove = (e: FederatedPointerEvent) => { - const { start: startAttr, end: endAttr, brushSelect, realTime = true } = this.attribute as DataZoomAttributes; - const pos = this.eventPosToStagePos(e); - const { attPos, max } = this._layoutCache; - const dis = (pos[attPos] - this._activeCache.lastPos[attPos]) / max; - - let { start, end } = this.state; - // this._activeState= false; - if (this._activeState) { - // if (this._activeTag === DataZoomActiveTag.background) { - // } else - if (this._activeTag === DataZoomActiveTag.middleHandler) { - this.moveZoomWithMiddle((this.state.start + this.state.end) / 2 + dis); - } else if (this._activeTag === DataZoomActiveTag.startHandler) { - if (start + dis > end) { - start = end; - end = start + dis; - this._activeTag = DataZoomActiveTag.endHandler; - } else { - start = start + dis; - } - } else if (this._activeTag === DataZoomActiveTag.endHandler) { - if (end + dis < start) { - end = start; - start = end + dis; - this._activeTag = DataZoomActiveTag.startHandler; - } else { - end = end + dis; - } - } - this._activeCache.lastPos = pos; - brushSelect && this.renderDragMask(); - } - start = Math.min(Math.max(start, 0), 1); - end = Math.min(Math.max(end, 0), 1); - - // 避免attributes相同时, 重复渲染 - if (startAttr !== start || endAttr !== end) { - this.setStateAttr(start, end, true); - if (realTime) { - this._dispatchEvent('change', { - start, - end, - tag: this._activeTag - }); - } - } - }; - private _onHandlerPointerMove = - this.attribute.delayTime === 0 - ? this._pointerMove - : delayMap[this.attribute.delayType](this._pointerMove, this.attribute.delayTime); - - /** - * 拖拽结束事件 - * @description 关闭activeState + 边界情况处理: 防止拖拽后start和end过近 - */ - private _onHandlerPointerUp = (e: FederatedPointerEvent) => { - const { start, end, brushSelect, realTime = true } = this.attribute as DataZoomAttributes; - if (this._activeState) { - if (this._activeTag === DataZoomActiveTag.background) { - const pos = this.eventPosToStagePos(e); - this.backgroundDragZoom(this._activeCache.startPos, pos); - } - } - this._activeState = false; - - // dragMask不依赖于state更新 - brushSelect && this.renderDragMask(); - - // 此次dispatch不能被省略 - // 因为pointermove时, 已经将状态更新至最新, 所以在pointerup时, 必定start = state.start & end = state.end - // 而realTime = false时, 需要依赖这次dispatch来更新图表图元 - this._dispatchEvent('change', { - start: this.state.start, - end: this.state.end, - tag: this._activeTag - }); - this._clearDragEvents(); - }; - - /** - * 鼠标进入事件 - * @description 鼠标进入选中部分出现start和end文字 - */ - private _onHandlerPointerEnter(e: FederatedPointerEvent) { - this._showText = true; - this.renderText(); - } - - /** - * 鼠标移出事件 - * @description 鼠标移出选中部分不出现start和end文字 - */ - private _onHandlerPointerLeave(e: FederatedPointerEvent) { - this._showText = false; - this.renderText(); - } - - protected backgroundDragZoom(startPos: IPointLike, endPos: IPointLike) { - const { attPos, max } = this._layoutCache; - const { position } = this.attribute as DataZoomAttributes; - const startPosInComponent = startPos[attPos] - position[attPos]; - const endPosInComponent = endPos[attPos] - position[attPos]; - const start = Math.min(Math.max(Math.min(startPosInComponent, endPosInComponent) / max, 0), 1); - const end = Math.min(Math.max(Math.max(startPosInComponent, endPosInComponent) / max, 0), 1); - if (Math.abs(start - end) < 0.01) { - this.moveZoomWithMiddle(start); - } else { - this.setStateAttr(start, end, false); - } - } - - protected moveZoomWithMiddle(middle: number) { - const currentMiddle = (this.state.start + this.state.end) / 2; - let offset = middle - currentMiddle; - // 拖拽middleHandler时,限制在background范围内 - if (offset === 0) { - return; - } else if (offset > 0) { - if (this.state.end + offset > 1) { - offset = 1 - this.state.end; - } - } else if (offset < 0) { - if (this.state.start + offset < 0) { - offset = -this.state.start; - } - } - this.setStateAttr(this.state.start + offset, this.state.end + offset, false); - } - - protected renderDragMask() { - const { dragMaskStyle } = this.attribute as DataZoomAttributes; - const { position, width, height } = this.getLayoutAttrFromConfig(); - // drag部分 - if (this._isHorizontal) { - this._dragMask = this._container.createOrUpdateChild( - 'dragMask', - { - x: clamp( - this.dragMaskSize() < 0 ? this._activeCache.lastPos.x : this._activeCache.startPos.x, - position.x, - position.x + width - ), - y: position.y, - width: - (this._activeState && this._activeTag === DataZoomActiveTag.background && Math.abs(this.dragMaskSize())) || - 0, - height, - ...dragMaskStyle - }, - 'rect' - ) as IRect; - } else { - this._dragMask = this._container.createOrUpdateChild( - 'dragMask', - { - x: position.x, - y: clamp( - this.dragMaskSize() < 0 ? this._activeCache.lastPos.y : this._activeCache.startPos.y, - position.y, - position.y + height - ), - width, - height: - (this._activeState && this._activeTag === DataZoomActiveTag.background && Math.abs(this.dragMaskSize())) || - 0, - ...dragMaskStyle - }, - 'rect' - ) as IRect; - } - } - - /** - * 判断文字是否超出datazoom范围 - */ - protected isTextOverflow(componentBoundsLike: IBoundsLike, textBounds: IBoundsLike | null, layout: 'start' | 'end') { - if (!textBounds) { - return false; - } - if (this._isHorizontal) { - if (layout === 'start') { - if (textBounds.x1 < componentBoundsLike.x1) { - return true; - } - } else { - if (textBounds.x2 > componentBoundsLike.x2) { - return true; - } - } - } else { - if (layout === 'start') { - if (textBounds.y1 < componentBoundsLike.y1) { - return true; - } - } else { - if (textBounds.y2 > componentBoundsLike.y2) { - return true; - } - } - } - return false; - } - - protected setTextAttr(startTextBounds: IBoundsLike, endTextBounds: IBoundsLike) { - const { startTextStyle, endTextStyle } = this.attribute as DataZoomAttributes; - const { formatMethod: startTextFormat, ...restStartTextStyle } = startTextStyle; - const { formatMethod: endTextFormat, ...restEndTextStyle } = endTextStyle; - const { start, end } = this.state; - this._startValue = this._statePointToData(start); - this._endValue = this._statePointToData(end); - const { position, width, height } = this.getLayoutAttrFromConfig(); - - const startTextValue = startTextFormat ? startTextFormat(this._startValue) : this._startValue; - const endTextValue = endTextFormat ? endTextFormat(this._endValue) : this._endValue; - const componentBoundsLike = { - x1: position.x, - y1: position.y, - x2: position.x + width, - y2: position.y + height - }; - let startTextPosition: IPointLike; - let endTextPosition: IPointLike; - let startTextAlignStyle: any; - let endTextAlignStyle: any; - if (this._isHorizontal) { - startTextPosition = { - x: position.x + start * width, - y: position.y + height / 2 - }; - endTextPosition = { - x: position.x + end * width, - y: position.y + height / 2 - }; - startTextAlignStyle = { - textAlign: this.isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'left' : 'right', - textBaseline: restStartTextStyle?.textStyle?.textBaseline ?? 'middle' - }; - endTextAlignStyle = { - textAlign: this.isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'right' : 'left', - textBaseline: restEndTextStyle?.textStyle?.textBaseline ?? 'middle' - }; - } else { - startTextPosition = { - x: position.x + width / 2, - y: position.y + start * height - }; - endTextPosition = { - x: position.x + width / 2, - y: position.y + end * height - }; - startTextAlignStyle = { - textAlign: restStartTextStyle?.textStyle?.textAlign ?? 'center', - textBaseline: this.isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'top' : 'bottom' - }; - endTextAlignStyle = { - textAlign: restEndTextStyle?.textStyle?.textAlign ?? 'center', - textBaseline: this.isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'bottom' : 'top' - }; - } - - this._startText = this.maybeAddLabel( - this._container, - merge({}, restStartTextStyle, { - text: startTextValue, - x: startTextPosition.x, - y: startTextPosition.y, - visible: this._showText, - pickable: false, - childrenPickable: false, - textStyle: startTextAlignStyle - }), - `data-zoom-start-text-${position}` - ); - this._endText = this.maybeAddLabel( - this._container, - merge({}, restEndTextStyle, { - text: endTextValue, - x: endTextPosition.x, - y: endTextPosition.y, - visible: this._showText, - pickable: false, - childrenPickable: false, - textStyle: endTextAlignStyle - }), - `data-zoom-end-text-${position}` - ); - } - - protected renderText() { - let startTextBounds: IBoundsLike | null = null; - let endTextBounds: IBoundsLike | null = null; - - // 第一次绘制 - this.setTextAttr(startTextBounds, endTextBounds); - // 得到bounds - startTextBounds = this._startText.AABBBounds; - endTextBounds = this._endText.AABBBounds; - - // 第二次绘制: 将text限制在组件bounds内 - this.setTextAttr(startTextBounds, endTextBounds); - // 得到bounds - startTextBounds = this._startText.AABBBounds; - endTextBounds = this._endText.AABBBounds; - const { x1, x2, y1, y2 } = startTextBounds; - const { dx: startTextDx = 0, dy: startTextDy = 0 } = this.attribute.startTextStyle; - - // 第三次绘制: 避免startText和endText重叠, 如果重叠了, 对startText做位置调整(考虑到调整的最小化,只单独调整startText而不调整endText) - if (new Bounds().set(x1, y1, x2, y2).intersects(endTextBounds)) { - const direction = this.attribute.orient === 'bottom' || this.attribute.orient === 'right' ? -1 : 1; - if (this._isHorizontal) { - this._startText.setAttribute('dy', startTextDy + direction * Math.abs(endTextBounds.y1 - endTextBounds.y2)); - } else { - this._startText.setAttribute('dx', startTextDx + direction * Math.abs(endTextBounds.x1 - endTextBounds.x2)); - } - } else { - if (this._isHorizontal) { - this._startText.setAttribute('dy', startTextDy); - } else { - this._startText.setAttribute('dx', startTextDx); - } - } + this._renderer = new Renderer(this._rendererAttrs()); + this._interaction = new InteractionManager(this._interactionAttrs()); + const { start, end } = this.attribute as DataZoomAttributes; + start && (this._state.start = start); + end && (this._state.end = end); } /** * 获取背景框中的位置和宽高 * @description 实际绘制的背景框中的高度或宽度 减去 中间手柄的高度或宽度 */ - protected getLayoutAttrFromConfig() { - if (this._layoutAttrFromConfig) { - return this._layoutAttrFromConfig; + private _getLayoutAttrFromConfig() { + if (this._layoutCacheFromConfig) { + return this._layoutCacheFromConfig; } const { position: positionConfig, @@ -652,7 +55,7 @@ export class DataZoom extends AbstractComponent> { let height; let position; if (middleHandlerStyle.visible) { - if (this._isHorizontal) { + if (this.attribute.orient === 'top' || this.attribute.orient === 'bottom') { width = widthConfig; height = heightConfig - middleHandlerSize; position = { @@ -673,11 +76,13 @@ export class DataZoom extends AbstractComponent> { position = positionConfig; } - const startHandlerSize = (startHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); - const endHandlerSize = (endHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); + const isHorizontal = this.attribute.orient === 'top' || this.attribute.orient === 'bottom'; + + const startHandlerSize = (startHandlerStyle.size as number) ?? (isHorizontal ? height : width); + const endHandlerSize = (endHandlerStyle.size as number) ?? (isHorizontal ? height : width); // 如果startHandler显示的话,要将其宽高计入dataZoom宽高 if (startHandlerStyle.visible) { - if (this._isHorizontal) { + if (isHorizontal) { width -= (startHandlerSize + endHandlerSize) / 2; position = { x: position.x + startHandlerSize / 2, @@ -693,520 +98,129 @@ export class DataZoom extends AbstractComponent> { } // stroke 需计入宽高, 否则dataZoom在画布边缘会被裁剪lineWidth / 2 - height += backgroundStyle.lineWidth / 2 ?? 1; - width += backgroundStyle.lineWidth / 2 ?? 1; + height += (backgroundStyle.lineWidth ?? 2) / 2; + width += (backgroundStyle.lineWidth ?? 2) / 2; - this._layoutAttrFromConfig = { + this._layoutCacheFromConfig = { position, width, height }; - return this._layoutAttrFromConfig; + return this._layoutCacheFromConfig; } - protected render() { - this._layoutAttrFromConfig = null; - const { - // start, - // end, - orient, - backgroundStyle, - backgroundChartStyle = {}, - selectedBackgroundStyle = {}, - selectedBackgroundChartStyle = {}, - middleHandlerStyle = {}, - startHandlerStyle = {}, - endHandlerStyle = {}, - brushSelect, - zoomLock - } = this.attribute as DataZoomAttributes; - const { start, end } = this.state; + get getLayoutAttrFromConfig() { + return this._getLayoutAttrFromConfig; + } - const { position, width, height } = this.getLayoutAttrFromConfig(); - const startHandlerMinSize = startHandlerStyle.triggerMinSize ?? 40; - const endHandlerMinSize = endHandlerStyle.triggerMinSize ?? 40; - const group = (this as unknown as IGroup).createOrUpdateChild('dataZoom-container', {}, 'group') as IGroup; - this._container = group; - this._background = group.createOrUpdateChild( - 'background', - { - x: position.x, - y: position.y, - width, - height, - cursor: brushSelect ? 'crosshair' : 'auto', - ...backgroundStyle, - pickable: zoomLock ? false : backgroundStyle.pickable ?? true + private _rendererAttrs(): IRenderer { + return { + attribute: this.attribute, + getLayoutAttrFromConfig: this.getLayoutAttrFromConfig, + setState: (state: { start: number; end: number }) => { + this._state = state; }, - 'rect' - ) as IRect; - - /** 背景图表 */ - backgroundChartStyle.line?.visible && this.setPreviewAttributes('line', group); - backgroundChartStyle.area?.visible && this.setPreviewAttributes('area', group); - - /** drag mask */ - brushSelect && this.renderDragMask(); - - /** 选中背景 */ - if (this._isHorizontal) { - // 选中部分 - this._selectedBackground = group.createOrUpdateChild( - 'selectedBackground', - { - x: position.x + start * width, - y: position.y, - width: (end - start) * width, - height: height, - cursor: brushSelect ? 'crosshair' : 'move', - ...selectedBackgroundStyle, - pickable: zoomLock ? false : (selectedBackgroundChartStyle as any).pickable ?? true - }, - 'rect' - ) as IRect; - } else { - // 选中部分 - this._selectedBackground = group.createOrUpdateChild( - 'selectedBackground', - { - x: position.x, - y: position.y + start * height, - width, - height: (end - start) * height, - cursor: brushSelect ? 'crosshair' : 'move', - ...selectedBackgroundStyle, - pickable: zoomLock ? false : selectedBackgroundStyle.pickable ?? true - }, - 'rect' - ) as IRect; - } - - /** 选中的背景图表 */ - selectedBackgroundChartStyle.line?.visible && this.setSelectedPreviewAttributes('line', group); - selectedBackgroundChartStyle.area?.visible && this.setSelectedPreviewAttributes('area', group); - - /** 左右 和 中间手柄 */ - if (this._isHorizontal) { - if (middleHandlerStyle.visible) { - const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; - this._middleHandlerRect = group.createOrUpdateChild( - 'middleHandlerRect', - { - x: position.x + start * width, - y: position.y - middleHandlerBackgroundSize, - width: (end - start) * width, - height: middleHandlerBackgroundSize, - ...middleHandlerStyle.background?.style, - pickable: zoomLock ? false : middleHandlerStyle.background?.style?.pickable ?? true - }, - 'rect' - ) as IRect; - this._middleHandlerSymbol = group.createOrUpdateChild( - 'middleHandlerSymbol', - { - x: position.x + ((start + end) / 2) * width, - y: position.y - middleHandlerBackgroundSize / 2, - strokeBoundsBuffer: 0, - angle: 0, - symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', - ...middleHandlerStyle.icon, - pickable: zoomLock ? false : middleHandlerStyle.icon.pickable ?? true - }, - 'symbol' - ) as ISymbol; - } - this._startHandler = group.createOrUpdateChild( - 'startHandler', - { - x: position.x + start * width, - y: position.y + height / 2, - size: height, - symbolType: startHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - ...startHandlerStyle, - pickable: zoomLock ? false : startHandlerStyle.pickable ?? true - }, - 'symbol' - ) as ISymbol; - this._endHandler = group.createOrUpdateChild( - 'endHandler', - { - x: position.x + end * width, - y: position.y + height / 2, - size: height, - symbolType: endHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - ...endHandlerStyle, - pickable: zoomLock ? false : endHandlerStyle.pickable ?? true - }, - 'symbol' - ) as ISymbol; - - // 透明mask构造热区, 热区大小配置来自handler bounds - const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); - const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); - const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); - const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); - - this._startHandlerMask = group.createOrUpdateChild( - 'startHandlerMask', - { - x: position.x + start * width - startHandlerWidth / 2, - y: position.y + height / 2 - startHandlerHeight / 2, - width: startHandlerWidth, - height: startHandlerHeight, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - this._endHandlerMask = group.createOrUpdateChild( - 'endHandlerMask', - { - x: position.x + end * width - endHandlerWidth / 2, - y: position.y + height / 2 - endHandlerHeight / 2, - width: endHandlerWidth, - height: endHandlerHeight, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - } else { - if (middleHandlerStyle.visible) { - const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; - - this._middleHandlerRect = group.createOrUpdateChild( - 'middleHandlerRect', - { - x: orient === 'left' ? position.x - middleHandlerBackgroundSize : position.x + width, - y: position.y + start * height, - width: middleHandlerBackgroundSize, - height: (end - start) * height, - ...middleHandlerStyle.background?.style, - pickable: zoomLock ? false : middleHandlerStyle.background?.style?.pickable ?? true - }, - 'rect' - ) as IRect; - this._middleHandlerSymbol = group.createOrUpdateChild( - 'middleHandlerSymbol', - { - x: - orient === 'left' - ? position.x - middleHandlerBackgroundSize / 2 - : position.x + width + middleHandlerBackgroundSize / 2, - y: position.y + ((start + end) / 2) * height, - // size: height, - angle: 90 * (Math.PI / 180), - symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', - strokeBoundsBuffer: 0, - ...middleHandlerStyle.icon, - pickable: zoomLock ? false : middleHandlerStyle.icon?.pickable ?? true - }, - 'symbol' - ) as ISymbol; + getState: () => { + return this._state; } - this._startHandler = group.createOrUpdateChild( - 'startHandler', - { - x: position.x + width / 2, - y: position.y + start * height, - size: width, - symbolType: startHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - ...startHandlerStyle, - pickable: zoomLock ? false : startHandlerStyle.pickable ?? true - }, - 'symbol' - ) as ISymbol; - - this._endHandler = group.createOrUpdateChild( - 'endHandler', - { - x: position.x + width / 2, - y: position.y + end * height, - size: width, - symbolType: endHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - ...endHandlerStyle, - pickable: zoomLock ? false : endHandlerStyle.pickable ?? true - }, - 'symbol' - ) as ISymbol; - - // 透明mask构造热区, 热区大小配置来自handler bounds - const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); - const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); - const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); - const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); - - this._startHandlerMask = group.createOrUpdateChild( - 'startHandlerMask', - { - x: position.x + width / 2 + startHandlerWidth / 2, - y: position.y + start * height - startHandlerHeight / 2, - width: endHandlerHeight, - height: endHandlerWidth, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - this._endHandlerMask = group.createOrUpdateChild( - 'endHandlerMask', - { - x: position.x + width / 2 + endHandlerWidth / 2, - y: position.y + end * height - endHandlerHeight / 2, - width: endHandlerHeight, - height: endHandlerWidth, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - } - - /** 左右文字 */ - if (this._showText) { - this.renderText(); - } + }; } - computeBasePoints() { - const { orient } = this.attribute as DataZoomAttributes; - const { position, width, height } = this.getLayoutAttrFromConfig(); - let basePointStart: any; - let basePointEnd: any; - if (this._isHorizontal) { - basePointStart = [ - { - x: position.x, - y: position.y + height - } - ]; - basePointEnd = [ - { - x: position.x + width, - y: position.y + height - } - ]; - } else if (orient === 'left') { - basePointStart = [ - { - x: position.x + width, - y: position.y - } - ]; - basePointEnd = [ - { - x: position.x + width, - y: position.y + height - } - ]; - } else { - basePointStart = [ - { - x: position.x, - y: position.y + height - } - ]; - basePointEnd = [ - { - x: position.x, - y: position.y - } - ]; - } + private _interactionAttrs(): InteractionManagerAttributes { return { - basePointStart, - basePointEnd + stage: this.stage, + attribute: this.attribute, + startHandlerMask: this._renderer.startHandlerMask, + endHandlerMask: this._renderer.endHandlerMask, + middleHandlerSymbol: this._renderer.middleHandlerSymbol, + middleHandlerRect: this._renderer.middleHandlerRect, + selectedBackground: this._renderer.selectedBackground, + background: this._renderer.background, + previewGroup: this._renderer.previewGroup, + selectedPreviewGroup: this._renderer.selectedPreviewGroup, + getLayoutAttrFromConfig: this.getLayoutAttrFromConfig, + setState: (state: { start: number; end: number }) => { + this._state = state; + }, + getState: () => { + return this._state; + } }; } - protected simplifyPoints(points: IPointLike[]) { - // 采样压缩率策略: 如果没做任何配置, 那么限制在niceCount内, 如果做了配置, 则按照配置计算 - const niceCount = 10000; // 经验值 - if (points.length > niceCount) { - const tolerance = this.attribute.tolerance ?? this._previewData.length / niceCount; - return flatten_simplify(points, tolerance, false); + bindEvents(): void { + if (this.attribute.disableTriggerEvent) { + this.setAttribute('childrenPickable', false); + return; } - return points; - } - - protected getPreviewLinePoints() { - let previewPoints = this._previewData.map(d => { - return { - x: this._previewPointsX && this._previewPointsX(d), - y: this._previewPointsY && this._previewPointsY(d) - }; + this._interaction.bindEvents(); + this._interaction.on('stateChange', ({ shouldRender }) => { + if (shouldRender) { + this._renderer.renderDataZoom(); + } }); - // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 - if (previewPoints.length === 0) { - return previewPoints; - } - - // 采样 - previewPoints = this.simplifyPoints(previewPoints); - - const { basePointStart, basePointEnd } = this.computeBasePoints(); - return basePointStart.concat(previewPoints).concat(basePointEnd); - } - - protected getPreviewAreaPoints() { - let previewPoints: IPointLike[] = this._previewData.map(d => { - return { - x: this._previewPointsX && this._previewPointsX(d), - y: this._previewPointsY && this._previewPointsY(d), - x1: this._previewPointsX1 && this._previewPointsX1(d), - y1: this._previewPointsY1 && this._previewPointsY1(d) - }; + this._interaction.on('eventChange', ({ start, end, tag }) => { + this._dispatchEvent('change', { start, end, tag }); + }); + this._interaction.on('renderMask', () => { + this._renderer.renderDragMask(); + }); + this._interaction.on('enter', () => { + this._renderer.showText = true; + this._renderer._renderText(); }); - // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 - if (previewPoints.length === 0) { - return previewPoints; - } - - // 采样 - previewPoints = this.simplifyPoints(previewPoints); - - const { basePointStart, basePointEnd } = this.computeBasePoints(); - return basePointStart.concat(previewPoints).concat(basePointEnd); - } - - /** 使用callback绘制背景图表 (数据和数据映射从外部传进来) */ - protected setPreviewAttributes(type: 'line' | 'area', group: IGroup) { - if (!this._previewGroup) { - this._previewGroup = group.createOrUpdateChild('previewGroup', { pickable: false }, 'group') as IGroup; - } - if (type === 'line') { - this._previewLine = this._previewGroup.createOrUpdateChild('previewLine', {}, 'line') as ILine; - } else { - this._previewArea = this._previewGroup.createOrUpdateChild( - 'previewArea', - { curveType: 'basis' }, - 'area' - ) as IArea; - } - - const { backgroundChartStyle = {} } = this.attribute as DataZoomAttributes; - type === 'line' && - this._previewLine.setAttributes({ - points: this.getPreviewLinePoints(), - curveType: 'basis', - pickable: false, - ...backgroundChartStyle.line + // hover + if (this.attribute.showDetail === 'auto') { + (this as unknown as IGroup).addEventListener('pointerenter', () => { + this._renderer.showText = true; + this._renderer._renderText(); }); - type === 'area' && - this._previewArea.setAttributes({ - points: this.getPreviewAreaPoints(), - curveType: 'basis', - pickable: false, - ...backgroundChartStyle.area + (this as unknown as IGroup).addEventListener('pointerleave', () => { + this._renderer.showText = false; + this._renderer._renderText(); }); + } } - /** 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) */ - protected setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { - if (!this._selectedPreviewGroupClip) { - this._selectedPreviewGroupClip = group.createOrUpdateChild( - 'selectedPreviewGroupClip', - { pickable: false }, - 'group' - ) as IGroup; - this._selectedPreviewGroup = this._selectedPreviewGroupClip.createOrUpdateChild( - 'selectedPreviewGroup', - {}, - 'group' - ) as IGroup; - } + setAttributes(params: Partial>, forceUpdateTag?: boolean): void { + super.setAttributes(params, forceUpdateTag); + const { start, end } = this.attribute as DataZoomAttributes; + start && (this._state.start = start); + end && (this._state.end = end); - if (type === 'line') { - this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewLine', - {}, - 'line' - ) as ILine; - } else { - this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewArea', - { curveType: 'basis' }, - 'area' - ) as IArea; - } + this._renderer.setAttributes(this._rendererAttrs()); + this._interaction.setAttributes(this._interactionAttrs()); + } - const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + render(): void { + this._layoutCacheFromConfig = null; - const { start, end } = this.state; - const { position, width, height } = this.getLayoutAttrFromConfig(); - this._selectedPreviewGroupClip.setAttributes({ - x: this._isHorizontal ? position.x + start * width : position.x, - y: this._isHorizontal ? position.y : position.y + start * height, - width: this._isHorizontal ? (end - start) * width : width, - height: this._isHorizontal ? height : (end - start) * height, - clip: true, - pickable: false - } as any); - this._selectedPreviewGroup.setAttributes({ - x: -(this._isHorizontal ? position.x + start * width : position.x), - y: -(this._isHorizontal ? position.y : position.y + start * height), - width: this._isHorizontal ? (end - start) * width : width, - height: this._isHorizontal ? height : (end - start) * height, - pickable: false - } as any); - type === 'line' && - this._selectedPreviewLine.setAttributes({ - points: this.getPreviewLinePoints(), - curveType: 'basis', - pickable: false, - ...selectedBackgroundChartStyle.line - }); - type === 'area' && - this._selectedPreviewArea.setAttributes({ - points: this.getPreviewAreaPoints(), - curveType: 'basis', - pickable: false, - ...selectedBackgroundChartStyle.area - }); + const group = (this as unknown as IGroup).createOrUpdateChild('dataZoom-container', {}, 'group') as IGroup; + this._renderer.container = group; + this._renderer.renderDataZoom(); + this._interaction.setAttributes(this._interactionAttrs()); } - protected maybeAddLabel(container: IGroup, attributes: TagAttributes, name: string): Tag { - let labelShape = (this as unknown as IGroup).find(node => node.name === name, true) as unknown as Tag; - if (labelShape) { - labelShape.setAttributes(attributes); - } else { - labelShape = new Tag(attributes); - labelShape.name = name; - } - - container.add(labelShape as unknown as INode); - return labelShape; + release(all?: boolean): void { + /** + * 浏览器上的事件必须解绑,防止内存泄漏,场景树上的事件会自动解绑 + */ + super.release(all); + this._interaction.clearDragEvents(); + this._interaction.clearDragEvents(); } /** 外部重置组件的起始状态 */ setStartAndEnd(start?: number, end?: number) { const { start: startAttr, end: endAttr } = this.attribute as DataZoomAttributes; - if (isValid(start) && isValid(end) && (start !== this.state.start || end !== this.state.end)) { - this.state.start = start; - this.state.end = end; - if (startAttr !== this.state.start || endAttr !== this.state.end) { - this.setStateAttr(start, end, true); + const { start: startState, end: endState } = this._state; + if (isValid(start) && isValid(end) && (start !== startState || end !== endState)) { + if (startAttr !== startState || endAttr !== endState) { + this._renderer.renderDataZoom(); this._dispatchEvent('change', { start, - end, - tag: this._activeTag + end }); } } @@ -1214,25 +228,25 @@ export class DataZoom extends AbstractComponent> { /** 外部更新背景图表的数据 */ setPreviewData(data: any[]) { - this._previewData = data; + this._renderer.previewData = data; } /** 外部更新手柄文字 */ setText(text: string, tag: 'start' | 'end') { if (tag === 'start') { - this._startText.setAttribute('text', text); + this._renderer.startText.setAttribute('text', text); } else { - this._endText.setAttribute('text', text); + this._renderer.endText.setAttribute('text', text); } } /** 外部获取起始点数据值 */ getStartValue() { - return this._startValue; + return this._renderer.startValue; } getEndTextValue() { - return this._endValue; + return this._renderer.endValue; } getMiddleHandlerSize() { @@ -1244,29 +258,18 @@ export class DataZoom extends AbstractComponent> { /** 外部传入数据映射 */ setPreviewPointsX(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsX = callback); + isFunction(callback) && (this._renderer.previewPointsX = callback); } setPreviewPointsY(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsY = callback); + isFunction(callback) && (this._renderer.previewPointsY = callback); } setPreviewPointsX1(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsX1 = callback); + isFunction(callback) && (this._renderer.previewPointsX1 = callback); } setPreviewPointsY1(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsY1 = callback); + isFunction(callback) && (this._renderer.previewPointsY1 = callback); } setStatePointToData(callback: (state: number) => any) { - isFunction(callback) && (this._statePointToData = callback); - } - - release(all?: boolean): void { - /** - * 浏览器上的事件必须解绑,防止内存泄漏,场景树上的事件会自动解绑 - */ - super.release(all); - (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { - passive: false - }); - this._clearDragEvents(); + isFunction(callback) && (this._renderer.statePointToData = callback); } } diff --git a/packages/vrender-components/src/data-zoom/interaction.ts b/packages/vrender-components/src/data-zoom/interaction.ts new file mode 100644 index 000000000..e836dc635 --- /dev/null +++ b/packages/vrender-components/src/data-zoom/interaction.ts @@ -0,0 +1,403 @@ +import { DataZoomActiveTag, type DataZoomAttributes } from './type'; +import { getEndTriggersOfDrag } from '../util/event'; +import type { IPointLike, Dict } from '@visactor/vutils'; +import { vglobal } from '@visactor/vrender-core'; +// eslint-disable-next-line no-duplicate-imports +import type { FederatedPointerEvent, IGroup, IRect, ISymbol, IStage, INode } from '@visactor/vrender-core'; +// eslint-disable-next-line no-duplicate-imports +import { clamp, debounce, EventEmitter, throttle } from '@visactor/vutils'; +const delayMap = { + debounce: debounce, + throttle: throttle +}; +export interface InteractionManagerAttributes { + stage: IStage; + attribute: Partial>; + startHandlerMask?: IRect; + endHandlerMask?: IRect; + middleHandlerSymbol?: ISymbol; + middleHandlerRect?: IRect; + selectedBackground?: IRect; + background?: IRect; + previewGroup?: IGroup; + selectedPreviewGroup?: IGroup; + getLayoutAttrFromConfig?: any; + getState: () => { start: number; end: number }; + setState: (state: { start: number; end: number }) => void; +} +export class InteractionManager extends EventEmitter { + /** 上层透传 */ + stage: IStage; + attribute!: Partial>; + private _getLayoutAttrFromConfig: any; + // 图元 + private _startHandlerMask: IRect | undefined; + private _middleHandlerSymbol: ISymbol | undefined; + private _middleHandlerRect: IRect | undefined; + private _endHandlerMask: IRect | undefined; + private _background: IRect | undefined; + private _previewGroup: IGroup | undefined; + private _selectedPreviewGroup: IGroup | undefined; + private _selectedBackground: IRect | undefined; + + /** 交互相关 */ + _activeTag!: DataZoomActiveTag; + _activeItem!: any; + _activeState = false; + _activeCache: { + startPos: IPointLike; + lastPos: IPointLike; + } = { + startPos: { x: 0, y: 0 }, + lastPos: { x: 0, y: 0 } + }; + _layoutCache: { + attPos: 'x' | 'y'; + attSize: 'width' | 'height'; + size: number; + } = { + attPos: 'x', + attSize: 'width', + size: 0 + }; + _spanCache: number; + + private _getState: () => { start: number; end: number }; + private _setState: (state: { start: number; end: number }) => void; + + constructor(props: InteractionManagerAttributes) { + super(); + this.attribute = props.attribute; + this._initAttrs(props); + } + + setAttributes(props: InteractionManagerAttributes): void { + this._initAttrs(props); + } + + private _initAttrs(props: InteractionManagerAttributes) { + this.stage = props.stage; + this.attribute = props.attribute; + this._startHandlerMask = props.startHandlerMask; + this._endHandlerMask = props.endHandlerMask; + this._middleHandlerSymbol = props.middleHandlerSymbol; + this._middleHandlerRect = props.middleHandlerRect; + this._selectedBackground = props.selectedBackground; + this._background = props.background; + this._previewGroup = props.previewGroup; + this._selectedPreviewGroup = props.selectedPreviewGroup; + this._getLayoutAttrFromConfig = props.getLayoutAttrFromConfig; + this._getState = props.getState; + this._setState = props.setState; + + const { width, height } = this._getLayoutAttrFromConfig(); + this._spanCache = this._getState().end - this._getState().start; + const isHorizontal = this.attribute.orient === 'top' || this.attribute.orient === 'bottom'; + this._layoutCache.size = isHorizontal ? width : height; + this._layoutCache.attPos = isHorizontal ? 'x' : 'y'; + this._layoutCache.attSize = isHorizontal ? 'width' : 'height'; + } + + clearDragEvents() { + const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; + const triggers = getEndTriggersOfDrag(); + + evtTarget.removeEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); + triggers.forEach((trigger: string) => { + evtTarget.removeEventListener(trigger, this._onHandlerPointerUp); + }); + + (this as unknown as IGroup).off('pointermove', this._onHandlerPointerMove, { + capture: true + }); + } + + clearVGlobalEvents() { + (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { + passive: false + }); + } + + bindEvents(): void { + const { brushSelect } = this.attribute as DataZoomAttributes; + // 拖拽开始 + this._startHandlerMask?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'start') as unknown as EventListener + ); + this._endHandlerMask?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'end') as unknown as EventListener + ); + this._middleHandlerSymbol?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleSymbol') as unknown as EventListener + ); + this._middleHandlerRect?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleRect') as unknown as EventListener + ); + const selectedTag = brushSelect ? 'background' : 'middleRect'; + this._selectedBackground?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener + ); + brushSelect && + this._background?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener + ); + brushSelect && + this._previewGroup?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener + ); + this._selectedPreviewGroup?.addEventListener( + 'pointerdown', + (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener + ); + + (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { + passive: false + }); + } + private _handleTouchMove = (e: TouchEvent) => { + if (this._activeState) { + /** + * https://developer.mozilla.org/zh-CN/docs/Web/CSS/overscroll-behavior + * 由于浏览器的overscroll-behavior属性,需要在move的时候阻止浏览器默认行为,否则会因为浏览器检测到scroll行为,阻止pointer事件, + * 抛出pointercancel事件,导致拖拽行为中断。 + */ + e.preventDefault(); + } + }; + + /** + * 拖拽开始事件 + * @description 开启activeState + 通过tag判断事件在哪个元素上触发 并 更新交互坐标 + */ + private _onHandlerPointerDown = (e: FederatedPointerEvent, tag: string) => { + // 清除之前的事件,防止没有被清除掉 + this.clearDragEvents(); + if (tag === 'start') { + this._activeTag = DataZoomActiveTag.startHandler; + this._activeItem = this._startHandlerMask; + } else if (tag === 'end') { + this._activeTag = DataZoomActiveTag.endHandler; + this._activeItem = this._endHandlerMask; + } else if (tag === 'middleRect') { + this._activeTag = DataZoomActiveTag.middleHandler; + this._activeItem = this._middleHandlerRect; + } else if (tag === 'middleSymbol') { + this._activeTag = DataZoomActiveTag.middleHandler; + this._activeItem = this._middleHandlerSymbol; + } else if (tag === 'background') { + this._activeTag = DataZoomActiveTag.background; + this._activeItem = this._background; + } + this._activeState = true; + this._activeCache.startPos = this._eventPosToStagePos(e); + this._activeCache.lastPos = this._eventPosToStagePos(e); + const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; + const triggers = getEndTriggersOfDrag(); + + /** + * move的时候,需要通过 capture: true,能够在捕获截断被拦截, + */ + evtTarget.addEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); + (this as unknown as IGroup).on('pointermove', this._onHandlerPointerMove, { + capture: true + }); + + triggers.forEach((trigger: string) => { + evtTarget.addEventListener(trigger, this._onHandlerPointerUp); + }); + }; + + /** + * 拖拽进行事件 + * @description 分为以下四种情况: + * 1. 在背景 or 背景图表上拖拽 (activeTag === 'background'): 改变lastPos => only renderDragMask + * 2. 在middleHandler上拖拽 (activeTag === 'middleHandler'): 改变lastPos、start & end + 边界处理: 防止拖拽结果超出背景 => render + * 3. 在startHandler上拖拽 (activeTag === 'startHandler'): 改变lastPos、start & end + 边界处理: startHandler和endHandler交换 => render + * 4. 在endHandler上拖拽,同上 + */ + private _pointerMove = (e: FederatedPointerEvent) => { + const { brushSelect } = this.attribute as DataZoomAttributes; + const pos = this._eventPosToStagePos(e); + const { attPos, size } = this._layoutCache; + const dis = (pos[attPos] - this._activeCache.lastPos[attPos]) / size; + + let { start, end } = this._getState(); + let shouldRender = true; + if (this._activeState) { + if (this._activeTag === DataZoomActiveTag.middleHandler) { + ({ start, end } = this._moveZoomWithMiddle(dis)); + } else if (this._activeTag === DataZoomActiveTag.startHandler) { + ({ start, end } = this._moveZoomWithHandler('start', dis)); + } else if (this._activeTag === DataZoomActiveTag.endHandler) { + ({ start, end } = this._moveZoomWithHandler('end', dis)); + } else if (this._activeTag === DataZoomActiveTag.background && brushSelect) { + const { position, width } = this._getLayoutAttrFromConfig(); + const currentPos = pos ?? this._activeCache.lastPos; + start = clamp( + (this._activeCache.startPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / width, + 0, + 1 + ); + end = clamp((currentPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / width, 0, 1); + if (start > end) { + [start, end] = [end, start]; + } + shouldRender = false; + this._dispatchEvent('renderMask'); + } + this._activeCache.lastPos = pos; + } + + // 避免attributes相同时, 重复渲染 + if (this._getState().start !== start || this._getState().end !== end) { + this._setStateAttr(start, end); + this._dispatchEvent('stateChange', { + start: this._getState().start, + end: this._getState().end, + shouldRender, + tag: this._activeTag + }); + if (this.attribute.realTime) { + this._dispatchEvent('eventChange', { + start: this._getState().start, + end: this._getState().end, + shouldRender: true, + tag: this._activeTag + }); + } + } + }; + private _onHandlerPointerMove = + (this.attribute?.delayTime ?? 0) === 0 + ? this._pointerMove + : delayMap[this.attribute?.delayType ?? 'debounce'](this._pointerMove, this.attribute?.delayTime ?? 0); + + /** state 边界处理 */ + private _setStateAttr(start: number, end: number) { + const { zoomLock = false, minSpan = 0, maxSpan = 1 } = this.attribute as DataZoomAttributes; + const span = end - start; + if (span !== this._spanCache && (zoomLock || span < minSpan || span > maxSpan)) { + return; + } + this._spanCache = span; + this._setState({ start, end }); + } + /** + * @description 拖拽middleHandler, 改变start和end + */ + private _moveZoomWithMiddle(dis: number) { + const { start: staetState, end: endState } = this._getState(); + // 拖拽middleHandler时,限制在background范围内 + if (dis > 0 && endState + dis > 1) { + dis = 1 - endState; + } else if (dis < 0 && staetState + dis < 0) { + dis = -staetState; + } + return { + start: clamp(staetState + dis, 0, 1), + end: clamp(endState + dis, 0, 1) + }; + } + + /** + * @description 拖拽startHandler/endHandler, 改变start和end + */ + private _moveZoomWithHandler(handler: 'start' | 'end', dis: number) { + const { start, end } = this._getState(); + let newStart = start; + let newEnd = end; + if (handler === 'start') { + if (start + dis > end) { + newStart = end; + newEnd = start + dis; + this._activeTag = DataZoomActiveTag.endHandler; + } else { + newStart = start + dis; + } + } else if (handler === 'end') { + if (end + dis < start) { + newEnd = start; + newStart = end + dis; + this._activeTag = DataZoomActiveTag.startHandler; + } else { + newEnd = end + dis; + } + } + return { + start: clamp(newStart, 0, 1), + end: clamp(newEnd, 0, 1) + }; + } + /** + * 拖拽结束事件 + * @description 关闭activeState + 边界情况处理: 防止拖拽后start和end过近 + */ + private _onHandlerPointerUp = (e: FederatedPointerEvent) => { + if (this._activeState) { + // brush的时候, 只改变了state, 没有触发重新渲染, 在抬起鼠标时触发 + if (this._activeTag === DataZoomActiveTag.background) { + this._setStateAttr(this._getState().start, this._getState().end); + this._dispatchEvent('stateChange', { + start: this._getState().start, + end: this._getState().end, + shouldRender: true, + tag: this._activeTag + }); + } + } + this._activeState = false; + // 此次dispatch不能被省略 + // 因为pointermove时, 已经将状态更新至最新, 所以在pointerup时, 必定start = state.start & end = state.end + // 而realTime = false时, 需要依赖这次dispatch来更新图表图元 + this._dispatchEvent('eventChange', { + start: this._getState().start, + end: this._getState().end, + shouldRender: true, + tag: this._activeTag + }); + + this.clearDragEvents(); + }; + + /** + * 鼠标进入事件 + * @description 鼠标进入选中部分出现start和end文字 + */ + private _onHandlerPointerEnter(e: FederatedPointerEvent) { + this._dispatchEvent('enter', { + start: this._getState().start, + end: this._getState().end, + shouldRender: true + }); + } + + /** + * 鼠标移出事件 + * @description 鼠标移出选中部分不出现start和end文字 + */ + private _onHandlerPointerLeave(e: FederatedPointerEvent) { + this._dispatchEvent('leave', { + start: this._getState().start, + end: this._getState().end, + shouldRender: true + }); + } + + /** 事件系统坐标转换为stage坐标 */ + private _eventPosToStagePos(e: FederatedPointerEvent) { + // updateSpec过程中交互的话, stage可能为空 + return this.stage?.eventPointTransform(e as any) ?? { x: 0, y: 0 }; + } + + protected _dispatchEvent(eventName: string, details?: Dict) { + this.emit(eventName, details); + // return !changeEvent.defaultPrevented; + } +} diff --git a/packages/vrender-components/src/data-zoom/renderer.ts b/packages/vrender-components/src/data-zoom/renderer.ts new file mode 100644 index 000000000..dcc89dc73 --- /dev/null +++ b/packages/vrender-components/src/data-zoom/renderer.ts @@ -0,0 +1,797 @@ +import type { DataZoomAttributes } from './type'; +import type { IBoundsLike, IPointLike } from '@visactor/vutils'; +import { flatten_simplify } from '@visactor/vrender-core'; +// eslint-disable-next-line no-duplicate-imports +import type { IArea, IGroup, ILine, IRect, ISymbol, INode } from '@visactor/vrender-core'; +// eslint-disable-next-line no-duplicate-imports +import { Bounds, cloneDeep, isFunction, merge } from '@visactor/vutils'; +import { Tag, type TagAttributes } from '../tag'; +import { DEFAULT_HANDLER_ATTR_MAP } from './config'; +import { isTextOverflow } from './utils'; +export interface IRenderer { + attribute: Partial>; + getLayoutAttrFromConfig: any; + getState: () => { start: number; end: number }; + setState: (state: { start: number; end: number }) => void; +} +export class Renderer { + /** 上层透传 */ + attribute: Partial>; + private _container!: IGroup; + set container(container: IGroup) { + this._container = container; + } + get container() { + return this._container; + } + private _getLayoutAttrFromConfig: any; + private _isHorizontal: boolean; + + private _getState: () => { start: number; end: number }; + + /** 手柄 */ + private _startHandlerMask!: IRect; + get startHandlerMask() { + return this._startHandlerMask; + } + private _startHandler!: ISymbol; + private _middleHandlerSymbol!: ISymbol; + get middleHandlerSymbol() { + return this._middleHandlerSymbol; + } + private _middleHandlerRect!: IRect; + get middleHandlerRect() { + return this._middleHandlerRect; + } + private _endHandlerMask!: IRect; + get endHandlerMask() { + return this._endHandlerMask; + } + private _endHandler!: ISymbol; + private _selectedBackground!: IRect; + get selectedBackground() { + return this._selectedBackground; + } + private _dragMask!: IRect; + get dragMask() { + return this._dragMask; + } + private _startText!: Tag; + get startText() { + return this._startText; + } + private _endText!: Tag; + get endText() { + return this._endText; + } + private _startValue!: string | number; + get startValue() { + return this._startValue; + } + private _endValue!: string | number; + get endValue() { + return this._endValue; + } + private _showText!: boolean; + set showText(showText: boolean) { + this._showText = showText; + } + + /** 背景图表 */ + private _background!: IRect; + get background() { + return this._background; + } + private _previewData: any[] = []; + set previewData(previewData: any[]) { + this._previewData = previewData; + } + private _previewGroup!: IGroup; + get previewGroup() { + return this._previewGroup; + } + private _previewLine!: ILine; + private _previewArea!: IArea; + private _selectedPreviewGroupClip!: IGroup; + private _selectedPreviewGroup!: IGroup; + get selectedPreviewGroup() { + return this._selectedPreviewGroup; + } + private _selectedPreviewLine!: ILine; + private _selectedPreviewArea!: IArea; + + /** 回调函数 */ + private _previewPointsX!: (datum: any) => number; + set previewPointsX(previewPointsX: (datum: any) => number) { + this._previewPointsX = previewPointsX; + } + private _previewPointsY!: (datum: any) => number; + set previewPointsY(previewPointsY: (datum: any) => number) { + this._previewPointsY = previewPointsY; + } + private _previewPointsX1!: (datum: any) => number; + set previewPointsX1(previewPointsX1: (datum: any) => number) { + this._previewPointsX1 = previewPointsX1; + } + private _previewPointsY1!: (datum: any) => number; + set previewPointsY1(previewPointsY1: (datum: any) => number) { + this._previewPointsY1 = previewPointsY1; + } + private _statePointToData: (state: number) => any = state => state; + set statePointToData(statePointToData: (state: number) => any) { + this._statePointToData = statePointToData; + } + + private _initAttrs(props: IRenderer) { + this.attribute = props.attribute; + this._isHorizontal = this.attribute.orient === 'top' || this.attribute.orient === 'bottom'; + const { previewData, showDetail, previewPointsX, previewPointsY, previewPointsX1, previewPointsY1 } = this + .attribute as DataZoomAttributes; + if (showDetail === 'auto') { + this._showText = false as boolean; + } else { + this._showText = showDetail as boolean; + } + previewData && (this._previewData = previewData); + isFunction(previewPointsX) && (this._previewPointsX = previewPointsX); + isFunction(previewPointsY) && (this._previewPointsY = previewPointsY); + isFunction(previewPointsX1) && (this._previewPointsX1 = previewPointsX1); + isFunction(previewPointsY1) && (this._previewPointsY1 = previewPointsY1); + this._getState = props.getState; + this._getLayoutAttrFromConfig = props.getLayoutAttrFromConfig; + } + + constructor(props: IRenderer) { + this._initAttrs(props); + } + + setAttributes(props: IRenderer): void { + this._initAttrs(props); + } + + // 渲染拖拽mask + renderDragMask() { + const { dragMaskStyle } = this.attribute as DataZoomAttributes; + const { position, width, height } = this._getLayoutAttrFromConfig(); + const { start, end } = this._getState(); + if (this._isHorizontal) { + this._dragMask = this._container.createOrUpdateChild( + 'dragMask', + { + x: position.x + start * width, + y: position.y, + width: (end - start) * width, + height: height, + ...dragMaskStyle + }, + 'rect' + ) as IRect; + } else { + this._dragMask = this._container.createOrUpdateChild( + 'dragMask', + { + x: position.x, + y: position.y + start * height, + width, + height: (end - start) * height, + ...dragMaskStyle + }, + 'rect' + ) as IRect; + } + return { start, end }; + } + + renderDataZoom() { + const { + orient, + backgroundStyle, + backgroundChartStyle = {}, + selectedBackgroundStyle = {}, + selectedBackgroundChartStyle = {}, + middleHandlerStyle = {}, + startHandlerStyle = {}, + endHandlerStyle = {}, + brushSelect, + zoomLock + } = this.attribute as DataZoomAttributes; + const { start, end } = this._getState(); + + // console.log('state, start, end', start, end); + + const { position, width, height } = this._getLayoutAttrFromConfig(); + const startHandlerMinSize = startHandlerStyle.triggerMinSize ?? 40; + const endHandlerMinSize = endHandlerStyle.triggerMinSize ?? 40; + const group = this._container; + this._background = group.createOrUpdateChild( + 'background', + { + x: position.x, + y: position.y, + width, + height, + cursor: brushSelect ? 'crosshair' : 'auto', + ...backgroundStyle, + pickable: zoomLock ? false : (backgroundStyle.pickable ?? true) + }, + 'rect' + ) as IRect; + + /** 背景图表 */ + backgroundChartStyle.line?.visible && this._setPreviewAttributes('line', group); + backgroundChartStyle.area?.visible && this._setPreviewAttributes('area', group); + + /** drag mask */ + brushSelect && this.renderDragMask(); + + /** 选中背景 */ + if (this._isHorizontal) { + // 选中部分 + this._selectedBackground = group.createOrUpdateChild( + 'selectedBackground', + { + x: position.x + start * width, + y: position.y, + width: (end - start) * width, + height: height, + cursor: brushSelect ? 'crosshair' : 'move', + ...selectedBackgroundStyle, + pickable: zoomLock ? false : ((selectedBackgroundChartStyle as any).pickable ?? true) + }, + 'rect' + ) as IRect; + } else { + // 选中部分 + this._selectedBackground = group.createOrUpdateChild( + 'selectedBackground', + { + x: position.x, + y: position.y + start * height, + width, + height: (end - start) * height, + cursor: brushSelect ? 'crosshair' : 'move', + ...selectedBackgroundStyle, + pickable: zoomLock ? false : (selectedBackgroundStyle.pickable ?? true) + }, + 'rect' + ) as IRect; + } + + /** 选中的背景图表 */ + selectedBackgroundChartStyle.line?.visible && this._setSelectedPreviewAttributes('line', group); + selectedBackgroundChartStyle.area?.visible && this._setSelectedPreviewAttributes('area', group); + + /** 左右 和 中间手柄 */ + if (this._isHorizontal) { + if (middleHandlerStyle.visible) { + const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; + this._middleHandlerRect = group.createOrUpdateChild( + 'middleHandlerRect', + { + x: position.x + start * width, + y: position.y - middleHandlerBackgroundSize, + width: (end - start) * width, + height: middleHandlerBackgroundSize, + ...middleHandlerStyle.background?.style, + pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) + }, + 'rect' + ) as IRect; + this._middleHandlerSymbol = group.createOrUpdateChild( + 'middleHandlerSymbol', + { + x: position.x + ((start + end) / 2) * width, + y: position.y - middleHandlerBackgroundSize / 2, + strokeBoundsBuffer: 0, + angle: 0, + symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', + ...middleHandlerStyle.icon, + pickable: zoomLock ? false : (middleHandlerStyle.icon.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + } + this._startHandler = group.createOrUpdateChild( + 'startHandler', + { + x: position.x + start * width, + y: position.y + height / 2, + size: height, + symbolType: startHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + ...startHandlerStyle, + pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + this._endHandler = group.createOrUpdateChild( + 'endHandler', + { + x: position.x + end * width, + y: position.y + height / 2, + size: height, + symbolType: endHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + ...endHandlerStyle, + pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + // 透明mask构造热区, 热区大小配置来自handler bounds + const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); + const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); + const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); + const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); + + this._startHandlerMask = group.createOrUpdateChild( + 'startHandlerMask', + { + x: position.x + start * width - startHandlerWidth / 2, + y: position.y + height / 2 - startHandlerHeight / 2, + width: startHandlerWidth, + height: startHandlerHeight, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + this._endHandlerMask = group.createOrUpdateChild( + 'endHandlerMask', + { + x: position.x + end * width - endHandlerWidth / 2, + y: position.y + height / 2 - endHandlerHeight / 2, + width: endHandlerWidth, + height: endHandlerHeight, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + } else { + if (middleHandlerStyle.visible) { + const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; + + this._middleHandlerRect = group.createOrUpdateChild( + 'middleHandlerRect', + { + x: orient === 'left' ? position.x - middleHandlerBackgroundSize : position.x + width, + y: position.y + start * height, + width: middleHandlerBackgroundSize, + height: (end - start) * height, + ...middleHandlerStyle.background?.style, + pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) + }, + 'rect' + ) as IRect; + this._middleHandlerSymbol = group.createOrUpdateChild( + 'middleHandlerSymbol', + { + x: + orient === 'left' + ? position.x - middleHandlerBackgroundSize / 2 + : position.x + width + middleHandlerBackgroundSize / 2, + y: position.y + ((start + end) / 2) * height, + // size: height, + angle: 90 * (Math.PI / 180), + symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', + strokeBoundsBuffer: 0, + ...middleHandlerStyle.icon, + pickable: zoomLock ? false : (middleHandlerStyle.icon?.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + } + this._startHandler = group.createOrUpdateChild( + 'startHandler', + { + x: position.x + width / 2, + y: position.y + start * height, + size: width, + symbolType: startHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + ...startHandlerStyle, + pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + this._endHandler = group.createOrUpdateChild( + 'endHandler', + { + x: position.x + width / 2, + y: position.y + end * height, + size: width, + symbolType: endHandlerStyle.symbolType ?? 'square', + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + ...endHandlerStyle, + pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) + }, + 'symbol' + ) as ISymbol; + + // 透明mask构造热区, 热区大小配置来自handler bounds + const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); + const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); + const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); + const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); + + this._startHandlerMask = group.createOrUpdateChild( + 'startHandlerMask', + { + x: position.x + width / 2 + startHandlerWidth / 2, + y: position.y + start * height - startHandlerHeight / 2, + width: endHandlerHeight, + height: endHandlerWidth, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + this._endHandlerMask = group.createOrUpdateChild( + 'endHandlerMask', + { + x: position.x + width / 2 + endHandlerWidth / 2, + y: position.y + end * height - endHandlerHeight / 2, + width: endHandlerHeight, + height: endHandlerWidth, + fill: 'white', + fillOpacity: 0, + zIndex: 999, + ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), + pickable: !zoomLock + }, + 'rect' + ) as IRect; + } + + /** 左右文字 */ + if (this._showText) { + this._renderText(); + } + } + + /** 使用callback绘制背景图表 (数据和数据映射从外部传进来) */ + private _setPreviewAttributes(type: 'line' | 'area', group: IGroup) { + if (!this._previewGroup) { + this._previewGroup = group.createOrUpdateChild('previewGroup', { pickable: false }, 'group') as IGroup; + } + if (type === 'line') { + this._previewLine = this._previewGroup.createOrUpdateChild('previewLine', {}, 'line') as ILine; + } else { + this._previewArea = this._previewGroup.createOrUpdateChild( + 'previewArea', + { curveType: 'basis' }, + 'area' + ) as IArea; + } + + const { backgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + + type === 'line' && + this._previewLine.setAttributes({ + points: this._getPreviewLinePoints(), + curveType: 'basis', + pickable: false, + ...backgroundChartStyle.line + }); + type === 'area' && + this._previewArea.setAttributes({ + points: this._getPreviewAreaPoints(), + curveType: 'basis', + pickable: false, + ...backgroundChartStyle.area + }); + } + + private _computeBasePoints() { + const { orient } = this.attribute as DataZoomAttributes; + const { position, width, height } = this._getLayoutAttrFromConfig(); + let basePointStart: any; + let basePointEnd: any; + if (this._isHorizontal) { + basePointStart = [ + { + x: position.x, + y: position.y + height + } + ]; + basePointEnd = [ + { + x: position.x + width, + y: position.y + height + } + ]; + } else if (orient === 'left') { + basePointStart = [ + { + x: position.x + width, + y: position.y + } + ]; + basePointEnd = [ + { + x: position.x + width, + y: position.y + height + } + ]; + } else { + basePointStart = [ + { + x: position.x, + y: position.y + height + } + ]; + basePointEnd = [ + { + x: position.x, + y: position.y + } + ]; + } + return { + basePointStart, + basePointEnd + }; + } + + private _simplifyPoints(points: IPointLike[]) { + // 采样压缩率策略: 如果没做任何配置, 那么限制在niceCount内, 如果做了配置, 则按照配置计算 + const niceCount = 10000; // 经验值 + if (points.length > niceCount) { + const tolerance = this.attribute.tolerance ?? this._previewData.length / niceCount; + return flatten_simplify(points, tolerance, false); + } + return points; + } + + private _getPreviewLinePoints() { + let previewPoints = this._previewData.map(d => { + return { + x: this._previewPointsX && this._previewPointsX(d), + y: this._previewPointsY && this._previewPointsY(d) + }; + }); + // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 + if (previewPoints.length === 0) { + return previewPoints; + } + + // 采样 + previewPoints = this._simplifyPoints(previewPoints); + + const { basePointStart, basePointEnd } = this._computeBasePoints(); + return basePointStart.concat(previewPoints).concat(basePointEnd); + } + + private _getPreviewAreaPoints() { + let previewPoints: IPointLike[] = this._previewData.map(d => { + return { + x: this._previewPointsX && this._previewPointsX(d), + y: this._previewPointsY && this._previewPointsY(d), + x1: this._previewPointsX1 && this._previewPointsX1(d), + y1: this._previewPointsY1 && this._previewPointsY1(d) + }; + }); + // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 + if (previewPoints.length === 0) { + return previewPoints; + } + + // 采样 + previewPoints = this._simplifyPoints(previewPoints); + + const { basePointStart, basePointEnd } = this._computeBasePoints(); + return basePointStart.concat(previewPoints).concat(basePointEnd); + } + + /** 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) */ + private _setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { + if (!this._selectedPreviewGroupClip) { + this._selectedPreviewGroupClip = group.createOrUpdateChild( + 'selectedPreviewGroupClip', + { pickable: false }, + 'group' + ) as IGroup; + this._selectedPreviewGroup = this._selectedPreviewGroupClip.createOrUpdateChild( + 'selectedPreviewGroup', + {}, + 'group' + ) as IGroup; + } + + if (type === 'line') { + this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewLine', + {}, + 'line' + ) as ILine; + } else { + this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewArea', + { curveType: 'basis' }, + 'area' + ) as IArea; + } + + const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + + const { start, end } = this._getState(); + const { position, width, height } = this._getLayoutAttrFromConfig(); + this._selectedPreviewGroupClip.setAttributes({ + x: this._isHorizontal ? position.x + start * width : position.x, + y: this._isHorizontal ? position.y : position.y + start * height, + width: this._isHorizontal ? (end - start) * width : width, + height: this._isHorizontal ? height : (end - start) * height, + clip: true, + pickable: false + } as any); + this._selectedPreviewGroup.setAttributes({ + x: -(this._isHorizontal ? position.x + start * width : position.x), + y: -(this._isHorizontal ? position.y : position.y + start * height), + width: this._isHorizontal ? (end - start) * width : width, + height: this._isHorizontal ? height : (end - start) * height, + pickable: false + } as any); + type === 'line' && + this._selectedPreviewLine.setAttributes({ + points: this._getPreviewLinePoints(), + curveType: 'basis', + pickable: false, + ...selectedBackgroundChartStyle.line + }); + type === 'area' && + this._selectedPreviewArea.setAttributes({ + points: this._getPreviewAreaPoints(), + curveType: 'basis', + pickable: false, + ...selectedBackgroundChartStyle.area + }); + } + + private _setTextAttr(startTextBounds: IBoundsLike, endTextBounds: IBoundsLike) { + const { startTextStyle, endTextStyle } = this.attribute as DataZoomAttributes; + const { formatMethod: startTextFormat, ...restStartTextStyle } = startTextStyle; + const { formatMethod: endTextFormat, ...restEndTextStyle } = endTextStyle; + const { start, end } = this._getState(); + this._startValue = this._statePointToData(start); + this._endValue = this._statePointToData(end); + const { position, width, height } = this._getLayoutAttrFromConfig(); + + const startTextValue = startTextFormat ? startTextFormat(this._startValue) : this._startValue; + const endTextValue = endTextFormat ? endTextFormat(this._endValue) : this._endValue; + const componentBoundsLike = { + x1: position.x, + y1: position.y, + x2: position.x + width, + y2: position.y + height + }; + let startTextPosition: IPointLike; + let endTextPosition: IPointLike; + let startTextAlignStyle: any; + let endTextAlignStyle: any; + if (this._isHorizontal) { + startTextPosition = { + x: position.x + start * width, + y: position.y + height / 2 + }; + endTextPosition = { + x: position.x + end * width, + y: position.y + height / 2 + }; + startTextAlignStyle = { + textAlign: isTextOverflow(componentBoundsLike, startTextBounds, 'start', this._isHorizontal) ? 'left' : 'right', + textBaseline: restStartTextStyle?.textStyle?.textBaseline ?? 'middle' + }; + endTextAlignStyle = { + textAlign: isTextOverflow(componentBoundsLike, endTextBounds, 'end', this._isHorizontal) ? 'right' : 'left', + textBaseline: restEndTextStyle?.textStyle?.textBaseline ?? 'middle' + }; + } else { + startTextPosition = { + x: position.x + width / 2, + y: position.y + start * height + }; + endTextPosition = { + x: position.x + width / 2, + y: position.y + end * height + }; + startTextAlignStyle = { + textAlign: restStartTextStyle?.textStyle?.textAlign ?? 'center', + textBaseline: isTextOverflow(componentBoundsLike, startTextBounds, 'start', this._isHorizontal) + ? 'top' + : 'bottom' + }; + endTextAlignStyle = { + textAlign: restEndTextStyle?.textStyle?.textAlign ?? 'center', + textBaseline: isTextOverflow(componentBoundsLike, endTextBounds, 'end', this._isHorizontal) ? 'bottom' : 'top' + }; + } + + this._startText = this._maybeAddLabel( + this._container, + merge({}, restStartTextStyle, { + text: startTextValue, + x: startTextPosition.x, + y: startTextPosition.y, + visible: this._showText, + pickable: false, + childrenPickable: false, + textStyle: startTextAlignStyle + }), + `data-zoom-start-text-${position.x}-${position.y}` + ); + this._endText = this._maybeAddLabel( + this._container, + merge({}, restEndTextStyle, { + text: endTextValue, + x: endTextPosition.x, + y: endTextPosition.y, + visible: this._showText, + pickable: false, + childrenPickable: false, + textStyle: endTextAlignStyle + }), + `data-zoom-end-text-${position.x}-${position.y}` + ); + } + + _renderText() { + let startTextBounds: IBoundsLike | null = null; + let endTextBounds: IBoundsLike | null = null; + + // 第一次绘制 + this._setTextAttr(startTextBounds, endTextBounds); + // 得到bounds + startTextBounds = this._startText.AABBBounds; + endTextBounds = this._endText.AABBBounds; + + // 第二次绘制: 将text限制在组件bounds内 + this._setTextAttr(startTextBounds, endTextBounds); + // 得到bounds + startTextBounds = this._startText.AABBBounds; + endTextBounds = this._endText.AABBBounds; + const { x1, x2, y1, y2 } = startTextBounds; + const { dx: startTextDx = 0, dy: startTextDy = 0 } = this.attribute.startTextStyle; + + // 第三次绘制: 避免startText和endText重叠, 如果重叠了, 对startText做位置调整(考虑到调整的最小化,只单独调整startText而不调整endText) + if (new Bounds().set(x1, y1, x2, y2).intersects(endTextBounds)) { + const direction = this.attribute.orient === 'bottom' || this.attribute.orient === 'right' ? -1 : 1; + if (this._isHorizontal) { + this._startText.setAttribute('dy', startTextDy + direction * Math.abs(endTextBounds.y1 - endTextBounds.y2)); + } else { + this._startText.setAttribute('dx', startTextDx + direction * Math.abs(endTextBounds.x1 - endTextBounds.x2)); + } + } else { + if (this._isHorizontal) { + this._startText.setAttribute('dy', startTextDy); + } else { + this._startText.setAttribute('dx', startTextDx); + } + } + + // console.log('this._showText', this._showText, cloneDeep(this._startText.attribute)); + } + + private _maybeAddLabel(container: IGroup, attributes: TagAttributes, name: string): Tag { + let labelShape = container.find(node => node.name === name, true) as unknown as Tag; + if (labelShape) { + labelShape.setAttributes(attributes); + } else { + labelShape = new Tag(attributes); + labelShape.name = name; + container.add(labelShape as unknown as INode); + } + + return labelShape; + } +} diff --git a/packages/vrender-components/src/data-zoom/utils.ts b/packages/vrender-components/src/data-zoom/utils.ts new file mode 100644 index 000000000..408104000 --- /dev/null +++ b/packages/vrender-components/src/data-zoom/utils.ts @@ -0,0 +1,37 @@ +import type { IBoundsLike } from '@visactor/vutils'; + +/** + * 判断文字是否超出datazoom范围 + */ +export const isTextOverflow = ( + componentBoundsLike: IBoundsLike, + textBounds: IBoundsLike | null, + layout: 'start' | 'end', + isHorizontal: boolean +) => { + if (!textBounds) { + return false; + } + if (isHorizontal) { + if (layout === 'start') { + if (textBounds.x1 < componentBoundsLike.x1) { + return true; + } + } else { + if (textBounds.x2 > componentBoundsLike.x2) { + return true; + } + } + } else { + if (layout === 'start') { + if (textBounds.y1 < componentBoundsLike.y1) { + return true; + } + } else { + if (textBounds.y2 > componentBoundsLike.y2) { + return true; + } + } + } + return false; +}; From c6b7dc3ff086f42e04eb8246b3934a04bf81a462 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Tue, 6 May 2025 20:07:30 +0800 Subject: [PATCH 2/6] fix: text display and interactive problem --- .../examples/data-zoom-preview-set-state.ts | 2 +- .../__tests__/browser/examples/data-zoom.ts | 33 +- .../src/data-zoom/data-zoom-3.ts | 1250 ---------------- .../src/data-zoom/data-zoom-4.ts | 1272 ----------------- .../src/data-zoom/data-zoom.ts | 101 +- .../src/data-zoom/interaction.ts | 151 +- .../src/data-zoom/renderer.ts | 416 +++--- .../vrender-components/src/data-zoom/type.ts | 20 + 8 files changed, 375 insertions(+), 2870 deletions(-) delete mode 100644 packages/vrender-components/src/data-zoom/data-zoom-3.ts delete mode 100644 packages/vrender-components/src/data-zoom/data-zoom-4.ts diff --git a/packages/vrender-components/__tests__/browser/examples/data-zoom-preview-set-state.ts b/packages/vrender-components/__tests__/browser/examples/data-zoom-preview-set-state.ts index ed314a716..39ade9d01 100644 --- a/packages/vrender-components/__tests__/browser/examples/data-zoom-preview-set-state.ts +++ b/packages/vrender-components/__tests__/browser/examples/data-zoom-preview-set-state.ts @@ -36,7 +36,7 @@ export function run() { } }); - // dataZoom.setStartAndEnd(0, 1); + dataZoom.setStartAndEnd(0, 1); dataZoom.setPreviewData(data); dataZoom.setPreviewPointsX(d => d.x); dataZoom.setPreviewPointsY(d => d.y); diff --git a/packages/vrender-components/__tests__/browser/examples/data-zoom.ts b/packages/vrender-components/__tests__/browser/examples/data-zoom.ts index 17ec1a3d1..eae09c640 100644 --- a/packages/vrender-components/__tests__/browser/examples/data-zoom.ts +++ b/packages/vrender-components/__tests__/browser/examples/data-zoom.ts @@ -5,22 +5,19 @@ import render from '../../util/render'; import { DataZoom } from '../../../src'; export function run() { - console.log('RectCrosshair'); - - const dataZoom = new DataZoom({ + const dataZoomdisableTriggerEvent = new DataZoom({ start: 0.2, end: 0.5, + // maxSpan: 0.4, position: { x: 50, - y: 235 + y: 75 }, size: { width: 400, height: 30 }, - showDetail: false, - delayTime: 1000, - brushSelect: true, + // brushSelect: false, backgroundChartStyle: { line: { visible: false @@ -31,22 +28,25 @@ export function run() { }, middleHandlerStyle: { visible: true - } + }, + disableTriggerEvent: false, + showDetail: 'auto' }); - const dataZoomdisableTriggerEvent = new DataZoom({ + const dataZoom = new DataZoom({ start: 0.2, end: 0.5, - // maxSpan: 0.4, position: { x: 50, - y: 75 + y: 235 }, size: { width: 400, height: 30 }, - // brushSelect: false, + showDetail: 'auto', + delayTime: 100, + brushSelect: true, backgroundChartStyle: { line: { visible: false @@ -57,15 +57,14 @@ export function run() { }, middleHandlerStyle: { visible: true - }, - disableTriggerEvent: false + } }); + console.log('dataZoom', dataZoom); - vglobal.supportsPointerEvents = false; + // vglobal.supportsPointerEvents = false; - const stage = render([dataZoom, dataZoomdisableTriggerEvent], 'main'); + const stage = render([dataZoomdisableTriggerEvent, dataZoom], 'main'); stage.defaultLayer.scale(1.5, 1.5); - stage.x = 10; // stage.addEventListener('pointermove', e => { // dataZoom.setLocation({ diff --git a/packages/vrender-components/src/data-zoom/data-zoom-3.ts b/packages/vrender-components/src/data-zoom/data-zoom-3.ts deleted file mode 100644 index 1b40f8672..000000000 --- a/packages/vrender-components/src/data-zoom/data-zoom-3.ts +++ /dev/null @@ -1,1250 +0,0 @@ -import type { FederatedPointerEvent, IArea, IGroup, ILine, IRect, ISymbol, INode } from '@visactor/vrender-core'; -// eslint-disable-next-line no-duplicate-imports -import { flatten_simplify, vglobal } from '@visactor/vrender-core'; -import type { IBoundsLike, IPointLike } from '@visactor/vutils'; -// eslint-disable-next-line no-duplicate-imports -import { Bounds, array, clamp, debounce, isFunction, isValid, merge, throttle } from '@visactor/vutils'; -import { AbstractComponent } from '../core/base'; -import type { TagAttributes } from '../tag'; -// eslint-disable-next-line no-duplicate-imports -import { Tag } from '../tag'; -import { DEFAULT_DATA_ZOOM_ATTRIBUTES, DEFAULT_HANDLER_ATTR_MAP } from './config'; -import { DataZoomActiveTag } from './type'; -// eslint-disable-next-line no-duplicate-imports -import type { DataZoomAttributes } from './type'; -import type { ComponentOptions } from '../interface'; -import { loadDataZoomComponent } from './register'; -import { getEndTriggersOfDrag } from '../util/event'; - -const delayMap = { - debounce: debounce, - throttle: throttle -}; - -loadDataZoomComponent(); -export class DataZoom extends AbstractComponent> { - name = 'dataZoom'; - static defaultAttributes = DEFAULT_DATA_ZOOM_ATTRIBUTES; - - /** 容器相关 */ - private _container!: IGroup; - - private _background!: IRect; - - /** 中间量 */ - private _isHorizontal: boolean; - private _layoutCacheFromConfig: any; - - /** 手柄 */ - private _startHandlerMask!: IRect; - private _startHandler!: ISymbol; - private _middleHandlerSymbol!: ISymbol; - private _middleHandlerRect!: IRect; - private _endHandlerMask!: IRect; - private _endHandler!: ISymbol; - private _selectedBackground!: IRect; - private _dragMask!: IRect; - private _startText!: Tag; - private _endText!: Tag; - private _startValue!: string | number; - private _endValue!: string | number; - private _showText!: boolean; - - /** 背景图表 */ - private _previewData: any[] = []; - private _previewGroup!: IGroup; - private _previewLine!: ILine; - private _previewArea!: IArea; - private _selectedPreviewGroupClip!: IGroup; - private _selectedPreviewGroup!: IGroup; - private _selectedPreviewLine!: ILine; - private _selectedPreviewArea!: IArea; - - /** 交互状态 */ - private _activeTag!: DataZoomActiveTag; - private _activeItem!: any; - private _activeState = false; - private _activeCache: { - startPos: IPointLike; - lastPos: IPointLike; - } = { - startPos: { x: 0, y: 0 }, - lastPos: { x: 0, y: 0 } - }; - private _layoutCache: { - attPos: 'x' | 'y'; - attSize: 'width' | 'height'; - size: number; - } = { - attPos: 'x', - attSize: 'width', - size: 0 - }; - private _spanCache: number; - /** 起始状态 */ - private state = { - start: 0, - end: 1 - }; - - /** 回调函数 */ - private _previewPointsX!: (datum: any) => number; - private _previewPointsY!: (datum: any) => number; - private _previewPointsX1!: (datum: any) => number; - private _previewPointsY1!: (datum: any) => number; - private _statePointToData: (state: number) => any = state => state; - - constructor(attributes: DataZoomAttributes, options?: ComponentOptions) { - super(options?.skipDefault ? attributes : merge({}, DataZoom.defaultAttributes, attributes)); - this._initStates(); - } - - setAttributes(params: Partial>, forceUpdateTag?: boolean): void { - super.setAttributes(params, forceUpdateTag); - this._initStates(); - } - - private _initStates() { - const { - start, - end, - orient, - previewData, - showDetail, - previewPointsX, - previewPointsY, - previewPointsX1, - previewPointsY1 - } = this.attribute as DataZoomAttributes; - if (showDetail === 'auto') { - this._showText = false as boolean; - } else { - this._showText = showDetail as boolean; - } - start && (this.state.start = start); - end && (this.state.end = end); - const { width, height } = this._getLayoutAttrFromConfig(); - this._spanCache = this.state.end - this.state.start; - this._isHorizontal = orient === 'top' || orient === 'bottom'; - this._layoutCache.size = this._isHorizontal ? width : height; - this._layoutCache.attPos = this._isHorizontal ? 'x' : 'y'; - this._layoutCache.attSize = this._isHorizontal ? 'width' : 'height'; - previewData && (this._previewData = previewData); - isFunction(previewPointsX) && (this._previewPointsX = previewPointsX); - isFunction(previewPointsY) && (this._previewPointsY = previewPointsY); - isFunction(previewPointsX1) && (this._previewPointsX1 = previewPointsX1); - isFunction(previewPointsY1) && (this._previewPointsY1 = previewPointsY1); - } - - protected bindEvents(): void { - if (this.attribute.disableTriggerEvent) { - this.setAttribute('childrenPickable', false); - return; - } - const { showDetail, brushSelect } = this.attribute as DataZoomAttributes; - // 拖拽开始 - this._startHandlerMask?.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'start') as unknown as EventListener - ); - this._endHandlerMask?.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'end') as unknown as EventListener - ); - this._middleHandlerSymbol?.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleSymbol') as unknown as EventListener - ); - this._middleHandlerRect?.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleRect') as unknown as EventListener - ); - const selectedTag = brushSelect ? 'background' : 'middleRect'; - this._selectedBackground?.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener - ); - brushSelect && - this._background?.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener - ); - brushSelect && - this._previewGroup?.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener - ); - this._selectedPreviewGroup?.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener - ); - // hover - if (showDetail === 'auto') { - (this as unknown as IGroup).addEventListener('pointerenter', this._onHandlerPointerEnter as EventListener); - (this as unknown as IGroup).addEventListener('pointerleave', this._onHandlerPointerLeave as EventListener); - } - (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { - passive: false - }); - } - private _handleTouchMove = (e: TouchEvent) => { - if (this._activeState) { - /** - * https://developer.mozilla.org/zh-CN/docs/Web/CSS/overscroll-behavior - * 由于浏览器的overscroll-behavior属性,需要在move的时候阻止浏览器默认行为,否则会因为浏览器检测到scroll行为,阻止pointer事件, - * 抛出pointercancel事件,导致拖拽行为中断。 - */ - e.preventDefault(); - } - }; - - /** - * 拖拽开始事件 - * @description 开启activeState + 通过tag判断事件在哪个元素上触发 并 更新交互坐标 - */ - private _onHandlerPointerDown = (e: FederatedPointerEvent, tag: string) => { - // 清除之前的事件,防止没有被清除掉 - this._clearDragEvents(); - if (tag === 'start') { - this._activeTag = DataZoomActiveTag.startHandler; - this._activeItem = this._startHandlerMask; - } else if (tag === 'end') { - this._activeTag = DataZoomActiveTag.endHandler; - this._activeItem = this._endHandlerMask; - } else if (tag === 'middleRect') { - this._activeTag = DataZoomActiveTag.middleHandler; - this._activeItem = this._middleHandlerRect; - } else if (tag === 'middleSymbol') { - this._activeTag = DataZoomActiveTag.middleHandler; - this._activeItem = this._middleHandlerSymbol; - } else if (tag === 'background') { - this._activeTag = DataZoomActiveTag.background; - this._activeItem = this._background; - } - this._activeState = true; - this._activeCache.startPos = this._eventPosToStagePos(e); - this._activeCache.lastPos = this._eventPosToStagePos(e); - const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; - const triggers = getEndTriggersOfDrag(); - - /** - * move的时候,需要通过 capture: true,能够在捕获截断被拦截, - */ - evtTarget.addEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); - (this as unknown as IGroup).addEventListener('pointermove', this._onHandlerPointerMove, { - capture: true - }); - - triggers.forEach((trigger: string) => { - evtTarget.addEventListener(trigger, this._onHandlerPointerUp); - }); - }; - - /** - * 拖拽进行事件 - * @description 分为以下四种情况: - * 1. 在背景 or 背景图表上拖拽 (activeTag === 'background'): 改变lastPos => only renderDragMask - * 2. 在middleHandler上拖拽 (activeTag === 'middleHandler'): 改变lastPos、start & end + 边界处理: 防止拖拽结果超出背景 => render - * 3. 在startHandler上拖拽 (activeTag === 'startHandler'): 改变lastPos、start & end + 边界处理: startHandler和endHandler交换 => render - * 4. 在endHandler上拖拽,同上 - */ - private _pointerMove = (e: FederatedPointerEvent) => { - const { brushSelect, realTime = true } = this.attribute as DataZoomAttributes; - const pos = this._eventPosToStagePos(e); - const { attPos, size } = this._layoutCache; - const dis = (pos[attPos] - this._activeCache.lastPos[attPos]) / size; - - let { start, end } = this.state; - let shouldRender = true; - if (this._activeState) { - if (this._activeTag === DataZoomActiveTag.middleHandler) { - ({ start, end } = this._moveZoomWithMiddle(dis)); - } else if (this._activeTag === DataZoomActiveTag.startHandler) { - ({ start, end } = this._moveZoomWithHandler('start', dis)); - } else if (this._activeTag === DataZoomActiveTag.endHandler) { - ({ start, end } = this._moveZoomWithHandler('end', dis)); - } else if (this._activeTag === DataZoomActiveTag.background && brushSelect) { - ({ start, end } = this._renderDragMask(pos)); - shouldRender = false; - } - this._activeCache.lastPos = pos; - } - - // 避免attributes相同时, 重复渲染 - if (this.state.start !== start || this.state.end !== end) { - this._setStateAttr(start, end, shouldRender); - if (realTime) { - this._dispatchEvent('change', { - start, - end, - tag: this._activeTag - }); - } - } - }; - private _onHandlerPointerMove = - this.attribute.delayTime === 0 - ? this._pointerMove - : delayMap[this.attribute.delayType](this._pointerMove, this.attribute.delayTime); - - /** - * @description 拖拽middleHandler, 改变start和end - */ - private _moveZoomWithMiddle(dis: number) { - // 拖拽middleHandler时,限制在background范围内 - if (dis > 0 && this.state.end + dis > 1) { - dis = 1 - this.state.end; - } else if (dis < 0 && this.state.start + dis < 0) { - dis = -this.state.start; - } - return { - start: clamp(this.state.start + dis, 0, 1), - end: clamp(this.state.end + dis, 0, 1) - }; - } - - /** - * @description 拖拽startHandler/endHandler, 改变start和end - */ - private _moveZoomWithHandler(handler: 'start' | 'end', dis: number) { - const { start, end } = this.state; - let newStart = start; - let newEnd = end; - if (handler === 'start') { - if (start + dis > end) { - newStart = end; - newEnd = start + dis; - this._activeTag = DataZoomActiveTag.endHandler; - } else { - newStart = start + dis; - } - } else if (handler === 'end') { - if (end + dis < start) { - newEnd = start; - newStart = end + dis; - this._activeTag = DataZoomActiveTag.startHandler; - } else { - newEnd = end + dis; - } - } - return { - start: clamp(newStart, 0, 1), - end: clamp(newEnd, 0, 1) - }; - } - - /** - * @description 绘制拖拽时的mask - */ - private _renderDragMask(pos?: IPointLike) { - const { dragMaskStyle } = this.attribute as DataZoomAttributes; - const { position, width, height } = this._getLayoutAttrFromConfig(); - const currentPos = pos ?? this._activeCache.lastPos; - let start = clamp( - (this._activeCache.startPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / width, - 0, - 1 - ); - let end = clamp((currentPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / width, 0, 1); - if (start > end) { - [start, end] = [end, start]; - } - - // drag部分 - if (this._isHorizontal) { - this._dragMask = this._container.createOrUpdateChild( - 'dragMask', - { - x: position.x + start * width, - y: position.y, - width: (end - start) * width, - height: height, - ...dragMaskStyle - }, - 'rect' - ) as IRect; - } else { - this._dragMask = this._container.createOrUpdateChild( - 'dragMask', - { - x: position.x, - y: position.y + start * height, - width, - height: (end - start) * height, - ...dragMaskStyle - }, - 'rect' - ) as IRect; - } - return { start, end }; - } - /** - * 拖拽结束事件 - * @description 关闭activeState + 边界情况处理: 防止拖拽后start和end过近 - */ - private _onHandlerPointerUp = (e: FederatedPointerEvent) => { - if (this._activeState) { - // brush的时候, 只改变了state, 没有触发重新渲染, 在抬起鼠标时触发 - if (this._activeTag === DataZoomActiveTag.background) { - this._setStateAttr(this.state.start, this.state.end, true); - } - } - this._activeState = false; - // 此次dispatch不能被省略 - // 因为pointermove时, 已经将状态更新至最新, 所以在pointerup时, 必定start = state.start & end = state.end - // 而realTime = false时, 需要依赖这次dispatch来更新图表图元 - this._dispatchEvent('change', { - start: this.state.start, - end: this.state.end, - tag: this._activeTag - }); - this._clearDragEvents(); - }; - - /** - * 鼠标进入事件 - * @description 鼠标进入选中部分出现start和end文字 - */ - private _onHandlerPointerEnter(e: FederatedPointerEvent) { - this._showText = true; - this._renderText(); - } - - /** - * 鼠标移出事件 - * @description 鼠标移出选中部分不出现start和end文字 - */ - private _onHandlerPointerLeave(e: FederatedPointerEvent) { - this._showText = false; - this._renderText(); - } - - /** - * 判断文字是否超出datazoom范围 - */ - private _isTextOverflow(componentBoundsLike: IBoundsLike, textBounds: IBoundsLike | null, layout: 'start' | 'end') { - if (!textBounds) { - return false; - } - if (this._isHorizontal) { - if (layout === 'start') { - if (textBounds.x1 < componentBoundsLike.x1) { - return true; - } - } else { - if (textBounds.x2 > componentBoundsLike.x2) { - return true; - } - } - } else { - if (layout === 'start') { - if (textBounds.y1 < componentBoundsLike.y1) { - return true; - } - } else { - if (textBounds.y2 > componentBoundsLike.y2) { - return true; - } - } - } - return false; - } - - private _setTextAttr(startTextBounds: IBoundsLike, endTextBounds: IBoundsLike) { - const { startTextStyle, endTextStyle } = this.attribute as DataZoomAttributes; - const { formatMethod: startTextFormat, ...restStartTextStyle } = startTextStyle; - const { formatMethod: endTextFormat, ...restEndTextStyle } = endTextStyle; - const { start, end } = this.state; - this._startValue = this._statePointToData(start); - this._endValue = this._statePointToData(end); - const { position, width, height } = this._getLayoutAttrFromConfig(); - - const startTextValue = startTextFormat ? startTextFormat(this._startValue) : this._startValue; - const endTextValue = endTextFormat ? endTextFormat(this._endValue) : this._endValue; - const componentBoundsLike = { - x1: position.x, - y1: position.y, - x2: position.x + width, - y2: position.y + height - }; - let startTextPosition: IPointLike; - let endTextPosition: IPointLike; - let startTextAlignStyle: any; - let endTextAlignStyle: any; - if (this._isHorizontal) { - startTextPosition = { - x: position.x + start * width, - y: position.y + height / 2 - }; - endTextPosition = { - x: position.x + end * width, - y: position.y + height / 2 - }; - startTextAlignStyle = { - textAlign: this._isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'left' : 'right', - textBaseline: restStartTextStyle?.textStyle?.textBaseline ?? 'middle' - }; - endTextAlignStyle = { - textAlign: this._isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'right' : 'left', - textBaseline: restEndTextStyle?.textStyle?.textBaseline ?? 'middle' - }; - } else { - startTextPosition = { - x: position.x + width / 2, - y: position.y + start * height - }; - endTextPosition = { - x: position.x + width / 2, - y: position.y + end * height - }; - startTextAlignStyle = { - textAlign: restStartTextStyle?.textStyle?.textAlign ?? 'center', - textBaseline: this._isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'top' : 'bottom' - }; - endTextAlignStyle = { - textAlign: restEndTextStyle?.textStyle?.textAlign ?? 'center', - textBaseline: this._isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'bottom' : 'top' - }; - } - - this._startText = this._maybeAddLabel( - this._container, - merge({}, restStartTextStyle, { - text: startTextValue, - x: startTextPosition.x, - y: startTextPosition.y, - visible: this._showText, - pickable: false, - childrenPickable: false, - textStyle: startTextAlignStyle - }), - `data-zoom-start-text-${position}` - ); - this._endText = this._maybeAddLabel( - this._container, - merge({}, restEndTextStyle, { - text: endTextValue, - x: endTextPosition.x, - y: endTextPosition.y, - visible: this._showText, - pickable: false, - childrenPickable: false, - textStyle: endTextAlignStyle - }), - `data-zoom-end-text-${position}` - ); - } - - private _renderText() { - let startTextBounds: IBoundsLike | null = null; - let endTextBounds: IBoundsLike | null = null; - - // 第一次绘制 - this._setTextAttr(startTextBounds, endTextBounds); - // 得到bounds - startTextBounds = this._startText.AABBBounds; - endTextBounds = this._endText.AABBBounds; - - // 第二次绘制: 将text限制在组件bounds内 - this._setTextAttr(startTextBounds, endTextBounds); - // 得到bounds - startTextBounds = this._startText.AABBBounds; - endTextBounds = this._endText.AABBBounds; - const { x1, x2, y1, y2 } = startTextBounds; - const { dx: startTextDx = 0, dy: startTextDy = 0 } = this.attribute.startTextStyle; - - // 第三次绘制: 避免startText和endText重叠, 如果重叠了, 对startText做位置调整(考虑到调整的最小化,只单独调整startText而不调整endText) - if (new Bounds().set(x1, y1, x2, y2).intersects(endTextBounds)) { - const direction = this.attribute.orient === 'bottom' || this.attribute.orient === 'right' ? -1 : 1; - if (this._isHorizontal) { - this._startText.setAttribute('dy', startTextDy + direction * Math.abs(endTextBounds.y1 - endTextBounds.y2)); - } else { - this._startText.setAttribute('dx', startTextDx + direction * Math.abs(endTextBounds.x1 - endTextBounds.x2)); - } - } else { - if (this._isHorizontal) { - this._startText.setAttribute('dy', startTextDy); - } else { - this._startText.setAttribute('dx', startTextDx); - } - } - } - - /** - * 获取背景框中的位置和宽高 - * @description 实际绘制的背景框中的高度或宽度 减去 中间手柄的高度或宽度 - */ - private _getLayoutAttrFromConfig() { - if (this._layoutCacheFromConfig) { - return this._layoutCacheFromConfig; - } - const { - position: positionConfig, - size, - orient, - middleHandlerStyle = {}, - startHandlerStyle = {}, - endHandlerStyle = {}, - backgroundStyle = {} - } = this.attribute as DataZoomAttributes; - const { width: widthConfig, height: heightConfig } = size; - const middleHandlerSize = middleHandlerStyle.background?.size ?? 10; - - // 如果middleHandler显示的话,要将其宽高计入datazoom宽高 - let width; - let height; - let position; - if (middleHandlerStyle.visible) { - if (this._isHorizontal) { - width = widthConfig; - height = heightConfig - middleHandlerSize; - position = { - x: positionConfig.x, - y: positionConfig.y + middleHandlerSize - }; - } else { - width = widthConfig - middleHandlerSize; - height = heightConfig; - position = { - x: positionConfig.x + (orient === 'left' ? middleHandlerSize : 0), - y: positionConfig.y - }; - } - } else { - width = widthConfig; - height = heightConfig; - position = positionConfig; - } - - const startHandlerSize = (startHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); - const endHandlerSize = (endHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); - // 如果startHandler显示的话,要将其宽高计入dataZoom宽高 - if (startHandlerStyle.visible) { - if (this._isHorizontal) { - width -= (startHandlerSize + endHandlerSize) / 2; - position = { - x: position.x + startHandlerSize / 2, - y: position.y - }; - } else { - height -= (startHandlerSize + endHandlerSize) / 2; - position = { - x: position.x, - y: position.y + startHandlerSize / 2 - }; - } - } - - // stroke 需计入宽高, 否则dataZoom在画布边缘会被裁剪lineWidth / 2 - height += backgroundStyle.lineWidth / 2 ?? 1; - width += backgroundStyle.lineWidth / 2 ?? 1; - - this._layoutCacheFromConfig = { - position, - width, - height - }; - return this._layoutCacheFromConfig; - } - - /** state 边界处理 */ - private _setStateAttr(start: number, end: number, shouldRender: boolean) { - const { zoomLock = false, minSpan = 0, maxSpan = 1 } = this.attribute as DataZoomAttributes; - const span = end - start; - if (span !== this._spanCache && (zoomLock || span < minSpan || span > maxSpan)) { - return; - } - this._spanCache = span; - this.state.start = start; - this.state.end = end; - shouldRender && this.setAttributes({ start, end }); - } - - /** 事件系统坐标转换为stage坐标 */ - private _eventPosToStagePos(e: FederatedPointerEvent) { - // updateSpec过程中交互的话, stage可能为空 - return this.stage?.eventPointTransform(e) ?? { x: 0, y: 0 }; - } - - private _clearDragEvents() { - const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; - const triggers = getEndTriggersOfDrag(); - - evtTarget.removeEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); - triggers.forEach((trigger: string) => { - evtTarget.removeEventListener(trigger, this._onHandlerPointerUp); - }); - - (this as unknown as IGroup).removeEventListener('pointermove', this._onHandlerPointerMove, { - capture: true - }); - } - - protected render() { - this._layoutCacheFromConfig = null; - const { - // start, - // end, - orient, - backgroundStyle, - backgroundChartStyle = {}, - selectedBackgroundStyle = {}, - selectedBackgroundChartStyle = {}, - middleHandlerStyle = {}, - startHandlerStyle = {}, - endHandlerStyle = {}, - brushSelect, - zoomLock - } = this.attribute as DataZoomAttributes; - const { start, end } = this.state; - - // console.log('state, start, end', start, end); - - const { position, width, height } = this._getLayoutAttrFromConfig(); - const startHandlerMinSize = startHandlerStyle.triggerMinSize ?? 40; - const endHandlerMinSize = endHandlerStyle.triggerMinSize ?? 40; - const group = (this as unknown as IGroup).createOrUpdateChild('dataZoom-container', {}, 'group') as IGroup; - this._container = group; - this._background = group.createOrUpdateChild( - 'background', - { - x: position.x, - y: position.y, - width, - height, - cursor: brushSelect ? 'crosshair' : 'auto', - ...backgroundStyle, - pickable: zoomLock ? false : (backgroundStyle.pickable ?? true) - }, - 'rect' - ) as IRect; - - /** 背景图表 */ - backgroundChartStyle.line?.visible && this._setPreviewAttributes('line', group); - backgroundChartStyle.area?.visible && this._setPreviewAttributes('area', group); - - /** drag mask */ - brushSelect && this._renderDragMask(); - - /** 选中背景 */ - if (this._isHorizontal) { - // 选中部分 - this._selectedBackground = group.createOrUpdateChild( - 'selectedBackground', - { - x: position.x + start * width, - y: position.y, - width: (end - start) * width, - height: height, - cursor: brushSelect ? 'crosshair' : 'move', - ...selectedBackgroundStyle, - pickable: zoomLock ? false : ((selectedBackgroundChartStyle as any).pickable ?? true) - }, - 'rect' - ) as IRect; - } else { - // 选中部分 - this._selectedBackground = group.createOrUpdateChild( - 'selectedBackground', - { - x: position.x, - y: position.y + start * height, - width, - height: (end - start) * height, - cursor: brushSelect ? 'crosshair' : 'move', - ...selectedBackgroundStyle, - pickable: zoomLock ? false : (selectedBackgroundStyle.pickable ?? true) - }, - 'rect' - ) as IRect; - } - - /** 选中的背景图表 */ - selectedBackgroundChartStyle.line?.visible && this._setSelectedPreviewAttributes('line', group); - selectedBackgroundChartStyle.area?.visible && this._setSelectedPreviewAttributes('area', group); - - /** 左右 和 中间手柄 */ - if (this._isHorizontal) { - if (middleHandlerStyle.visible) { - const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; - this._middleHandlerRect = group.createOrUpdateChild( - 'middleHandlerRect', - { - x: position.x + start * width, - y: position.y - middleHandlerBackgroundSize, - width: (end - start) * width, - height: middleHandlerBackgroundSize, - ...middleHandlerStyle.background?.style, - pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) - }, - 'rect' - ) as IRect; - this._middleHandlerSymbol = group.createOrUpdateChild( - 'middleHandlerSymbol', - { - x: position.x + ((start + end) / 2) * width, - y: position.y - middleHandlerBackgroundSize / 2, - strokeBoundsBuffer: 0, - angle: 0, - symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', - ...middleHandlerStyle.icon, - pickable: zoomLock ? false : (middleHandlerStyle.icon.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - } - this._startHandler = group.createOrUpdateChild( - 'startHandler', - { - x: position.x + start * width, - y: position.y + height / 2, - size: height, - symbolType: startHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - ...startHandlerStyle, - pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - this._endHandler = group.createOrUpdateChild( - 'endHandler', - { - x: position.x + end * width, - y: position.y + height / 2, - size: height, - symbolType: endHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - ...endHandlerStyle, - pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - - // 透明mask构造热区, 热区大小配置来自handler bounds - const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); - const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); - const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); - const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); - - this._startHandlerMask = group.createOrUpdateChild( - 'startHandlerMask', - { - x: position.x + start * width - startHandlerWidth / 2, - y: position.y + height / 2 - startHandlerHeight / 2, - width: startHandlerWidth, - height: startHandlerHeight, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - this._endHandlerMask = group.createOrUpdateChild( - 'endHandlerMask', - { - x: position.x + end * width - endHandlerWidth / 2, - y: position.y + height / 2 - endHandlerHeight / 2, - width: endHandlerWidth, - height: endHandlerHeight, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - } else { - if (middleHandlerStyle.visible) { - const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; - - this._middleHandlerRect = group.createOrUpdateChild( - 'middleHandlerRect', - { - x: orient === 'left' ? position.x - middleHandlerBackgroundSize : position.x + width, - y: position.y + start * height, - width: middleHandlerBackgroundSize, - height: (end - start) * height, - ...middleHandlerStyle.background?.style, - pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) - }, - 'rect' - ) as IRect; - this._middleHandlerSymbol = group.createOrUpdateChild( - 'middleHandlerSymbol', - { - x: - orient === 'left' - ? position.x - middleHandlerBackgroundSize / 2 - : position.x + width + middleHandlerBackgroundSize / 2, - y: position.y + ((start + end) / 2) * height, - // size: height, - angle: 90 * (Math.PI / 180), - symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', - strokeBoundsBuffer: 0, - ...middleHandlerStyle.icon, - pickable: zoomLock ? false : (middleHandlerStyle.icon?.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - } - this._startHandler = group.createOrUpdateChild( - 'startHandler', - { - x: position.x + width / 2, - y: position.y + start * height, - size: width, - symbolType: startHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - ...startHandlerStyle, - pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - - this._endHandler = group.createOrUpdateChild( - 'endHandler', - { - x: position.x + width / 2, - y: position.y + end * height, - size: width, - symbolType: endHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - ...endHandlerStyle, - pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - - // 透明mask构造热区, 热区大小配置来自handler bounds - const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); - const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); - const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); - const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); - - this._startHandlerMask = group.createOrUpdateChild( - 'startHandlerMask', - { - x: position.x + width / 2 + startHandlerWidth / 2, - y: position.y + start * height - startHandlerHeight / 2, - width: endHandlerHeight, - height: endHandlerWidth, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - this._endHandlerMask = group.createOrUpdateChild( - 'endHandlerMask', - { - x: position.x + width / 2 + endHandlerWidth / 2, - y: position.y + end * height - endHandlerHeight / 2, - width: endHandlerHeight, - height: endHandlerWidth, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - } - - /** 左右文字 */ - if (this._showText) { - this._renderText(); - } - } - - private _computeBasePoints() { - const { orient } = this.attribute as DataZoomAttributes; - const { position, width, height } = this._getLayoutAttrFromConfig(); - let basePointStart: any; - let basePointEnd: any; - if (this._isHorizontal) { - basePointStart = [ - { - x: position.x, - y: position.y + height - } - ]; - basePointEnd = [ - { - x: position.x + width, - y: position.y + height - } - ]; - } else if (orient === 'left') { - basePointStart = [ - { - x: position.x + width, - y: position.y - } - ]; - basePointEnd = [ - { - x: position.x + width, - y: position.y + height - } - ]; - } else { - basePointStart = [ - { - x: position.x, - y: position.y + height - } - ]; - basePointEnd = [ - { - x: position.x, - y: position.y - } - ]; - } - return { - basePointStart, - basePointEnd - }; - } - - private _simplifyPoints(points: IPointLike[]) { - // 采样压缩率策略: 如果没做任何配置, 那么限制在niceCount内, 如果做了配置, 则按照配置计算 - const niceCount = 10000; // 经验值 - if (points.length > niceCount) { - const tolerance = this.attribute.tolerance ?? this._previewData.length / niceCount; - return flatten_simplify(points, tolerance, false); - } - return points; - } - - private _getPreviewLinePoints() { - let previewPoints = this._previewData.map(d => { - return { - x: this._previewPointsX && this._previewPointsX(d), - y: this._previewPointsY && this._previewPointsY(d) - }; - }); - // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 - if (previewPoints.length === 0) { - return previewPoints; - } - - // 采样 - previewPoints = this._simplifyPoints(previewPoints); - - const { basePointStart, basePointEnd } = this._computeBasePoints(); - return basePointStart.concat(previewPoints).concat(basePointEnd); - } - - private _getPreviewAreaPoints() { - let previewPoints: IPointLike[] = this._previewData.map(d => { - return { - x: this._previewPointsX && this._previewPointsX(d), - y: this._previewPointsY && this._previewPointsY(d), - x1: this._previewPointsX1 && this._previewPointsX1(d), - y1: this._previewPointsY1 && this._previewPointsY1(d) - }; - }); - // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 - if (previewPoints.length === 0) { - return previewPoints; - } - - // 采样 - previewPoints = this._simplifyPoints(previewPoints); - - const { basePointStart, basePointEnd } = this._computeBasePoints(); - return basePointStart.concat(previewPoints).concat(basePointEnd); - } - - /** 使用callback绘制背景图表 (数据和数据映射从外部传进来) */ - private _setPreviewAttributes(type: 'line' | 'area', group: IGroup) { - if (!this._previewGroup) { - this._previewGroup = group.createOrUpdateChild('previewGroup', { pickable: false }, 'group') as IGroup; - } - if (type === 'line') { - this._previewLine = this._previewGroup.createOrUpdateChild('previewLine', {}, 'line') as ILine; - } else { - this._previewArea = this._previewGroup.createOrUpdateChild( - 'previewArea', - { curveType: 'basis' }, - 'area' - ) as IArea; - } - - const { backgroundChartStyle = {} } = this.attribute as DataZoomAttributes; - - type === 'line' && - this._previewLine.setAttributes({ - points: this._getPreviewLinePoints(), - curveType: 'basis', - pickable: false, - ...backgroundChartStyle.line - }); - type === 'area' && - this._previewArea.setAttributes({ - points: this._getPreviewAreaPoints(), - curveType: 'basis', - pickable: false, - ...backgroundChartStyle.area - }); - } - - /** 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) */ - private _setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { - if (!this._selectedPreviewGroupClip) { - this._selectedPreviewGroupClip = group.createOrUpdateChild( - 'selectedPreviewGroupClip', - { pickable: false }, - 'group' - ) as IGroup; - this._selectedPreviewGroup = this._selectedPreviewGroupClip.createOrUpdateChild( - 'selectedPreviewGroup', - {}, - 'group' - ) as IGroup; - } - - if (type === 'line') { - this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewLine', - {}, - 'line' - ) as ILine; - } else { - this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewArea', - { curveType: 'basis' }, - 'area' - ) as IArea; - } - - const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; - - const { start, end } = this.state; - const { position, width, height } = this._getLayoutAttrFromConfig(); - this._selectedPreviewGroupClip.setAttributes({ - x: this._isHorizontal ? position.x + start * width : position.x, - y: this._isHorizontal ? position.y : position.y + start * height, - width: this._isHorizontal ? (end - start) * width : width, - height: this._isHorizontal ? height : (end - start) * height, - clip: true, - pickable: false - } as any); - this._selectedPreviewGroup.setAttributes({ - x: -(this._isHorizontal ? position.x + start * width : position.x), - y: -(this._isHorizontal ? position.y : position.y + start * height), - width: this._isHorizontal ? (end - start) * width : width, - height: this._isHorizontal ? height : (end - start) * height, - pickable: false - } as any); - type === 'line' && - this._selectedPreviewLine.setAttributes({ - points: this._getPreviewLinePoints(), - curveType: 'basis', - pickable: false, - ...selectedBackgroundChartStyle.line - }); - type === 'area' && - this._selectedPreviewArea.setAttributes({ - points: this._getPreviewAreaPoints(), - curveType: 'basis', - pickable: false, - ...selectedBackgroundChartStyle.area - }); - } - - private _maybeAddLabel(container: IGroup, attributes: TagAttributes, name: string): Tag { - let labelShape = (this as unknown as IGroup).find(node => node.name === name, true) as unknown as Tag; - if (labelShape) { - labelShape.setAttributes(attributes); - } else { - labelShape = new Tag(attributes); - labelShape.name = name; - } - - container.add(labelShape as unknown as INode); - return labelShape; - } - - /** 外部重置组件的起始状态 */ - setStartAndEnd(start?: number, end?: number) { - const { start: startAttr, end: endAttr } = this.attribute as DataZoomAttributes; - if (isValid(start) && isValid(end) && (start !== this.state.start || end !== this.state.end)) { - this.state.start = start; - this.state.end = end; - if (startAttr !== this.state.start || endAttr !== this.state.end) { - this._setStateAttr(start, end, true); - this._dispatchEvent('change', { - start, - end, - tag: this._activeTag - }); - } - } - } - - /** 外部更新背景图表的数据 */ - setPreviewData(data: any[]) { - this._previewData = data; - } - - /** 外部更新手柄文字 */ - setText(text: string, tag: 'start' | 'end') { - if (tag === 'start') { - this._startText.setAttribute('text', text); - } else { - this._endText.setAttribute('text', text); - } - } - - /** 外部获取起始点数据值 */ - getStartValue() { - return this._startValue; - } - - getEndTextValue() { - return this._endValue; - } - - getMiddleHandlerSize() { - const { middleHandlerStyle = {} } = this.attribute as DataZoomAttributes; - const middleHandlerRectSize = middleHandlerStyle.background?.size ?? 10; - const middleHandlerSymbolSize = middleHandlerStyle.icon?.size ?? 10; - return Math.max(middleHandlerRectSize, ...array(middleHandlerSymbolSize)); - } - - /** 外部传入数据映射 */ - setPreviewPointsX(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsX = callback); - } - setPreviewPointsY(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsY = callback); - } - setPreviewPointsX1(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsX1 = callback); - } - setPreviewPointsY1(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsY1 = callback); - } - setStatePointToData(callback: (state: number) => any) { - isFunction(callback) && (this._statePointToData = callback); - } - - release(all?: boolean): void { - /** - * 浏览器上的事件必须解绑,防止内存泄漏,场景树上的事件会自动解绑 - */ - super.release(all); - (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { - passive: false - }); - this._clearDragEvents(); - } -} diff --git a/packages/vrender-components/src/data-zoom/data-zoom-4.ts b/packages/vrender-components/src/data-zoom/data-zoom-4.ts deleted file mode 100644 index cda4eeafd..000000000 --- a/packages/vrender-components/src/data-zoom/data-zoom-4.ts +++ /dev/null @@ -1,1272 +0,0 @@ -import type { FederatedPointerEvent, IArea, IGroup, ILine, IRect, ISymbol, INode } from '@visactor/vrender-core'; -// eslint-disable-next-line no-duplicate-imports -import { flatten_simplify, vglobal } from '@visactor/vrender-core'; -import type { IBoundsLike, IPointLike } from '@visactor/vutils'; -// eslint-disable-next-line no-duplicate-imports -import { Bounds, array, clamp, debounce, isFunction, isValid, merge, throttle } from '@visactor/vutils'; -import { AbstractComponent } from '../core/base'; -import type { TagAttributes } from '../tag'; -// eslint-disable-next-line no-duplicate-imports -import { Tag } from '../tag'; -import { DEFAULT_DATA_ZOOM_ATTRIBUTES, DEFAULT_HANDLER_ATTR_MAP } from './config'; -import { DataZoomActiveTag } from './type'; -// eslint-disable-next-line no-duplicate-imports -import type { DataZoomAttributes } from './type'; -import type { ComponentOptions } from '../interface'; -import { loadDataZoomComponent } from './register'; -import { getEndTriggersOfDrag } from '../util/event'; - -const delayMap = { - debounce: debounce, - throttle: throttle -}; -loadDataZoomComponent(); -export class DataZoom extends AbstractComponent> { - name = 'dataZoom'; - static defaultAttributes = DEFAULT_DATA_ZOOM_ATTRIBUTES; - - private _isHorizontal: boolean; - - private _background!: IRect; - - private _container!: IGroup; - - /** 手柄 */ - private _startHandlerMask!: IRect; - private _startHandler!: ISymbol; - private _middleHandlerSymbol!: ISymbol; - private _middleHandlerRect!: IRect; - private _endHandlerMask!: IRect; - private _endHandler!: ISymbol; - private _selectedBackground!: IRect; - private _dragMask!: IRect; - private _startText!: Tag; - private _endText!: Tag; - private _startValue!: string | number; - private _endValue!: string | number; - private _showText!: boolean; - - /** 背景图表 */ - private _previewData: any[] = []; - private _previewGroup!: IGroup; - private _previewLine!: ILine; - private _previewArea!: IArea; - private _selectedPreviewGroupClip!: IGroup; - private _selectedPreviewGroup!: IGroup; - private _selectedPreviewLine!: ILine; - private _selectedPreviewArea!: IArea; - - /** 交互状态 */ - protected _activeTag!: DataZoomActiveTag; - protected _activeItem!: any; - protected _activeState = false; - protected _activeCache: { - startPos: IPointLike; - lastPos: IPointLike; - } = { - startPos: { x: 0, y: 0 }, - lastPos: { x: 0, y: 0 } - }; - protected _layoutCache: { - attPos: 'x' | 'y'; - attSize: 'width' | 'height'; - max: number; - } = { - attPos: 'x', - attSize: 'width', - max: 0 - }; - /** 起始状态 */ - readonly state = { - start: 0, - end: 1 - }; - protected _spanCache: number; - - /** 回调函数 */ - private _previewPointsX!: (datum: any) => number; - private _previewPointsY!: (datum: any) => number; - private _previewPointsX1!: (datum: any) => number; - private _previewPointsY1!: (datum: any) => number; - private _statePointToData: (state: number) => any = state => state; - private _layoutAttrFromConfig: any; // 用于缓存 - - setPropsFromAttrs() { - const { start, end, orient, previewData, previewPointsX, previewPointsY, previewPointsX1, previewPointsY1 } = this - .attribute as DataZoomAttributes; - start && (this.state.start = start); - end && (this.state.end = end); - const { width, height } = this.getLayoutAttrFromConfig(); - this._spanCache = this.state.end - this.state.start; - this._isHorizontal = orient === 'top' || orient === 'bottom'; - this._layoutCache.max = this._isHorizontal ? width : height; - this._layoutCache.attPos = this._isHorizontal ? 'x' : 'y'; - this._layoutCache.attSize = this._isHorizontal ? 'width' : 'height'; - previewData && (this._previewData = previewData); - isFunction(previewPointsX) && (this._previewPointsX = previewPointsX); - isFunction(previewPointsY) && (this._previewPointsY = previewPointsY); - isFunction(previewPointsX1) && (this._previewPointsX1 = previewPointsX1); - isFunction(previewPointsY1) && (this._previewPointsY1 = previewPointsY1); - } - - constructor(attributes: DataZoomAttributes, options?: ComponentOptions) { - super(options?.skipDefault ? attributes : merge({}, DataZoom.defaultAttributes, attributes)); - const { position, showDetail } = attributes; - // 这些属性在事件交互过程中会改变,所以不能在setAttrs里面动态更改 - this._activeCache.startPos = position; - this._activeCache.lastPos = position; - if (showDetail === 'auto') { - this._showText = false as boolean; - } else { - this._showText = showDetail as boolean; - } - this.setPropsFromAttrs(); - } - - setAttributes(params: Partial>, forceUpdateTag?: boolean): void { - super.setAttributes(params, forceUpdateTag); - this.setPropsFromAttrs(); - } - - protected bindEvents(): void { - if (this.attribute.disableTriggerEvent) { - this.setAttribute('childrenPickable', false); - return; - } - const { showDetail, brushSelect } = this.attribute as DataZoomAttributes; - // 拖拽开始 - if (this._startHandlerMask) { - this._startHandlerMask.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'start') as unknown as EventListener - ); - } - if (this._endHandlerMask) { - this._endHandlerMask.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'end') as unknown as EventListener - ); - } - if (this._middleHandlerSymbol) { - this._middleHandlerSymbol.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleSymbol') as unknown as EventListener - ); - } - if (this._middleHandlerRect) { - this._middleHandlerRect.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'middleRect') as unknown as EventListener - ); - } - - const selectedTag = brushSelect ? 'background' : 'middleRect'; - if (this._selectedBackground) { - this._selectedBackground.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener - ); - } - if (brushSelect && this._background) { - this._background.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener - ); - } - if (brushSelect && this._previewGroup) { - this._previewGroup.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, 'background') as unknown as EventListener - ); - } - if (this._selectedPreviewGroup) { - this._selectedPreviewGroup.addEventListener( - 'pointerdown', - (e: FederatedPointerEvent) => this._onHandlerPointerDown(e, selectedTag) as unknown as EventListener - ); - } - - // hover - if (showDetail === 'auto') { - (this as unknown as IGroup).addEventListener('pointerenter', this._onHandlerPointerEnter as EventListener); - (this as unknown as IGroup).addEventListener('pointerleave', this._onHandlerPointerLeave as EventListener); - } - - (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { - passive: false - }); - } - private _handleTouchMove = (e: TouchEvent) => { - if (this._activeState) { - /** - * https://developer.mozilla.org/zh-CN/docs/Web/CSS/overscroll-behavior - * 由于浏览器的overscroll-behavior属性,需要在move的时候阻止浏览器默认行为,否则会因为浏览器检测到scroll行为,阻止pointer事件, - * 抛出pointercancel事件,导致拖拽行为中断。 - */ - e.preventDefault(); - } - }; - - /** dragMask size边界处理 */ - protected dragMaskSize() { - const { position } = this.attribute as DataZoomAttributes; - const { attPos, max } = this._layoutCache; - if (this._activeCache.lastPos[attPos] - position[attPos] > max) { - return max + position[attPos] - this._activeCache.startPos[attPos]; - } else if (this._activeCache.lastPos[attPos] - position[attPos] < 0) { - return position[attPos] - this._activeCache.startPos[attPos]; - } - return this._activeCache.lastPos[attPos] - this._activeCache.startPos[attPos]; - } - - /** state 边界处理 */ - protected setStateAttr(start: number, end: number, shouldRender: boolean) { - const { zoomLock = false, minSpan = 0, maxSpan = 1 } = this.attribute as DataZoomAttributes; - const span = end - start; - if (span !== this._spanCache && (zoomLock || span < minSpan || span > maxSpan)) { - return; - } - this._spanCache = span; - this.state.start = start; - this.state.end = end; - shouldRender && this.setAttributes({ start, end }); - } - - /** 事件系统坐标转换为stage坐标 */ - protected eventPosToStagePos(e: FederatedPointerEvent) { - // updateSpec过程中交互的话, stage可能为空 - return this.stage?.eventPointTransform(e) ?? { x: 0, y: 0 }; - } - - private _clearDragEvents() { - const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; - const triggers = getEndTriggersOfDrag(); - - evtTarget.removeEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); - triggers.forEach((trigger: string) => { - evtTarget.removeEventListener(trigger, this._onHandlerPointerUp); - }); - - (this as unknown as IGroup).removeEventListener('pointermove', this._onHandlerPointerMove, { - capture: true - }); - } - - /** - * 拖拽开始事件 - * @description 开启activeState + 通过tag判断事件在哪个元素上触发 并 更新交互坐标 - */ - private _onHandlerPointerDown = (e: FederatedPointerEvent, tag: string) => { - // 清除之前的事件,防止没有被清除掉 - this._clearDragEvents(); - if (tag === 'start') { - this._activeTag = DataZoomActiveTag.startHandler; - this._activeItem = this._startHandlerMask; - } else if (tag === 'end') { - this._activeTag = DataZoomActiveTag.endHandler; - this._activeItem = this._endHandlerMask; - } else if (tag === 'middleRect') { - this._activeTag = DataZoomActiveTag.middleHandler; - this._activeItem = this._middleHandlerRect; - } else if (tag === 'middleSymbol') { - this._activeTag = DataZoomActiveTag.middleHandler; - this._activeItem = this._middleHandlerSymbol; - } else if (tag === 'background') { - this._activeTag = DataZoomActiveTag.background; - this._activeItem = this._background; - } - this._activeState = true; - this._activeCache.startPos = this.eventPosToStagePos(e); - this._activeCache.lastPos = this.eventPosToStagePos(e); - const evtTarget = vglobal.env === 'browser' ? vglobal : this.stage; - const triggers = getEndTriggersOfDrag(); - - /** - * move的时候,需要通过 capture: true,能够在捕获截断被拦截, - */ - evtTarget.addEventListener('pointermove', this._onHandlerPointerMove, { capture: true }); - (this as unknown as IGroup).addEventListener('pointermove', this._onHandlerPointerMove, { - capture: true - }); - - triggers.forEach((trigger: string) => { - evtTarget.addEventListener(trigger, this._onHandlerPointerUp); - }); - }; - - /** - * 拖拽进行事件 - * @description 分为以下四种情况: - * 1. 在背景 or 背景图表上拖拽 (activeTag === 'background'): 改变lastPos => dragMask的宽 or 高被改变 - * 2. 在middleHandler上拖拽 (activeTag === 'middleHandler'): 改变lastPos、start & end + 边界处理: 防止拖拽结果超出背景 => 所有handler的位置被改变 - * 3. 在startHandler上拖拽 (activeTag === 'startHandler'): 改变lastPos、start & end + 边界处理: startHandler和endHandler交换 => 所有handler的位置被改变 - * 4. 在endHandler上拖拽,同上 - */ - private _pointerMove = (e: FederatedPointerEvent) => { - const { start: startAttr, end: endAttr, brushSelect, realTime = true } = this.attribute as DataZoomAttributes; - const pos = this.eventPosToStagePos(e); - const { attPos, max } = this._layoutCache; - const dis = (pos[attPos] - this._activeCache.lastPos[attPos]) / max; - - let { start, end } = this.state; - // this._activeState= false; - if (this._activeState) { - // if (this._activeTag === DataZoomActiveTag.background) { - // } else - if (this._activeTag === DataZoomActiveTag.middleHandler) { - this.moveZoomWithMiddle((this.state.start + this.state.end) / 2 + dis); - } else if (this._activeTag === DataZoomActiveTag.startHandler) { - if (start + dis > end) { - start = end; - end = start + dis; - this._activeTag = DataZoomActiveTag.endHandler; - } else { - start = start + dis; - } - } else if (this._activeTag === DataZoomActiveTag.endHandler) { - if (end + dis < start) { - end = start; - start = end + dis; - this._activeTag = DataZoomActiveTag.startHandler; - } else { - end = end + dis; - } - } - this._activeCache.lastPos = pos; - brushSelect && this.renderDragMask(); - } - start = Math.min(Math.max(start, 0), 1); - end = Math.min(Math.max(end, 0), 1); - - // 避免attributes相同时, 重复渲染 - if (startAttr !== start || endAttr !== end) { - this.setStateAttr(start, end, true); - if (realTime) { - this._dispatchEvent('change', { - start, - end, - tag: this._activeTag - }); - } - } - }; - private _onHandlerPointerMove = - this.attribute.delayTime === 0 - ? this._pointerMove - : delayMap[this.attribute.delayType](this._pointerMove, this.attribute.delayTime); - - /** - * 拖拽结束事件 - * @description 关闭activeState + 边界情况处理: 防止拖拽后start和end过近 - */ - private _onHandlerPointerUp = (e: FederatedPointerEvent) => { - const { start, end, brushSelect, realTime = true } = this.attribute as DataZoomAttributes; - if (this._activeState) { - if (this._activeTag === DataZoomActiveTag.background) { - const pos = this.eventPosToStagePos(e); - this.backgroundDragZoom(this._activeCache.startPos, pos); - } - } - this._activeState = false; - - // dragMask不依赖于state更新 - brushSelect && this.renderDragMask(); - - // 此次dispatch不能被省略 - // 因为pointermove时, 已经将状态更新至最新, 所以在pointerup时, 必定start = state.start & end = state.end - // 而realTime = false时, 需要依赖这次dispatch来更新图表图元 - this._dispatchEvent('change', { - start: this.state.start, - end: this.state.end, - tag: this._activeTag - }); - this._clearDragEvents(); - }; - - /** - * 鼠标进入事件 - * @description 鼠标进入选中部分出现start和end文字 - */ - private _onHandlerPointerEnter(e: FederatedPointerEvent) { - this._showText = true; - this.renderText(); - } - - /** - * 鼠标移出事件 - * @description 鼠标移出选中部分不出现start和end文字 - */ - private _onHandlerPointerLeave(e: FederatedPointerEvent) { - this._showText = false; - this.renderText(); - } - - protected backgroundDragZoom(startPos: IPointLike, endPos: IPointLike) { - const { attPos, max } = this._layoutCache; - const { position } = this.attribute as DataZoomAttributes; - const startPosInComponent = startPos[attPos] - position[attPos]; - const endPosInComponent = endPos[attPos] - position[attPos]; - const start = Math.min(Math.max(Math.min(startPosInComponent, endPosInComponent) / max, 0), 1); - const end = Math.min(Math.max(Math.max(startPosInComponent, endPosInComponent) / max, 0), 1); - if (Math.abs(start - end) < 0.01) { - this.moveZoomWithMiddle(start); - } else { - this.setStateAttr(start, end, false); - } - } - - protected moveZoomWithMiddle(middle: number) { - const currentMiddle = (this.state.start + this.state.end) / 2; - let offset = middle - currentMiddle; - // 拖拽middleHandler时,限制在background范围内 - if (offset === 0) { - return; - } else if (offset > 0) { - if (this.state.end + offset > 1) { - offset = 1 - this.state.end; - } - } else if (offset < 0) { - if (this.state.start + offset < 0) { - offset = -this.state.start; - } - } - this.setStateAttr(this.state.start + offset, this.state.end + offset, false); - } - - protected renderDragMask() { - const { dragMaskStyle } = this.attribute as DataZoomAttributes; - const { position, width, height } = this.getLayoutAttrFromConfig(); - // drag部分 - if (this._isHorizontal) { - this._dragMask = this._container.createOrUpdateChild( - 'dragMask', - { - x: clamp( - this.dragMaskSize() < 0 ? this._activeCache.lastPos.x : this._activeCache.startPos.x, - position.x, - position.x + width - ), - y: position.y, - width: - (this._activeState && this._activeTag === DataZoomActiveTag.background && Math.abs(this.dragMaskSize())) || - 0, - height, - ...dragMaskStyle - }, - 'rect' - ) as IRect; - } else { - this._dragMask = this._container.createOrUpdateChild( - 'dragMask', - { - x: position.x, - y: clamp( - this.dragMaskSize() < 0 ? this._activeCache.lastPos.y : this._activeCache.startPos.y, - position.y, - position.y + height - ), - width, - height: - (this._activeState && this._activeTag === DataZoomActiveTag.background && Math.abs(this.dragMaskSize())) || - 0, - ...dragMaskStyle - }, - 'rect' - ) as IRect; - } - } - - /** - * 判断文字是否超出datazoom范围 - */ - protected isTextOverflow(componentBoundsLike: IBoundsLike, textBounds: IBoundsLike | null, layout: 'start' | 'end') { - if (!textBounds) { - return false; - } - if (this._isHorizontal) { - if (layout === 'start') { - if (textBounds.x1 < componentBoundsLike.x1) { - return true; - } - } else { - if (textBounds.x2 > componentBoundsLike.x2) { - return true; - } - } - } else { - if (layout === 'start') { - if (textBounds.y1 < componentBoundsLike.y1) { - return true; - } - } else { - if (textBounds.y2 > componentBoundsLike.y2) { - return true; - } - } - } - return false; - } - - protected setTextAttr(startTextBounds: IBoundsLike, endTextBounds: IBoundsLike) { - const { startTextStyle, endTextStyle } = this.attribute as DataZoomAttributes; - const { formatMethod: startTextFormat, ...restStartTextStyle } = startTextStyle; - const { formatMethod: endTextFormat, ...restEndTextStyle } = endTextStyle; - const { start, end } = this.state; - this._startValue = this._statePointToData(start); - this._endValue = this._statePointToData(end); - const { position, width, height } = this.getLayoutAttrFromConfig(); - - const startTextValue = startTextFormat ? startTextFormat(this._startValue) : this._startValue; - const endTextValue = endTextFormat ? endTextFormat(this._endValue) : this._endValue; - const componentBoundsLike = { - x1: position.x, - y1: position.y, - x2: position.x + width, - y2: position.y + height - }; - let startTextPosition: IPointLike; - let endTextPosition: IPointLike; - let startTextAlignStyle: any; - let endTextAlignStyle: any; - if (this._isHorizontal) { - startTextPosition = { - x: position.x + start * width, - y: position.y + height / 2 - }; - endTextPosition = { - x: position.x + end * width, - y: position.y + height / 2 - }; - startTextAlignStyle = { - textAlign: this.isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'left' : 'right', - textBaseline: restStartTextStyle?.textStyle?.textBaseline ?? 'middle' - }; - endTextAlignStyle = { - textAlign: this.isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'right' : 'left', - textBaseline: restEndTextStyle?.textStyle?.textBaseline ?? 'middle' - }; - } else { - startTextPosition = { - x: position.x + width / 2, - y: position.y + start * height - }; - endTextPosition = { - x: position.x + width / 2, - y: position.y + end * height - }; - startTextAlignStyle = { - textAlign: restStartTextStyle?.textStyle?.textAlign ?? 'center', - textBaseline: this.isTextOverflow(componentBoundsLike, startTextBounds, 'start') ? 'top' : 'bottom' - }; - endTextAlignStyle = { - textAlign: restEndTextStyle?.textStyle?.textAlign ?? 'center', - textBaseline: this.isTextOverflow(componentBoundsLike, endTextBounds, 'end') ? 'bottom' : 'top' - }; - } - - this._startText = this.maybeAddLabel( - this._container, - merge({}, restStartTextStyle, { - text: startTextValue, - x: startTextPosition.x, - y: startTextPosition.y, - visible: this._showText, - pickable: false, - childrenPickable: false, - textStyle: startTextAlignStyle - }), - `data-zoom-start-text-${position}` - ); - this._endText = this.maybeAddLabel( - this._container, - merge({}, restEndTextStyle, { - text: endTextValue, - x: endTextPosition.x, - y: endTextPosition.y, - visible: this._showText, - pickable: false, - childrenPickable: false, - textStyle: endTextAlignStyle - }), - `data-zoom-end-text-${position}` - ); - } - - protected renderText() { - let startTextBounds: IBoundsLike | null = null; - let endTextBounds: IBoundsLike | null = null; - - // 第一次绘制 - this.setTextAttr(startTextBounds, endTextBounds); - // 得到bounds - startTextBounds = this._startText.AABBBounds; - endTextBounds = this._endText.AABBBounds; - - // 第二次绘制: 将text限制在组件bounds内 - this.setTextAttr(startTextBounds, endTextBounds); - // 得到bounds - startTextBounds = this._startText.AABBBounds; - endTextBounds = this._endText.AABBBounds; - const { x1, x2, y1, y2 } = startTextBounds; - const { dx: startTextDx = 0, dy: startTextDy = 0 } = this.attribute.startTextStyle; - - // 第三次绘制: 避免startText和endText重叠, 如果重叠了, 对startText做位置调整(考虑到调整的最小化,只单独调整startText而不调整endText) - if (new Bounds().set(x1, y1, x2, y2).intersects(endTextBounds)) { - const direction = this.attribute.orient === 'bottom' || this.attribute.orient === 'right' ? -1 : 1; - if (this._isHorizontal) { - this._startText.setAttribute('dy', startTextDy + direction * Math.abs(endTextBounds.y1 - endTextBounds.y2)); - } else { - this._startText.setAttribute('dx', startTextDx + direction * Math.abs(endTextBounds.x1 - endTextBounds.x2)); - } - } else { - if (this._isHorizontal) { - this._startText.setAttribute('dy', startTextDy); - } else { - this._startText.setAttribute('dx', startTextDx); - } - } - } - - /** - * 获取背景框中的位置和宽高 - * @description 实际绘制的背景框中的高度或宽度 减去 中间手柄的高度或宽度 - */ - protected getLayoutAttrFromConfig() { - if (this._layoutAttrFromConfig) { - return this._layoutAttrFromConfig; - } - const { - position: positionConfig, - size, - orient, - middleHandlerStyle = {}, - startHandlerStyle = {}, - endHandlerStyle = {}, - backgroundStyle = {} - } = this.attribute as DataZoomAttributes; - const { width: widthConfig, height: heightConfig } = size; - const middleHandlerSize = middleHandlerStyle.background?.size ?? 10; - - // 如果middleHandler显示的话,要将其宽高计入datazoom宽高 - let width; - let height; - let position; - if (middleHandlerStyle.visible) { - if (this._isHorizontal) { - width = widthConfig; - height = heightConfig - middleHandlerSize; - position = { - x: positionConfig.x, - y: positionConfig.y + middleHandlerSize - }; - } else { - width = widthConfig - middleHandlerSize; - height = heightConfig; - position = { - x: positionConfig.x + (orient === 'left' ? middleHandlerSize : 0), - y: positionConfig.y - }; - } - } else { - width = widthConfig; - height = heightConfig; - position = positionConfig; - } - - const startHandlerSize = (startHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); - const endHandlerSize = (endHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); - // 如果startHandler显示的话,要将其宽高计入dataZoom宽高 - if (startHandlerStyle.visible) { - if (this._isHorizontal) { - width -= (startHandlerSize + endHandlerSize) / 2; - position = { - x: position.x + startHandlerSize / 2, - y: position.y - }; - } else { - height -= (startHandlerSize + endHandlerSize) / 2; - position = { - x: position.x, - y: position.y + startHandlerSize / 2 - }; - } - } - - // stroke 需计入宽高, 否则dataZoom在画布边缘会被裁剪lineWidth / 2 - height += backgroundStyle.lineWidth / 2 ?? 1; - width += backgroundStyle.lineWidth / 2 ?? 1; - - this._layoutAttrFromConfig = { - position, - width, - height - }; - return this._layoutAttrFromConfig; - } - - protected render() { - this._layoutAttrFromConfig = null; - const { - // start, - // end, - orient, - backgroundStyle, - backgroundChartStyle = {}, - selectedBackgroundStyle = {}, - selectedBackgroundChartStyle = {}, - middleHandlerStyle = {}, - startHandlerStyle = {}, - endHandlerStyle = {}, - brushSelect, - zoomLock - } = this.attribute as DataZoomAttributes; - const { start, end } = this.state; - - const { position, width, height } = this.getLayoutAttrFromConfig(); - const startHandlerMinSize = startHandlerStyle.triggerMinSize ?? 40; - const endHandlerMinSize = endHandlerStyle.triggerMinSize ?? 40; - const group = (this as unknown as IGroup).createOrUpdateChild('dataZoom-container', {}, 'group') as IGroup; - this._container = group; - this._background = group.createOrUpdateChild( - 'background', - { - x: position.x, - y: position.y, - width, - height, - cursor: brushSelect ? 'crosshair' : 'auto', - ...backgroundStyle, - pickable: zoomLock ? false : (backgroundStyle.pickable ?? true) - }, - 'rect' - ) as IRect; - - /** 背景图表 */ - backgroundChartStyle.line?.visible && this.setPreviewAttributes('line', group); - backgroundChartStyle.area?.visible && this.setPreviewAttributes('area', group); - - /** drag mask */ - brushSelect && this.renderDragMask(); - - /** 选中背景 */ - if (this._isHorizontal) { - // 选中部分 - this._selectedBackground = group.createOrUpdateChild( - 'selectedBackground', - { - x: position.x + start * width, - y: position.y, - width: (end - start) * width, - height: height, - cursor: brushSelect ? 'crosshair' : 'move', - ...selectedBackgroundStyle, - pickable: zoomLock ? false : ((selectedBackgroundChartStyle as any).pickable ?? true) - }, - 'rect' - ) as IRect; - } else { - // 选中部分 - this._selectedBackground = group.createOrUpdateChild( - 'selectedBackground', - { - x: position.x, - y: position.y + start * height, - width, - height: (end - start) * height, - cursor: brushSelect ? 'crosshair' : 'move', - ...selectedBackgroundStyle, - pickable: zoomLock ? false : (selectedBackgroundStyle.pickable ?? true) - }, - 'rect' - ) as IRect; - } - - /** 选中的背景图表 */ - selectedBackgroundChartStyle.line?.visible && this.setSelectedPreviewAttributes('line', group); - selectedBackgroundChartStyle.area?.visible && this.setSelectedPreviewAttributes('area', group); - - /** 左右 和 中间手柄 */ - if (this._isHorizontal) { - if (middleHandlerStyle.visible) { - const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; - this._middleHandlerRect = group.createOrUpdateChild( - 'middleHandlerRect', - { - x: position.x + start * width, - y: position.y - middleHandlerBackgroundSize, - width: (end - start) * width, - height: middleHandlerBackgroundSize, - ...middleHandlerStyle.background?.style, - pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) - }, - 'rect' - ) as IRect; - this._middleHandlerSymbol = group.createOrUpdateChild( - 'middleHandlerSymbol', - { - x: position.x + ((start + end) / 2) * width, - y: position.y - middleHandlerBackgroundSize / 2, - strokeBoundsBuffer: 0, - angle: 0, - symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', - ...middleHandlerStyle.icon, - pickable: zoomLock ? false : (middleHandlerStyle.icon.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - } - this._startHandler = group.createOrUpdateChild( - 'startHandler', - { - x: position.x + start * width, - y: position.y + height / 2, - size: height, - symbolType: startHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - ...startHandlerStyle, - pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - this._endHandler = group.createOrUpdateChild( - 'endHandler', - { - x: position.x + end * width, - y: position.y + height / 2, - size: height, - symbolType: endHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - ...endHandlerStyle, - pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - - // 透明mask构造热区, 热区大小配置来自handler bounds - const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); - const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); - const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); - const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); - - this._startHandlerMask = group.createOrUpdateChild( - 'startHandlerMask', - { - x: position.x + start * width - startHandlerWidth / 2, - y: position.y + height / 2 - startHandlerHeight / 2, - width: startHandlerWidth, - height: startHandlerHeight, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - this._endHandlerMask = group.createOrUpdateChild( - 'endHandlerMask', - { - x: position.x + end * width - endHandlerWidth / 2, - y: position.y + height / 2 - endHandlerHeight / 2, - width: endHandlerWidth, - height: endHandlerHeight, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.horizontal as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - } else { - if (middleHandlerStyle.visible) { - const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; - - this._middleHandlerRect = group.createOrUpdateChild( - 'middleHandlerRect', - { - x: orient === 'left' ? position.x - middleHandlerBackgroundSize : position.x + width, - y: position.y + start * height, - width: middleHandlerBackgroundSize, - height: (end - start) * height, - ...middleHandlerStyle.background?.style, - pickable: zoomLock ? false : (middleHandlerStyle.background?.style?.pickable ?? true) - }, - 'rect' - ) as IRect; - this._middleHandlerSymbol = group.createOrUpdateChild( - 'middleHandlerSymbol', - { - x: - orient === 'left' - ? position.x - middleHandlerBackgroundSize / 2 - : position.x + width + middleHandlerBackgroundSize / 2, - y: position.y + ((start + end) / 2) * height, - // size: height, - angle: 90 * (Math.PI / 180), - symbolType: middleHandlerStyle.icon?.symbolType ?? 'square', - strokeBoundsBuffer: 0, - ...middleHandlerStyle.icon, - pickable: zoomLock ? false : (middleHandlerStyle.icon?.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - } - this._startHandler = group.createOrUpdateChild( - 'startHandler', - { - x: position.x + width / 2, - y: position.y + start * height, - size: width, - symbolType: startHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - ...startHandlerStyle, - pickable: zoomLock ? false : (startHandlerStyle.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - - this._endHandler = group.createOrUpdateChild( - 'endHandler', - { - x: position.x + width / 2, - y: position.y + end * height, - size: width, - symbolType: endHandlerStyle.symbolType ?? 'square', - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - ...endHandlerStyle, - pickable: zoomLock ? false : (endHandlerStyle.pickable ?? true) - }, - 'symbol' - ) as ISymbol; - - // 透明mask构造热区, 热区大小配置来自handler bounds - const startHandlerWidth = Math.max(this._startHandler.AABBBounds.width(), startHandlerMinSize); - const startHandlerHeight = Math.max(this._startHandler.AABBBounds.height(), startHandlerMinSize); - const endHandlerWidth = Math.max(this._endHandler.AABBBounds.width(), endHandlerMinSize); - const endHandlerHeight = Math.max(this._endHandler.AABBBounds.height(), endHandlerMinSize); - - this._startHandlerMask = group.createOrUpdateChild( - 'startHandlerMask', - { - x: position.x + width / 2 + startHandlerWidth / 2, - y: position.y + start * height - startHandlerHeight / 2, - width: endHandlerHeight, - height: endHandlerWidth, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - this._endHandlerMask = group.createOrUpdateChild( - 'endHandlerMask', - { - x: position.x + width / 2 + endHandlerWidth / 2, - y: position.y + end * height - endHandlerHeight / 2, - width: endHandlerHeight, - height: endHandlerWidth, - fill: 'white', - fillOpacity: 0, - zIndex: 999, - ...(DEFAULT_HANDLER_ATTR_MAP.vertical as any), - pickable: !zoomLock - }, - 'rect' - ) as IRect; - } - - /** 左右文字 */ - if (this._showText) { - this.renderText(); - } - } - - computeBasePoints() { - const { orient } = this.attribute as DataZoomAttributes; - const { position, width, height } = this.getLayoutAttrFromConfig(); - let basePointStart: any; - let basePointEnd: any; - if (this._isHorizontal) { - basePointStart = [ - { - x: position.x, - y: position.y + height - } - ]; - basePointEnd = [ - { - x: position.x + width, - y: position.y + height - } - ]; - } else if (orient === 'left') { - basePointStart = [ - { - x: position.x + width, - y: position.y - } - ]; - basePointEnd = [ - { - x: position.x + width, - y: position.y + height - } - ]; - } else { - basePointStart = [ - { - x: position.x, - y: position.y + height - } - ]; - basePointEnd = [ - { - x: position.x, - y: position.y - } - ]; - } - return { - basePointStart, - basePointEnd - }; - } - - protected simplifyPoints(points: IPointLike[]) { - // 采样压缩率策略: 如果没做任何配置, 那么限制在niceCount内, 如果做了配置, 则按照配置计算 - const niceCount = 10000; // 经验值 - if (points.length > niceCount) { - const tolerance = this.attribute.tolerance ?? this._previewData.length / niceCount; - return flatten_simplify(points, tolerance, false); - } - return points; - } - - protected getPreviewLinePoints() { - let previewPoints = this._previewData.map(d => { - return { - x: this._previewPointsX && this._previewPointsX(d), - y: this._previewPointsY && this._previewPointsY(d) - }; - }); - // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 - if (previewPoints.length === 0) { - return previewPoints; - } - - // 采样 - previewPoints = this.simplifyPoints(previewPoints); - - const { basePointStart, basePointEnd } = this.computeBasePoints(); - return basePointStart.concat(previewPoints).concat(basePointEnd); - } - - protected getPreviewAreaPoints() { - let previewPoints: IPointLike[] = this._previewData.map(d => { - return { - x: this._previewPointsX && this._previewPointsX(d), - y: this._previewPointsY && this._previewPointsY(d), - x1: this._previewPointsX1 && this._previewPointsX1(d), - y1: this._previewPointsY1 && this._previewPointsY1(d) - }; - }); - // 仅在有数据的时候增加base point, 以弥补背景图表两端的不连续缺口。不然的话没有数据时,会因为base point而仍然绘制图形 - if (previewPoints.length === 0) { - return previewPoints; - } - - // 采样 - previewPoints = this.simplifyPoints(previewPoints); - - const { basePointStart, basePointEnd } = this.computeBasePoints(); - return basePointStart.concat(previewPoints).concat(basePointEnd); - } - - /** 使用callback绘制背景图表 (数据和数据映射从外部传进来) */ - protected setPreviewAttributes(type: 'line' | 'area', group: IGroup) { - if (!this._previewGroup) { - this._previewGroup = group.createOrUpdateChild('previewGroup', { pickable: false }, 'group') as IGroup; - } - if (type === 'line') { - this._previewLine = this._previewGroup.createOrUpdateChild('previewLine', {}, 'line') as ILine; - } else { - this._previewArea = this._previewGroup.createOrUpdateChild( - 'previewArea', - { curveType: 'basis' }, - 'area' - ) as IArea; - } - - const { backgroundChartStyle = {} } = this.attribute as DataZoomAttributes; - - type === 'line' && - this._previewLine.setAttributes({ - points: this.getPreviewLinePoints(), - curveType: 'basis', - pickable: false, - ...backgroundChartStyle.line - }); - type === 'area' && - this._previewArea.setAttributes({ - points: this.getPreviewAreaPoints(), - curveType: 'basis', - pickable: false, - ...backgroundChartStyle.area - }); - } - - /** 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) */ - protected setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { - if (!this._selectedPreviewGroupClip) { - this._selectedPreviewGroupClip = group.createOrUpdateChild( - 'selectedPreviewGroupClip', - { pickable: false }, - 'group' - ) as IGroup; - this._selectedPreviewGroup = this._selectedPreviewGroupClip.createOrUpdateChild( - 'selectedPreviewGroup', - {}, - 'group' - ) as IGroup; - } - - if (type === 'line') { - this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewLine', - {}, - 'line' - ) as ILine; - } else { - this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewArea', - { curveType: 'basis' }, - 'area' - ) as IArea; - } - - const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; - - const { start, end } = this.state; - const { position, width, height } = this.getLayoutAttrFromConfig(); - this._selectedPreviewGroupClip.setAttributes({ - x: this._isHorizontal ? position.x + start * width : position.x, - y: this._isHorizontal ? position.y : position.y + start * height, - width: this._isHorizontal ? (end - start) * width : width, - height: this._isHorizontal ? height : (end - start) * height, - clip: true, - pickable: false - } as any); - this._selectedPreviewGroup.setAttributes({ - x: -(this._isHorizontal ? position.x + start * width : position.x), - y: -(this._isHorizontal ? position.y : position.y + start * height), - width: this._isHorizontal ? (end - start) * width : width, - height: this._isHorizontal ? height : (end - start) * height, - pickable: false - } as any); - type === 'line' && - this._selectedPreviewLine.setAttributes({ - points: this.getPreviewLinePoints(), - curveType: 'basis', - pickable: false, - ...selectedBackgroundChartStyle.line - }); - type === 'area' && - this._selectedPreviewArea.setAttributes({ - points: this.getPreviewAreaPoints(), - curveType: 'basis', - pickable: false, - ...selectedBackgroundChartStyle.area - }); - } - - protected maybeAddLabel(container: IGroup, attributes: TagAttributes, name: string): Tag { - let labelShape = (this as unknown as IGroup).find(node => node.name === name, true) as unknown as Tag; - if (labelShape) { - labelShape.setAttributes(attributes); - } else { - labelShape = new Tag(attributes); - labelShape.name = name; - } - - container.add(labelShape as unknown as INode); - return labelShape; - } - - /** 外部重置组件的起始状态 */ - setStartAndEnd(start?: number, end?: number) { - const { start: startAttr, end: endAttr } = this.attribute as DataZoomAttributes; - if (isValid(start) && isValid(end) && (start !== this.state.start || end !== this.state.end)) { - this.state.start = start; - this.state.end = end; - if (startAttr !== this.state.start || endAttr !== this.state.end) { - this.setStateAttr(start, end, true); - this._dispatchEvent('change', { - start, - end, - tag: this._activeTag - }); - } - } - } - - /** 外部更新背景图表的数据 */ - setPreviewData(data: any[]) { - this._previewData = data; - } - - /** 外部更新手柄文字 */ - setText(text: string, tag: 'start' | 'end') { - if (tag === 'start') { - this._startText.setAttribute('text', text); - } else { - this._endText.setAttribute('text', text); - } - } - - /** 外部获取起始点数据值 */ - getStartValue() { - return this._startValue; - } - - getEndTextValue() { - return this._endValue; - } - - getMiddleHandlerSize() { - const { middleHandlerStyle = {} } = this.attribute as DataZoomAttributes; - const middleHandlerRectSize = middleHandlerStyle.background?.size ?? 10; - const middleHandlerSymbolSize = middleHandlerStyle.icon?.size ?? 10; - return Math.max(middleHandlerRectSize, ...array(middleHandlerSymbolSize)); - } - - /** 外部传入数据映射 */ - setPreviewPointsX(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsX = callback); - } - setPreviewPointsY(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsY = callback); - } - setPreviewPointsX1(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsX1 = callback); - } - setPreviewPointsY1(callback: (d: any) => number) { - isFunction(callback) && (this._previewPointsY1 = callback); - } - setStatePointToData(callback: (state: number) => any) { - isFunction(callback) && (this._statePointToData = callback); - } - - release(all?: boolean): void { - /** - * 浏览器上的事件必须解绑,防止内存泄漏,场景树上的事件会自动解绑 - */ - super.release(all); - (vglobal.env === 'browser' ? vglobal : this.stage).addEventListener('touchmove', this._handleTouchMove, { - passive: false - }); - this._clearDragEvents(); - } -} diff --git a/packages/vrender-components/src/data-zoom/data-zoom.ts b/packages/vrender-components/src/data-zoom/data-zoom.ts index cedd38469..206e6104a 100644 --- a/packages/vrender-components/src/data-zoom/data-zoom.ts +++ b/packages/vrender-components/src/data-zoom/data-zoom.ts @@ -1,10 +1,10 @@ -import type { IGroup } from '@visactor/vrender-core'; +import { type IGroup } from '@visactor/vrender-core'; import { array, isFunction, isValid, merge } from '@visactor/vutils'; import { AbstractComponent } from '../core/base'; -import type { DataZoomAttributes } from './type'; +import { IDataZoomEvent, IDataZoomInteractiveEvent, type DataZoomAttributes } from './type'; import type { ComponentOptions } from '../interface'; -import { Renderer, type IRenderer } from './renderer'; -import { InteractionManager, type InteractionManagerAttributes } from './interaction'; +import { DataZoomRenderer, type DataZoomRendererAttrs } from './renderer'; +import { DataZoomInteraction, type InteractionAttributes } from './interaction'; import { loadDataZoomComponent } from './register'; import { DEFAULT_DATA_ZOOM_ATTRIBUTES } from './config'; @@ -13,28 +13,34 @@ export class DataZoom extends AbstractComponent> { name = 'dataZoom'; static defaultAttributes = DEFAULT_DATA_ZOOM_ATTRIBUTES; /** 交互控制 */ - private _interaction: InteractionManager; + private _interaction: DataZoomInteraction; /** 渲染控制 */ - private _renderer: Renderer; + private _renderer: DataZoomRenderer; + /** 共享变量: 容器 */ + private _container: IGroup; /** 共享变量: 状态 */ private _state: { start: number; end: number } = { start: 0, end: 1 }; /** 共享变量: 布局 */ private _layoutCacheFromConfig: any; + /** 中间量 */ + private _isHorizontal: boolean; + constructor(attributes: DataZoomAttributes, options?: ComponentOptions) { super(options?.skipDefault ? attributes : merge({}, DataZoom.defaultAttributes, attributes)); - this._renderer = new Renderer(this._rendererAttrs()); - this._interaction = new InteractionManager(this._interactionAttrs()); - const { start, end } = this.attribute as DataZoomAttributes; + this._renderer = new DataZoomRenderer(this._getRendererAttrs()); + this._interaction = new DataZoomInteraction(this._getInteractionAttrs()); + const { start, end, orient } = this.attribute as DataZoomAttributes; start && (this._state.start = start); end && (this._state.end = end); + this._isHorizontal = orient === 'top' || orient === 'bottom'; } /** * 获取背景框中的位置和宽高 * @description 实际绘制的背景框中的高度或宽度 减去 中间手柄的高度或宽度 */ - private _getLayoutAttrFromConfig() { + getLayoutAttrFromConfig() { if (this._layoutCacheFromConfig) { return this._layoutCacheFromConfig; } @@ -55,7 +61,7 @@ export class DataZoom extends AbstractComponent> { let height; let position; if (middleHandlerStyle.visible) { - if (this.attribute.orient === 'top' || this.attribute.orient === 'bottom') { + if (this._isHorizontal) { width = widthConfig; height = heightConfig - middleHandlerSize; position = { @@ -76,13 +82,11 @@ export class DataZoom extends AbstractComponent> { position = positionConfig; } - const isHorizontal = this.attribute.orient === 'top' || this.attribute.orient === 'bottom'; - - const startHandlerSize = (startHandlerStyle.size as number) ?? (isHorizontal ? height : width); - const endHandlerSize = (endHandlerStyle.size as number) ?? (isHorizontal ? height : width); + const startHandlerSize = (startHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); + const endHandlerSize = (endHandlerStyle.size as number) ?? (this._isHorizontal ? height : width); // 如果startHandler显示的话,要将其宽高计入dataZoom宽高 if (startHandlerStyle.visible) { - if (isHorizontal) { + if (this._isHorizontal) { width -= (startHandlerSize + endHandlerSize) / 2; position = { x: position.x + startHandlerSize / 2, @@ -109,24 +113,19 @@ export class DataZoom extends AbstractComponent> { return this._layoutCacheFromConfig; } - get getLayoutAttrFromConfig() { - return this._getLayoutAttrFromConfig; - } - - private _rendererAttrs(): IRenderer { + private _getRendererAttrs(): DataZoomRendererAttrs { return { attribute: this.attribute, getLayoutAttrFromConfig: this.getLayoutAttrFromConfig, setState: (state: { start: number; end: number }) => { this._state = state; }, - getState: () => { - return this._state; - } + getState: () => this._state, + getContainer: () => this._container }; } - private _interactionAttrs(): InteractionManagerAttributes { + private _getInteractionAttrs(): InteractionAttributes { return { stage: this.stage, attribute: this.attribute, @@ -142,9 +141,8 @@ export class DataZoom extends AbstractComponent> { setState: (state: { start: number; end: number }) => { this._state = state; }, - getState: () => { - return this._state; - } + getState: () => this._state, + getGlobalTransMatrix: () => this.globalTransMatrix }; } @@ -154,31 +152,25 @@ export class DataZoom extends AbstractComponent> { return; } this._interaction.bindEvents(); - this._interaction.on('stateChange', ({ shouldRender }) => { + this._interaction.on(IDataZoomInteractiveEvent.stateUpdate, ({ shouldRender }) => { if (shouldRender) { this._renderer.renderDataZoom(); } }); - this._interaction.on('eventChange', ({ start, end, tag }) => { - this._dispatchEvent('change', { start, end, tag }); + this._interaction.on(IDataZoomInteractiveEvent.dataZoomUpdate, ({ start, end, tag }) => { + this._dispatchEvent(IDataZoomEvent.dataZoomChange, { start, end, tag }); }); - this._interaction.on('renderMask', () => { + this._interaction.on(IDataZoomInteractiveEvent.maskUpdate, () => { this._renderer.renderDragMask(); }); - this._interaction.on('enter', () => { - this._renderer.showText = true; - this._renderer._renderText(); - }); - - // hover if (this.attribute.showDetail === 'auto') { - (this as unknown as IGroup).addEventListener('pointerenter', () => { + this._container.addEventListener('pointerenter', () => { this._renderer.showText = true; - this._renderer._renderText(); + this._renderer.renderText(); }); - (this as unknown as IGroup).addEventListener('pointerleave', () => { + this._container.addEventListener('pointerleave', () => { this._renderer.showText = false; - this._renderer._renderText(); + this._renderer.renderText(); }); } } @@ -189,17 +181,15 @@ export class DataZoom extends AbstractComponent> { start && (this._state.start = start); end && (this._state.end = end); - this._renderer.setAttributes(this._rendererAttrs()); - this._interaction.setAttributes(this._interactionAttrs()); + this._renderer.setAttributes(this._getRendererAttrs()); + this._interaction.setAttributes(this._getInteractionAttrs()); } render(): void { this._layoutCacheFromConfig = null; - - const group = (this as unknown as IGroup).createOrUpdateChild('dataZoom-container', {}, 'group') as IGroup; - this._renderer.container = group; + this._container = this.createOrUpdateChild('datazoom-container', {}, 'group') as IGroup; this._renderer.renderDataZoom(); - this._interaction.setAttributes(this._interactionAttrs()); + this._interaction.setAttributes(this._getInteractionAttrs()); } release(all?: boolean): void { @@ -208,21 +198,18 @@ export class DataZoom extends AbstractComponent> { */ super.release(all); this._interaction.clearDragEvents(); - this._interaction.clearDragEvents(); } /** 外部重置组件的起始状态 */ setStartAndEnd(start?: number, end?: number) { - const { start: startAttr, end: endAttr } = this.attribute as DataZoomAttributes; const { start: startState, end: endState } = this._state; if (isValid(start) && isValid(end) && (start !== startState || end !== endState)) { - if (startAttr !== startState || endAttr !== endState) { - this._renderer.renderDataZoom(); - this._dispatchEvent('change', { - start, - end - }); - } + this._state = { start, end }; + this.render(); + this._dispatchEvent(IDataZoomEvent.dataZoomChange, { + start, + end + }); } } diff --git a/packages/vrender-components/src/data-zoom/interaction.ts b/packages/vrender-components/src/data-zoom/interaction.ts index e836dc635..96258ac09 100644 --- a/packages/vrender-components/src/data-zoom/interaction.ts +++ b/packages/vrender-components/src/data-zoom/interaction.ts @@ -1,16 +1,14 @@ -import { DataZoomActiveTag, type DataZoomAttributes } from './type'; +import { DataZoomActiveTag, IDataZoomInteractiveEvent, type DataZoomAttributes } from './type'; import { getEndTriggersOfDrag } from '../util/event'; -import type { IPointLike, Dict } from '@visactor/vutils'; +import type { IPointLike, Dict, Matrix } from '@visactor/vutils'; import { vglobal } from '@visactor/vrender-core'; -// eslint-disable-next-line no-duplicate-imports -import type { FederatedPointerEvent, IGroup, IRect, ISymbol, IStage, INode } from '@visactor/vrender-core'; -// eslint-disable-next-line no-duplicate-imports +import type { FederatedPointerEvent, IGroup, IRect, ISymbol, IStage } from '@visactor/vrender-core'; import { clamp, debounce, EventEmitter, throttle } from '@visactor/vutils'; const delayMap = { debounce: debounce, throttle: throttle }; -export interface InteractionManagerAttributes { +export interface InteractionAttributes { stage: IStage; attribute: Partial>; startHandlerMask?: IRect; @@ -24,13 +22,18 @@ export interface InteractionManagerAttributes { getLayoutAttrFromConfig?: any; getState: () => { start: number; end: number }; setState: (state: { start: number; end: number }) => void; + getGlobalTransMatrix: () => Matrix; } -export class InteractionManager extends EventEmitter { +export class DataZoomInteraction extends EventEmitter { /** 上层透传 */ stage: IStage; attribute!: Partial>; private _getLayoutAttrFromConfig: any; - // 图元 + private _getState: () => { start: number; end: number }; + private _setState: (state: { start: number; end: number }) => void; + private _getGlobalTransMatrix: () => Matrix; + + /** 图元 */ private _startHandlerMask: IRect | undefined; private _middleHandlerSymbol: ISymbol | undefined; private _middleHandlerRect: IRect | undefined; @@ -40,18 +43,18 @@ export class InteractionManager extends EventEmitter { private _selectedPreviewGroup: IGroup | undefined; private _selectedBackground: IRect | undefined; - /** 交互相关 */ - _activeTag!: DataZoomActiveTag; - _activeItem!: any; - _activeState = false; - _activeCache: { + /** 交互 */ + private _activeTag!: DataZoomActiveTag; + private _activeItem!: any; + private _activeState = false; + private _activeCache: { startPos: IPointLike; lastPos: IPointLike; } = { startPos: { x: 0, y: 0 }, lastPos: { x: 0, y: 0 } }; - _layoutCache: { + private _layoutCache: { attPos: 'x' | 'y'; attSize: 'width' | 'height'; size: number; @@ -60,22 +63,24 @@ export class InteractionManager extends EventEmitter { attSize: 'width', size: 0 }; - _spanCache: number; + private _spanCache: number; - private _getState: () => { start: number; end: number }; - private _setState: (state: { start: number; end: number }) => void; + private _onHandlerPointerMove: (e: FederatedPointerEvent) => void; - constructor(props: InteractionManagerAttributes) { + constructor(props: InteractionAttributes) { super(); - this.attribute = props.attribute; this._initAttrs(props); } - setAttributes(props: InteractionManagerAttributes): void { + setAttributes(props: InteractionAttributes): void { this._initAttrs(props); + this._onHandlerPointerMove = + (this.attribute?.delayTime ?? 0) === 0 + ? this._pointerMove + : delayMap[this.attribute?.delayType ?? 'debounce'](this._pointerMove, this.attribute?.delayTime ?? 0); } - private _initAttrs(props: InteractionManagerAttributes) { + private _initAttrs(props: InteractionAttributes) { this.stage = props.stage; this.attribute = props.attribute; this._startHandlerMask = props.startHandlerMask; @@ -96,6 +101,7 @@ export class InteractionManager extends EventEmitter { this._layoutCache.size = isHorizontal ? width : height; this._layoutCache.attPos = isHorizontal ? 'x' : 'y'; this._layoutCache.attSize = isHorizontal ? 'width' : 'height'; + this._getGlobalTransMatrix = props.getGlobalTransMatrix; } clearDragEvents() { @@ -224,9 +230,12 @@ export class InteractionManager extends EventEmitter { */ private _pointerMove = (e: FederatedPointerEvent) => { const { brushSelect } = this.attribute as DataZoomAttributes; + const { position } = this._getLayoutAttrFromConfig(); const pos = this._eventPosToStagePos(e); - const { attPos, size } = this._layoutCache; + + const { attPos, size, attSize } = this._layoutCache; const dis = (pos[attPos] - this._activeCache.lastPos[attPos]) / size; + const statePos = (pos[attPos] - position[attPos]) / this._getLayoutAttrFromConfig()[attSize]; let { start, end } = this._getState(); let shouldRender = true; @@ -234,23 +243,13 @@ export class InteractionManager extends EventEmitter { if (this._activeTag === DataZoomActiveTag.middleHandler) { ({ start, end } = this._moveZoomWithMiddle(dis)); } else if (this._activeTag === DataZoomActiveTag.startHandler) { - ({ start, end } = this._moveZoomWithHandler('start', dis)); + ({ start, end } = this._moveZoomWithHandler(statePos, 'start')); } else if (this._activeTag === DataZoomActiveTag.endHandler) { - ({ start, end } = this._moveZoomWithHandler('end', dis)); + ({ start, end } = this._moveZoomWithHandler(statePos, 'end')); } else if (this._activeTag === DataZoomActiveTag.background && brushSelect) { - const { position, width } = this._getLayoutAttrFromConfig(); - const currentPos = pos ?? this._activeCache.lastPos; - start = clamp( - (this._activeCache.startPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / width, - 0, - 1 - ); - end = clamp((currentPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / width, 0, 1); - if (start > end) { - [start, end] = [end, start]; - } + ({ start, end } = this._moveZoomWithBackground(statePos)); shouldRender = false; - this._dispatchEvent('renderMask'); + this._dispatchEvent(IDataZoomInteractiveEvent.maskUpdate); } this._activeCache.lastPos = pos; } @@ -258,14 +257,14 @@ export class InteractionManager extends EventEmitter { // 避免attributes相同时, 重复渲染 if (this._getState().start !== start || this._getState().end !== end) { this._setStateAttr(start, end); - this._dispatchEvent('stateChange', { + this._dispatchEvent(IDataZoomInteractiveEvent.stateUpdate, { start: this._getState().start, end: this._getState().end, shouldRender, tag: this._activeTag }); if (this.attribute.realTime) { - this._dispatchEvent('eventChange', { + this._dispatchEvent(IDataZoomInteractiveEvent.dataZoomUpdate, { start: this._getState().start, end: this._getState().end, shouldRender: true, @@ -274,10 +273,6 @@ export class InteractionManager extends EventEmitter { } } }; - private _onHandlerPointerMove = - (this.attribute?.delayTime ?? 0) === 0 - ? this._pointerMove - : delayMap[this.attribute?.delayType ?? 'debounce'](this._pointerMove, this.attribute?.delayTime ?? 0); /** state 边界处理 */ private _setStateAttr(start: number, end: number) { @@ -309,25 +304,25 @@ export class InteractionManager extends EventEmitter { /** * @description 拖拽startHandler/endHandler, 改变start和end */ - private _moveZoomWithHandler(handler: 'start' | 'end', dis: number) { + private _moveZoomWithHandler(statePos: number, handler: 'start' | 'end') { const { start, end } = this._getState(); let newStart = start; let newEnd = end; if (handler === 'start') { - if (start + dis > end) { + if (statePos > end) { newStart = end; - newEnd = start + dis; + newEnd = statePos; this._activeTag = DataZoomActiveTag.endHandler; } else { - newStart = start + dis; + newStart = statePos; } } else if (handler === 'end') { - if (end + dis < start) { + if (statePos < start) { newEnd = start; - newStart = end + dis; + newStart = statePos; this._activeTag = DataZoomActiveTag.startHandler; } else { - newEnd = end + dis; + newEnd = statePos; } } return { @@ -335,6 +330,25 @@ export class InteractionManager extends EventEmitter { end: clamp(newEnd, 0, 1) }; } + + /** + * @description 拖拽背景, 改变start和end + */ + private _moveZoomWithBackground(statePos: number) { + const { position } = this._getLayoutAttrFromConfig(); + const { attSize } = this._layoutCache; + const startPos = + (this._activeCache.startPos[this._layoutCache.attPos] - position[this._layoutCache.attPos]) / + this._getLayoutAttrFromConfig()[attSize]; + const endPos = statePos; + let start = clamp(startPos, 0, 1); + let end = clamp(endPos, 0, 1); + if (start > end) { + [start, end] = [end, start]; + } + return { start, end }; + } + /** * 拖拽结束事件 * @description 关闭activeState + 边界情况处理: 防止拖拽后start和end过近 @@ -344,7 +358,7 @@ export class InteractionManager extends EventEmitter { // brush的时候, 只改变了state, 没有触发重新渲染, 在抬起鼠标时触发 if (this._activeTag === DataZoomActiveTag.background) { this._setStateAttr(this._getState().start, this._getState().end); - this._dispatchEvent('stateChange', { + this._dispatchEvent(IDataZoomInteractiveEvent.stateUpdate, { start: this._getState().start, end: this._getState().end, shouldRender: true, @@ -356,7 +370,7 @@ export class InteractionManager extends EventEmitter { // 此次dispatch不能被省略 // 因为pointermove时, 已经将状态更新至最新, 所以在pointerup时, 必定start = state.start & end = state.end // 而realTime = false时, 需要依赖这次dispatch来更新图表图元 - this._dispatchEvent('eventChange', { + this._dispatchEvent(IDataZoomInteractiveEvent.dataZoomUpdate, { start: this._getState().start, end: this._getState().end, shouldRender: true, @@ -366,38 +380,17 @@ export class InteractionManager extends EventEmitter { this.clearDragEvents(); }; - /** - * 鼠标进入事件 - * @description 鼠标进入选中部分出现start和end文字 - */ - private _onHandlerPointerEnter(e: FederatedPointerEvent) { - this._dispatchEvent('enter', { - start: this._getState().start, - end: this._getState().end, - shouldRender: true - }); - } - - /** - * 鼠标移出事件 - * @description 鼠标移出选中部分不出现start和end文字 - */ - private _onHandlerPointerLeave(e: FederatedPointerEvent) { - this._dispatchEvent('leave', { - start: this._getState().start, - end: this._getState().end, - shouldRender: true - }); - } - /** 事件系统坐标转换为stage坐标 */ private _eventPosToStagePos(e: FederatedPointerEvent) { - // updateSpec过程中交互的话, stage可能为空 - return this.stage?.eventPointTransform(e as any) ?? { x: 0, y: 0 }; + const result = { x: 0, y: 0 }; + // 1. 外部坐标 -> 内部坐标 + const stagePoints = this.stage?.eventPointTransform(e as any) ?? { x: 0, y: 0 }; // updateSpec过程中交互的话, stage可能为空 + // 2. 内部坐标 -> 组件坐标 (比如: 给layer设置 scale / x / y) + this._getGlobalTransMatrix().transformPoint(stagePoints, result); + return result; } - protected _dispatchEvent(eventName: string, details?: Dict) { + protected _dispatchEvent(eventName: IDataZoomInteractiveEvent, details?: Dict) { this.emit(eventName, details); - // return !changeEvent.defaultPrevented; } } diff --git a/packages/vrender-components/src/data-zoom/renderer.ts b/packages/vrender-components/src/data-zoom/renderer.ts index dcc89dc73..88e25f202 100644 --- a/packages/vrender-components/src/data-zoom/renderer.ts +++ b/packages/vrender-components/src/data-zoom/renderer.ts @@ -1,33 +1,27 @@ import type { DataZoomAttributes } from './type'; import type { IBoundsLike, IPointLike } from '@visactor/vutils'; import { flatten_simplify } from '@visactor/vrender-core'; -// eslint-disable-next-line no-duplicate-imports import type { IArea, IGroup, ILine, IRect, ISymbol, INode } from '@visactor/vrender-core'; -// eslint-disable-next-line no-duplicate-imports -import { Bounds, cloneDeep, isFunction, merge } from '@visactor/vutils'; +import { Bounds, isFunction, merge } from '@visactor/vutils'; import { Tag, type TagAttributes } from '../tag'; import { DEFAULT_HANDLER_ATTR_MAP } from './config'; import { isTextOverflow } from './utils'; -export interface IRenderer { +export interface DataZoomRendererAttrs { attribute: Partial>; getLayoutAttrFromConfig: any; getState: () => { start: number; end: number }; setState: (state: { start: number; end: number }) => void; + getContainer: () => IGroup; } -export class Renderer { +export class DataZoomRenderer { /** 上层透传 */ attribute: Partial>; - private _container!: IGroup; - set container(container: IGroup) { - this._container = container; - } - get container() { - return this._container; - } private _getLayoutAttrFromConfig: any; - private _isHorizontal: boolean; - private _getState: () => { start: number; end: number }; + private _getContainer: () => IGroup; + + /** 中间变量 */ + private _isHorizontal: boolean; /** 手柄 */ private _startHandlerMask!: IRect; @@ -122,16 +116,12 @@ export class Renderer { this._statePointToData = statePointToData; } - private _initAttrs(props: IRenderer) { + private _initAttrs(props: DataZoomRendererAttrs) { this.attribute = props.attribute; this._isHorizontal = this.attribute.orient === 'top' || this.attribute.orient === 'bottom'; const { previewData, showDetail, previewPointsX, previewPointsY, previewPointsX1, previewPointsY1 } = this .attribute as DataZoomAttributes; - if (showDetail === 'auto') { - this._showText = false as boolean; - } else { - this._showText = showDetail as boolean; - } + this._showText = showDetail === 'auto' ? false : showDetail; previewData && (this._previewData = previewData); isFunction(previewPointsX) && (this._previewPointsX = previewPointsX); isFunction(previewPointsY) && (this._previewPointsY = previewPointsY); @@ -139,23 +129,58 @@ export class Renderer { isFunction(previewPointsY1) && (this._previewPointsY1 = previewPointsY1); this._getState = props.getState; this._getLayoutAttrFromConfig = props.getLayoutAttrFromConfig; + this._getContainer = props.getContainer; } - constructor(props: IRenderer) { + constructor(props: DataZoomRendererAttrs) { this._initAttrs(props); } - setAttributes(props: IRenderer): void { + setAttributes(props: DataZoomRendererAttrs): void { this._initAttrs(props); } - // 渲染拖拽mask + renderDataZoom() { + const { + backgroundChartStyle = {}, + selectedBackgroundChartStyle = {}, + brushSelect + } = this.attribute as DataZoomAttributes; + + this._renderBackground(); + + /** 背景图表 */ + backgroundChartStyle.line?.visible && this._setPreviewAttributes('line', this._getContainer()); + backgroundChartStyle.area?.visible && this._setPreviewAttributes('area', this._getContainer()); + + /** 背景选框 */ + brushSelect && this.renderDragMask(); + + /** 选中背景 */ + this._renderSelectedBackground(); + + /** 选中的背景图表 */ + selectedBackgroundChartStyle.line?.visible && this._setSelectedPreviewAttributes('line', this._getContainer()); + selectedBackgroundChartStyle.area?.visible && this._setSelectedPreviewAttributes('area', this._getContainer()); + + /** 左右 和 中间手柄 */ + this._renderHandler(); + + /** 左右文字 */ + if (this._showText) { + this.renderText(); + } + } + + /** + * @description 渲染拖拽mask + */ renderDragMask() { const { dragMaskStyle } = this.attribute as DataZoomAttributes; const { position, width, height } = this._getLayoutAttrFromConfig(); const { start, end } = this._getState(); if (this._isHorizontal) { - this._dragMask = this._container.createOrUpdateChild( + this._dragMask = this._getContainer().createOrUpdateChild( 'dragMask', { x: position.x + start * width, @@ -167,7 +192,7 @@ export class Renderer { 'rect' ) as IRect; } else { - this._dragMask = this._container.createOrUpdateChild( + this._dragMask = this._getContainer().createOrUpdateChild( 'dragMask', { x: position.x, @@ -182,27 +207,13 @@ export class Renderer { return { start, end }; } - renderDataZoom() { - const { - orient, - backgroundStyle, - backgroundChartStyle = {}, - selectedBackgroundStyle = {}, - selectedBackgroundChartStyle = {}, - middleHandlerStyle = {}, - startHandlerStyle = {}, - endHandlerStyle = {}, - brushSelect, - zoomLock - } = this.attribute as DataZoomAttributes; - const { start, end } = this._getState(); - - // console.log('state, start, end', start, end); - + /** + * @description 渲染背景 + */ + private _renderBackground() { + const { backgroundStyle, brushSelect, zoomLock } = this.attribute as DataZoomAttributes; const { position, width, height } = this._getLayoutAttrFromConfig(); - const startHandlerMinSize = startHandlerStyle.triggerMinSize ?? 40; - const endHandlerMinSize = endHandlerStyle.triggerMinSize ?? 40; - const group = this._container; + const group = this._getContainer(); this._background = group.createOrUpdateChild( 'background', { @@ -216,52 +227,25 @@ export class Renderer { }, 'rect' ) as IRect; + } + /** + * @description 渲染手柄 + */ + private _renderHandler() { + const { + orient, + middleHandlerStyle = {}, + startHandlerStyle = {}, + endHandlerStyle = {}, + zoomLock + } = this.attribute as DataZoomAttributes; + const { start, end } = this._getState(); - /** 背景图表 */ - backgroundChartStyle.line?.visible && this._setPreviewAttributes('line', group); - backgroundChartStyle.area?.visible && this._setPreviewAttributes('area', group); - - /** drag mask */ - brushSelect && this.renderDragMask(); - - /** 选中背景 */ - if (this._isHorizontal) { - // 选中部分 - this._selectedBackground = group.createOrUpdateChild( - 'selectedBackground', - { - x: position.x + start * width, - y: position.y, - width: (end - start) * width, - height: height, - cursor: brushSelect ? 'crosshair' : 'move', - ...selectedBackgroundStyle, - pickable: zoomLock ? false : ((selectedBackgroundChartStyle as any).pickable ?? true) - }, - 'rect' - ) as IRect; - } else { - // 选中部分 - this._selectedBackground = group.createOrUpdateChild( - 'selectedBackground', - { - x: position.x, - y: position.y + start * height, - width, - height: (end - start) * height, - cursor: brushSelect ? 'crosshair' : 'move', - ...selectedBackgroundStyle, - pickable: zoomLock ? false : (selectedBackgroundStyle.pickable ?? true) - }, - 'rect' - ) as IRect; - } - - /** 选中的背景图表 */ - selectedBackgroundChartStyle.line?.visible && this._setSelectedPreviewAttributes('line', group); - selectedBackgroundChartStyle.area?.visible && this._setSelectedPreviewAttributes('area', group); + const { position, width, height } = this._getLayoutAttrFromConfig(); + const startHandlerMinSize = startHandlerStyle.triggerMinSize ?? 40; + const endHandlerMinSize = endHandlerStyle.triggerMinSize ?? 40; - /** 左右 和 中间手柄 */ + const group = this._getContainer(); if (this._isHorizontal) { if (middleHandlerStyle.visible) { const middleHandlerBackgroundSize = middleHandlerStyle.background?.size || 10; @@ -453,14 +437,57 @@ export class Renderer { 'rect' ) as IRect; } + } - /** 左右文字 */ - if (this._showText) { - this._renderText(); + /** + * @description 渲染选中背景 + */ + private _renderSelectedBackground() { + const { + selectedBackgroundStyle = {}, + selectedBackgroundChartStyle = {}, + brushSelect, + zoomLock + } = this.attribute as DataZoomAttributes; + const { start, end } = this._getState(); + + const { position, width, height } = this._getLayoutAttrFromConfig(); + + const group = this._getContainer(); + if (this._isHorizontal) { + // 选中部分 + this._selectedBackground = group.createOrUpdateChild( + 'selectedBackground', + { + x: position.x + start * width, + y: position.y, + width: (end - start) * width, + height: height, + cursor: brushSelect ? 'crosshair' : 'move', + ...selectedBackgroundStyle, + pickable: zoomLock ? false : ((selectedBackgroundChartStyle as any).pickable ?? true) + }, + 'rect' + ) as IRect; + } else { + // 选中部分 + this._selectedBackground = group.createOrUpdateChild( + 'selectedBackground', + { + x: position.x, + y: position.y + start * height, + width, + height: (end - start) * height, + cursor: brushSelect ? 'crosshair' : 'move', + ...selectedBackgroundStyle, + pickable: zoomLock ? false : (selectedBackgroundStyle.pickable ?? true) + }, + 'rect' + ) as IRect; } } - /** 使用callback绘制背景图表 (数据和数据映射从外部传进来) */ + // 使用callback绘制背景图表 (数据和数据映射从外部传进来) private _setPreviewAttributes(type: 'line' | 'area', group: IGroup) { if (!this._previewGroup) { this._previewGroup = group.createOrUpdateChild('previewGroup', { pickable: false }, 'group') as IGroup; @@ -493,6 +520,70 @@ export class Renderer { }); } + // 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) + private _setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { + if (!this._selectedPreviewGroupClip) { + this._selectedPreviewGroupClip = group.createOrUpdateChild( + 'selectedPreviewGroupClip', + { pickable: false }, + 'group' + ) as IGroup; + this._selectedPreviewGroup = this._selectedPreviewGroupClip.createOrUpdateChild( + 'selectedPreviewGroup', + {}, + 'group' + ) as IGroup; + } + + if (type === 'line') { + this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewLine', + {}, + 'line' + ) as ILine; + } else { + this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewArea', + { curveType: 'basis' }, + 'area' + ) as IArea; + } + + const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + + const { start, end } = this._getState(); + const { position, width, height } = this._getLayoutAttrFromConfig(); + this._selectedPreviewGroupClip.setAttributes({ + x: this._isHorizontal ? position.x + start * width : position.x, + y: this._isHorizontal ? position.y : position.y + start * height, + width: this._isHorizontal ? (end - start) * width : width, + height: this._isHorizontal ? height : (end - start) * height, + clip: true, + pickable: false + } as any); + this._selectedPreviewGroup.setAttributes({ + x: -(this._isHorizontal ? position.x + start * width : position.x), + y: -(this._isHorizontal ? position.y : position.y + start * height), + width: this._isHorizontal ? (end - start) * width : width, + height: this._isHorizontal ? height : (end - start) * height, + pickable: false + } as any); + type === 'line' && + this._selectedPreviewLine.setAttributes({ + points: this._getPreviewLinePoints(), + curveType: 'basis', + pickable: false, + ...selectedBackgroundChartStyle.line + }); + type === 'area' && + this._selectedPreviewArea.setAttributes({ + points: this._getPreviewAreaPoints(), + curveType: 'basis', + pickable: false, + ...selectedBackgroundChartStyle.area + }); + } + private _computeBasePoints() { const { orient } = this.attribute as DataZoomAttributes; const { position, width, height } = this._getLayoutAttrFromConfig(); @@ -594,70 +685,45 @@ export class Renderer { return basePointStart.concat(previewPoints).concat(basePointEnd); } - /** 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) */ - private _setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { - if (!this._selectedPreviewGroupClip) { - this._selectedPreviewGroupClip = group.createOrUpdateChild( - 'selectedPreviewGroupClip', - { pickable: false }, - 'group' - ) as IGroup; - this._selectedPreviewGroup = this._selectedPreviewGroupClip.createOrUpdateChild( - 'selectedPreviewGroup', - {}, - 'group' - ) as IGroup; - } + /** + * @description 渲染文本 + */ + renderText() { + let startTextBounds: IBoundsLike | null = null; + let endTextBounds: IBoundsLike | null = null; - if (type === 'line') { - this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewLine', - {}, - 'line' - ) as ILine; - } else { - this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewArea', - { curveType: 'basis' }, - 'area' - ) as IArea; + // 第一次绘制 + this._setTextAttr(startTextBounds, endTextBounds); + if (this._showText) { + // 得到bounds + startTextBounds = this._startText.AABBBounds; + endTextBounds = this._endText.AABBBounds; + + // 第二次绘制: 将text限制在组件bounds内 + this._setTextAttr(startTextBounds, endTextBounds); + // 得到bounds + startTextBounds = this._startText.AABBBounds; + endTextBounds = this._endText.AABBBounds; + const { x1, x2, y1, y2 } = startTextBounds; + const { dx: startTextDx = 0, dy: startTextDy = 0 } = this.attribute.startTextStyle; + + // 第三次绘制: 避免startText和endText重叠, 如果重叠了, 对startText做位置调整(考虑到调整的最小化,只单独调整startText而不调整endText) + if (new Bounds().set(x1, y1, x2, y2).intersects(endTextBounds)) { + const direction = this.attribute.orient === 'bottom' || this.attribute.orient === 'right' ? -1 : 1; + if (this._isHorizontal) { + this._startText.setAttribute('dy', startTextDy + direction * Math.abs(endTextBounds.y1 - endTextBounds.y2)); + } else { + this._startText.setAttribute('dx', startTextDx + direction * Math.abs(endTextBounds.x1 - endTextBounds.x2)); + } + } else { + if (this._isHorizontal) { + this._startText.setAttribute('dy', startTextDy); + } else { + this._startText.setAttribute('dx', startTextDx); + } + } } - - const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; - - const { start, end } = this._getState(); - const { position, width, height } = this._getLayoutAttrFromConfig(); - this._selectedPreviewGroupClip.setAttributes({ - x: this._isHorizontal ? position.x + start * width : position.x, - y: this._isHorizontal ? position.y : position.y + start * height, - width: this._isHorizontal ? (end - start) * width : width, - height: this._isHorizontal ? height : (end - start) * height, - clip: true, - pickable: false - } as any); - this._selectedPreviewGroup.setAttributes({ - x: -(this._isHorizontal ? position.x + start * width : position.x), - y: -(this._isHorizontal ? position.y : position.y + start * height), - width: this._isHorizontal ? (end - start) * width : width, - height: this._isHorizontal ? height : (end - start) * height, - pickable: false - } as any); - type === 'line' && - this._selectedPreviewLine.setAttributes({ - points: this._getPreviewLinePoints(), - curveType: 'basis', - pickable: false, - ...selectedBackgroundChartStyle.line - }); - type === 'area' && - this._selectedPreviewArea.setAttributes({ - points: this._getPreviewAreaPoints(), - curveType: 'basis', - pickable: false, - ...selectedBackgroundChartStyle.area - }); } - private _setTextAttr(startTextBounds: IBoundsLike, endTextBounds: IBoundsLike) { const { startTextStyle, endTextStyle } = this.attribute as DataZoomAttributes; const { formatMethod: startTextFormat, ...restStartTextStyle } = startTextStyle; @@ -718,7 +784,7 @@ export class Renderer { } this._startText = this._maybeAddLabel( - this._container, + this._getContainer(), merge({}, restStartTextStyle, { text: startTextValue, x: startTextPosition.x, @@ -731,7 +797,7 @@ export class Renderer { `data-zoom-start-text-${position.x}-${position.y}` ); this._endText = this._maybeAddLabel( - this._container, + this._getContainer(), merge({}, restEndTextStyle, { text: endTextValue, x: endTextPosition.x, @@ -744,44 +810,6 @@ export class Renderer { `data-zoom-end-text-${position.x}-${position.y}` ); } - - _renderText() { - let startTextBounds: IBoundsLike | null = null; - let endTextBounds: IBoundsLike | null = null; - - // 第一次绘制 - this._setTextAttr(startTextBounds, endTextBounds); - // 得到bounds - startTextBounds = this._startText.AABBBounds; - endTextBounds = this._endText.AABBBounds; - - // 第二次绘制: 将text限制在组件bounds内 - this._setTextAttr(startTextBounds, endTextBounds); - // 得到bounds - startTextBounds = this._startText.AABBBounds; - endTextBounds = this._endText.AABBBounds; - const { x1, x2, y1, y2 } = startTextBounds; - const { dx: startTextDx = 0, dy: startTextDy = 0 } = this.attribute.startTextStyle; - - // 第三次绘制: 避免startText和endText重叠, 如果重叠了, 对startText做位置调整(考虑到调整的最小化,只单独调整startText而不调整endText) - if (new Bounds().set(x1, y1, x2, y2).intersects(endTextBounds)) { - const direction = this.attribute.orient === 'bottom' || this.attribute.orient === 'right' ? -1 : 1; - if (this._isHorizontal) { - this._startText.setAttribute('dy', startTextDy + direction * Math.abs(endTextBounds.y1 - endTextBounds.y2)); - } else { - this._startText.setAttribute('dx', startTextDx + direction * Math.abs(endTextBounds.x1 - endTextBounds.x2)); - } - } else { - if (this._isHorizontal) { - this._startText.setAttribute('dy', startTextDy); - } else { - this._startText.setAttribute('dx', startTextDx); - } - } - - // console.log('this._showText', this._showText, cloneDeep(this._startText.attribute)); - } - private _maybeAddLabel(container: IGroup, attributes: TagAttributes, name: string): Tag { let labelShape = container.find(node => node.name === name, true) as unknown as Tag; if (labelShape) { diff --git a/packages/vrender-components/src/data-zoom/type.ts b/packages/vrender-components/src/data-zoom/type.ts index 17492712b..8c5299273 100644 --- a/packages/vrender-components/src/data-zoom/type.ts +++ b/packages/vrender-components/src/data-zoom/type.ts @@ -221,3 +221,23 @@ export interface DataZoomAttributes extends IGroupGraphicAttribute { */ tolerance?: number; } + +/** + * 交互模块向外部抛出的事件 + */ +export enum IDataZoomInteractiveEvent { + // 更新start和end + stateUpdate = 'stateUpdate', + // 更新dragMask + maskUpdate = 'maskUpdate', + // 更新dataZoom + dataZoomUpdate = 'dataZoomUpdate' +} + +/** + * vrender-components 对外抛出的事件 + * 由vchart层监听 + */ +export enum IDataZoomEvent { + dataZoomChange = 'dataZoomChange' +} From 29fa3aa980a480f37ba0e85fb68af8df0a10fa90 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Wed, 21 May 2025 16:41:50 +0800 Subject: [PATCH 3/6] fix: bug of layout --- packages/vrender-components/src/data-zoom/data-zoom.ts | 10 +++++----- packages/vrender-components/src/data-zoom/renderer.ts | 8 ++++++-- packages/vrender-components/src/data-zoom/type.ts | 3 ++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/vrender-components/src/data-zoom/data-zoom.ts b/packages/vrender-components/src/data-zoom/data-zoom.ts index 206e6104a..3f7075cff 100644 --- a/packages/vrender-components/src/data-zoom/data-zoom.ts +++ b/packages/vrender-components/src/data-zoom/data-zoom.ts @@ -28,19 +28,19 @@ export class DataZoom extends AbstractComponent> { constructor(attributes: DataZoomAttributes, options?: ComponentOptions) { super(options?.skipDefault ? attributes : merge({}, DataZoom.defaultAttributes, attributes)); - this._renderer = new DataZoomRenderer(this._getRendererAttrs()); - this._interaction = new DataZoomInteraction(this._getInteractionAttrs()); const { start, end, orient } = this.attribute as DataZoomAttributes; + this._isHorizontal = orient === 'top' || orient === 'bottom'; start && (this._state.start = start); end && (this._state.end = end); - this._isHorizontal = orient === 'top' || orient === 'bottom'; + this._renderer = new DataZoomRenderer(this._getRendererAttrs()); + this._interaction = new DataZoomInteraction(this._getInteractionAttrs()); } /** * 获取背景框中的位置和宽高 * @description 实际绘制的背景框中的高度或宽度 减去 中间手柄的高度或宽度 */ - getLayoutAttrFromConfig() { + getLayoutAttrFromConfig = () => { if (this._layoutCacheFromConfig) { return this._layoutCacheFromConfig; } @@ -111,7 +111,7 @@ export class DataZoom extends AbstractComponent> { height }; return this._layoutCacheFromConfig; - } + }; private _getRendererAttrs(): DataZoomRendererAttrs { return { diff --git a/packages/vrender-components/src/data-zoom/renderer.ts b/packages/vrender-components/src/data-zoom/renderer.ts index 88e25f202..b0aa6a9ad 100644 --- a/packages/vrender-components/src/data-zoom/renderer.ts +++ b/packages/vrender-components/src/data-zoom/renderer.ts @@ -1,7 +1,9 @@ import type { DataZoomAttributes } from './type'; import type { IBoundsLike, IPointLike } from '@visactor/vutils'; import { flatten_simplify } from '@visactor/vrender-core'; +// eslint-disable-next-line no-duplicate-imports import type { IArea, IGroup, ILine, IRect, ISymbol, INode } from '@visactor/vrender-core'; +// eslint-disable-next-line no-duplicate-imports import { Bounds, isFunction, merge } from '@visactor/vutils'; import { Tag, type TagAttributes } from '../tag'; import { DEFAULT_HANDLER_ATTR_MAP } from './config'; @@ -119,9 +121,9 @@ export class DataZoomRenderer { private _initAttrs(props: DataZoomRendererAttrs) { this.attribute = props.attribute; this._isHorizontal = this.attribute.orient === 'top' || this.attribute.orient === 'bottom'; - const { previewData, showDetail, previewPointsX, previewPointsY, previewPointsX1, previewPointsY1 } = this + const { previewData, previewPointsX, previewPointsY, previewPointsX1, previewPointsY1 } = this .attribute as DataZoomAttributes; - this._showText = showDetail === 'auto' ? false : showDetail; + previewData && (this._previewData = previewData); isFunction(previewPointsX) && (this._previewPointsX = previewPointsX); isFunction(previewPointsY) && (this._previewPointsY = previewPointsY); @@ -133,6 +135,8 @@ export class DataZoomRenderer { } constructor(props: DataZoomRendererAttrs) { + const { showDetail } = props.attribute as DataZoomAttributes; + this._showText = showDetail === 'auto' ? false : showDetail; this._initAttrs(props); } diff --git a/packages/vrender-components/src/data-zoom/type.ts b/packages/vrender-components/src/data-zoom/type.ts index 8c5299273..1d23d6815 100644 --- a/packages/vrender-components/src/data-zoom/type.ts +++ b/packages/vrender-components/src/data-zoom/type.ts @@ -223,7 +223,8 @@ export interface DataZoomAttributes extends IGroupGraphicAttribute { } /** - * 交互模块向外部抛出的事件 + * 交互模块事件, datazoom内部监听 + * 由vrender-component层监听 */ export enum IDataZoomInteractiveEvent { // 更新start和end From d0dac46c79ee1cf995bca4a48e44001082417c54 Mon Sep 17 00:00:00 2001 From: skie1997 Date: Mon, 26 May 2025 18:13:20 +0800 Subject: [PATCH 4/6] Merge branch dev/1.0.0 into refactor/vrender-data-zoom --- common/config/rush/pnpm-lock.yaml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 3d8d45deb..2b6ca6a0d 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: specifier: ~0.5.7 version: 0.5.7 '@visactor/vrender': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../packages/vrender '@visactor/vutils': specifier: 1.0.4 @@ -95,7 +95,7 @@ importers: ../../packages/react-vrender: dependencies: '@visactor/vrender': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../vrender '@visactor/vutils': specifier: 1.0.4 @@ -153,10 +153,10 @@ importers: ../../packages/react-vrender-utils: dependencies: '@visactor/react-vrender': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../react-vrender '@visactor/vrender': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../vrender '@visactor/vutils': specifier: 1.0.4 @@ -214,10 +214,10 @@ importers: specifier: workspace:0.22.8 version: link:../vrender-animate '@visactor/vrender-core': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../vrender-core '@visactor/vrender-kits': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../vrender-kits devDependencies: '@internal/bundler': @@ -345,10 +345,10 @@ importers: specifier: workspace:0.22.8 version: link:../vrender-animate '@visactor/vrender-core': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../vrender-core '@visactor/vrender-kits': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../vrender-kits '@visactor/vscale': specifier: 1.0.4 @@ -467,7 +467,7 @@ importers: specifier: 2.4.1 version: 2.4.1 '@visactor/vrender-core': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../vrender-core '@visactor/vutils': specifier: 1.0.4 @@ -583,16 +583,16 @@ importers: ../../tools/bugserver-trigger: dependencies: '@visactor/vrender': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../../packages/vrender '@visactor/vrender-components': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../../packages/vrender-components '@visactor/vrender-core': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../../packages/vrender-core '@visactor/vrender-kits': - specifier: workspace:0.22.9 + specifier: workspace:0.22.8 version: link:../../packages/vrender-kits devDependencies: '@internal/bundler': From 16d39761429aae9f29b96141b86aca3d2e3a208b Mon Sep 17 00:00:00 2001 From: skie1997 Date: Wed, 4 Jun 2025 10:57:02 +0800 Subject: [PATCH 5/6] fix: handler text display problem --- packages/vrender-components/src/data-zoom/data-zoom.ts | 2 +- packages/vrender-components/src/data-zoom/renderer.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vrender-components/src/data-zoom/data-zoom.ts b/packages/vrender-components/src/data-zoom/data-zoom.ts index 3f7075cff..651476fce 100644 --- a/packages/vrender-components/src/data-zoom/data-zoom.ts +++ b/packages/vrender-components/src/data-zoom/data-zoom.ts @@ -176,13 +176,13 @@ export class DataZoom extends AbstractComponent> { } setAttributes(params: Partial>, forceUpdateTag?: boolean): void { - super.setAttributes(params, forceUpdateTag); const { start, end } = this.attribute as DataZoomAttributes; start && (this._state.start = start); end && (this._state.end = end); this._renderer.setAttributes(this._getRendererAttrs()); this._interaction.setAttributes(this._getInteractionAttrs()); + super.setAttributes(params, forceUpdateTag); } render(): void { diff --git a/packages/vrender-components/src/data-zoom/renderer.ts b/packages/vrender-components/src/data-zoom/renderer.ts index b0aa6a9ad..920f800e8 100644 --- a/packages/vrender-components/src/data-zoom/renderer.ts +++ b/packages/vrender-components/src/data-zoom/renderer.ts @@ -798,7 +798,7 @@ export class DataZoomRenderer { childrenPickable: false, textStyle: startTextAlignStyle }), - `data-zoom-start-text-${position.x}-${position.y}` + `data-zoom-start-text` ); this._endText = this._maybeAddLabel( this._getContainer(), @@ -811,7 +811,7 @@ export class DataZoomRenderer { childrenPickable: false, textStyle: endTextAlignStyle }), - `data-zoom-end-text-${position.x}-${position.y}` + `data-zoom-end-text` ); } private _maybeAddLabel(container: IGroup, attributes: TagAttributes, name: string): Tag { From 421506e438b74fe47d908f4bad9b054f6f235e9b Mon Sep 17 00:00:00 2001 From: skie1997 Date: Wed, 2 Jul 2025 17:35:46 +0800 Subject: [PATCH 6/6] perf: avoid rerender for background chart --- .../src/data-zoom/renderer.ts | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/packages/vrender-components/src/data-zoom/renderer.ts b/packages/vrender-components/src/data-zoom/renderer.ts index 920f800e8..7ac78e6cb 100644 --- a/packages/vrender-components/src/data-zoom/renderer.ts +++ b/packages/vrender-components/src/data-zoom/renderer.ts @@ -144,7 +144,7 @@ export class DataZoomRenderer { this._initAttrs(props); } - renderDataZoom() { + renderDataZoom(onlyStateChange: boolean = false) { const { backgroundChartStyle = {}, selectedBackgroundChartStyle = {}, @@ -154,8 +154,8 @@ export class DataZoomRenderer { this._renderBackground(); /** 背景图表 */ - backgroundChartStyle.line?.visible && this._setPreviewAttributes('line', this._getContainer()); - backgroundChartStyle.area?.visible && this._setPreviewAttributes('area', this._getContainer()); + backgroundChartStyle.line?.visible && !onlyStateChange && this._setPreviewAttributes('line', this._getContainer()); + backgroundChartStyle.area?.visible && !onlyStateChange && this._setPreviewAttributes('area', this._getContainer()); /** 背景选框 */ brushSelect && this.renderDragMask(); @@ -164,8 +164,11 @@ export class DataZoomRenderer { this._renderSelectedBackground(); /** 选中的背景图表 */ - selectedBackgroundChartStyle.line?.visible && this._setSelectedPreviewAttributes('line', this._getContainer()); - selectedBackgroundChartStyle.area?.visible && this._setSelectedPreviewAttributes('area', this._getContainer()); + selectedBackgroundChartStyle.line?.visible && this._setSelectedPreviewClipAttributes('line', this._getContainer()); + selectedBackgroundChartStyle.line?.visible && !onlyStateChange && this._setSelectedPreviewAttributes('line'); + + selectedBackgroundChartStyle.line?.visible && this._setSelectedPreviewClipAttributes('area', this._getContainer()); + selectedBackgroundChartStyle.area?.visible && !onlyStateChange && this._setSelectedPreviewAttributes('area'); /** 左右 和 中间手柄 */ this._renderHandler(); @@ -525,7 +528,7 @@ export class DataZoomRenderer { } // 使用callback绘制选中的背景图表 (数据和数据映射从外部传进来) - private _setSelectedPreviewAttributes(type: 'area' | 'line', group: IGroup) { + private _setSelectedPreviewClipAttributes(type: 'area' | 'line', group: IGroup) { if (!this._selectedPreviewGroupClip) { this._selectedPreviewGroupClip = group.createOrUpdateChild( 'selectedPreviewGroupClip', @@ -539,22 +542,6 @@ export class DataZoomRenderer { ) as IGroup; } - if (type === 'line') { - this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewLine', - {}, - 'line' - ) as ILine; - } else { - this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( - 'selectedPreviewArea', - { curveType: 'basis' }, - 'area' - ) as IArea; - } - - const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; - const { start, end } = this._getState(); const { position, width, height } = this._getLayoutAttrFromConfig(); this._selectedPreviewGroupClip.setAttributes({ @@ -572,6 +559,23 @@ export class DataZoomRenderer { height: this._isHorizontal ? height : (end - start) * height, pickable: false } as any); + } + + private _setSelectedPreviewAttributes(type: 'line' | 'area') { + const { selectedBackgroundChartStyle = {} } = this.attribute as DataZoomAttributes; + if (type === 'line') { + this._selectedPreviewLine = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewLine', + {}, + 'line' + ) as ILine; + } else { + this._selectedPreviewArea = this._selectedPreviewGroup.createOrUpdateChild( + 'selectedPreviewArea', + { curveType: 'basis' }, + 'area' + ) as IArea; + } type === 'line' && this._selectedPreviewLine.setAttributes({ points: this._getPreviewLinePoints(),