import { Component, isEqual, jsx } from '@antv/f-engine'; import { ChartChildProps } from '../../chart'; import { updateRange, updateFollow } from './zoomUtil'; import { Scale, ScaleConfig } from '../../deps/f2-scale/src'; import { each, isNumberEqual, isArray } from '@antv/util'; import { quadraticOut as easeing } from './easing'; export type ZoomRange = [number, number]; export type ScaleValues = number[] | string[]; function lerp(min, max, fraction) { return (max - min) * fraction + min; } function isNumberEqualRange(aRange: number[], bRange: number[]) { if (!bRange) return false; for (let i = 0, len = aRange.length; i < len; i++) { if (!isNumberEqual(aRange[i], bRange[i])) return false; } return true; } function isEqualRange(aRange, bRange) { if (!bRange) return false; if (isArray(aRange)) { return isNumberEqualRange(aRange, bRange); } // object for (const i in aRange) { if (!isNumberEqualRange(aRange[i], bRange[i])) return false; } return true; } export interface ZoomProps { panSensitive?: number; pinchSensitive?: number; /** * 缩放和平移模式 */ mode?: 'x' | 'y' | ['x', 'y'] | null; /** * 显示的范围 */ range?: ZoomRange; /** * 平移 */ pan?: boolean; /** * 缩放 */ pinch?: boolean; /** * 横扫 */ swipe?: boolean; /** * 横扫动画时长 */ swipeDuration?: number; /** * 事件回调 */ onPanStart?: Function; onPinchStart?: Function; onPan?: Function; onPinch?: Function; onPanEnd?: Function; onPinchEnd?: Function; onInit?: Function; onChange?: Function; /** * 自动同步 x/y 的坐标值 */ autoFit?: boolean; /** * 最少展示数据量,用于控制最小缩放比例, 默认是10 */ minCount?: number; } export interface ZoomState { range: { x?: ZoomRange; y?: ZoomRange; }; } function cloneScale(scale: Scale, scaleConfig?: ScaleConfig) { // @ts-ignore return new scale.constructor({ // @ts-ignore ...scale.__cfg__, ...scaleConfig, }); } export default (View) => { return class Zoom< P extends ZoomProps = ZoomProps, S extends ZoomState = ZoomState > extends Component

{ startRange: { x?: ZoomRange; y?: ZoomRange; }; scale: {} = {}; originScale: {} = {}; // 最小的缩放比例 minScale: number; dims: Array; //swipe end x y swipeEnd = { startX: 0, startY: 0, endX: 0, endY: 0, }; loop: number; constructor(props: P) { const defaultProps = { onPanStart: () => {}, onPinchStart: () => {}, onPan: () => {}, onPinch: () => {}, onInit: () => {}, onPanEnd: () => {}, onPinchEnd: () => {}, minCount: 10, }; super({ ...defaultProps, ...props }); const { mode } = props; this.dims = isArray(mode) ? mode : [mode]; } didMount(): void { const { scale } = this; const { onInit } = this.props; onInit({ scale }); this._bindEvents(); } willReceiveProps(nextProps: P): void { // @ts-ignore const { range: nextRange, data: nextData } = nextProps; const { range: lastRange, data: lastData } = this.props; if (nextData !== lastData) { this._cancelAnimationFrame(); } if (!isEqual(nextRange, lastRange)) { const cacheRange = {}; each(this.dims, (dim) => { cacheRange[dim] = nextRange; }); this.state = { range: cacheRange, } as S; } } willMount(): void { const { props, dims } = this; const { minCount, range } = props; let valueLength = Number.MIN_VALUE; const cacheRange = {}; each(dims, (dim) => { const scale = this._getScale(dim); const { values } = scale; valueLength = values.length > valueLength ? values.length : valueLength; this.scale[dim] = scale; this.originScale[dim] = cloneScale(scale); this.updateRange(range, dim); cacheRange[dim] = range; }); // 图表上最少显示 MIN_COUNT 个数据 this.minScale = minCount / valueLength; this.renderRange(cacheRange); } willUpdate(): void { const { props, state, dims } = this; const { minCount, range } = props; let valueLength = Number.MIN_VALUE; const cacheRange = {}; each(dims, (dim) => { const scale = this._getScale(dim); // scale 没有变化, 不处理 if (scale === this.scale[dim]) { return; } const { values } = scale; valueLength = values.length > valueLength ? values.length : valueLength; this.scale[dim] = scale; this.originScale[dim] = cloneScale(scale); // 让 range 触发更新 this.state.range[dim] = [0, 1]; this.updateRange(range, dim); cacheRange[dim] = range; }); // 有变化 if (Object.keys(cacheRange).length > 0) { this.minScale = minCount / valueLength; const newRange = { ...state.range, ...cacheRange, }; this.renderRange(newRange); } } didUnmount(): void { this._cancelAnimationFrame(); this._unBindEvents(); } _requestAnimationFrame(calllback: Function) { const { context } = this; const { requestAnimationFrame } = context.canvas; this.loop = requestAnimationFrame(calllback); return this.loop; } _cancelAnimationFrame() { const { loop, context } = this; if (loop) { context.canvas.cancelAnimationFrame(loop); } } onPanStart = () => { const { scale } = this; const { onPanStart } = this.props; this.onStart(); onPanStart?.({ scale }); }; onPan = (ev) => { const { onPan } = this.props; const { dims } = this; const range = {}; each(dims, (dim) => { if (dim === 'x') { range['x'] = this._doXPan(ev); return; } if (dim === 'y') { range['y'] = this._doYPan(ev); return; } }); this.renderRange(range); onPan?.(ev); }; onPanEnd = () => { const { scale } = this; const { onPanEnd } = this.props; this.onEnd(); onPanEnd?.({ scale }); }; onPinchStart = () => { const { onPinchStart } = this.props; this.onStart(); onPinchStart?.(); }; onPinch = (ev) => { const { onPinch } = this.props; const { dims } = this; const range = {}; each(dims, (dim) => { if (dim === 'x') { range['x'] = this._doXPinch(ev); return; } if (dim === 'y') { range['y'] = this._doYPinch(ev); return; } }); this.renderRange(range); onPinch?.(ev); }; onPinchEnd = () => { const { scale } = this; const { onPinchEnd } = this.props; this.onEnd(); onPinchEnd?.({ scale }); }; _bindEvents() { const { chart, pan, pinch, swipe } = this.props; // 统一绑定事件 if (pan !== false) { chart.on('panstart', this.onPanStart); chart.on('pan', this.onPan); chart.on('panend', this.onPanEnd); } if (pinch !== false) { chart.on('pinch', this.onPinch); chart.on('pinchstart', this.onPinchStart); chart.on('pinchend', this.onPinchEnd); } if (swipe !== false) { chart.on('swipe', this.onSwipe); } } _unBindEvents() { const { chart, pan, pinch, swipe } = this.props; // 统一绑定事件 if (pan !== false) { chart.off('panstart', this.onPanStart); chart.off('pan', this.onPan); chart.off('panend', this.onPanEnd); } if (pinch !== false) { chart.off('pinch', this.onPinch); chart.off('pinchstart', this.onPinchStart); chart.off('pinchend', this.onPinchEnd); } if (swipe !== false) { chart.off('swipe', this.onSwipe); } } onStart = () => { const { state } = this; const { range } = state; this.startRange = range; this._cancelAnimationFrame(); }; update() { const { startX, startY, endX, endY } = this.swipeEnd; const x = lerp(startX, endX, 0.05); const y = lerp(startY, endY, 0.05); this.swipeEnd = { startX: x, startY: y, endX, endY, }; const { props } = this; const { coord } = props; const { width: coordWidth, height: coordHeight } = coord; const range = {}; range['x'] = this._doPan((x - startX) / coordWidth, 'x'); range['y'] = this._doPan((y - startY) / coordHeight, 'y'); this.renderRange(range); this.startRange = range; this._requestAnimationFrame(() => this.update()); if (Math.abs(x - endX) < 0.0005 && Math.abs(y - endY) < 0.0005) { this.onEnd(); this._cancelAnimationFrame(); } } animateSwipe(dim: string, dimRange: ZoomRange, velocity: number) { const { props } = this; const { swipeDuration = 1000 } = props; const diff = (dimRange[1] - dimRange[0]) * velocity; const startTime = Date.now(); const updateRange = (t: number) => { const newDimRange: ZoomRange = [dimRange[0] + diff * t, dimRange[1] + diff * t]; const newRange = this.updateRange(newDimRange, dim); this.renderRange({ x: newRange, }); }; // 更新动画 const update = () => { // 计算动画已经进行的时间 const currentTime = Date.now() - startTime; // 如果动画已经结束,则清除定时器 if (currentTime >= swipeDuration) { updateRange(1); return; } // 计算缓动值 const progress = currentTime / swipeDuration; const easedProgress = easeing(progress); updateRange(easedProgress); this._requestAnimationFrame(() => { update(); }); }; update(); } onSwipe = (ev) => { const { props, state } = this; // 滑动速率 const { velocity, direction, velocityX = 0, velocityY = 0, points } = ev; const { mode, swipe } = props; const { range } = state; if (!swipe || !mode) { return; } if (mode.length === 1) { this.animateSwipe( mode, range[mode], direction === 'right' || direction === 'down' ? -velocity : velocity ); return; } const { x, y } = points[0]; // 边界处理 if (Math.abs(range?.x[0] - 0) < 0.0005 && velocityX > 0) return; if (Math.abs(range?.x[1] - 1) < 0.0005 && velocityX < 0) return; if (Math.abs(range?.y[0] - 0) < 0.0005 && velocityY < 0) return; if (Math.abs(range?.x[1] - 1) < 0.0005 && velocityY > 0) return; this.swipeEnd = { startX: x, startY: y, endX: x + velocityX * 50, endY: y - velocityY * 50, }; this.onStart(); this.update(); }; onEnd = () => { this.startRange = null; }; _doXPan(ev) { const { direction, deltaX } = ev; if (this.props.mode.length === 1 && (direction === 'up' || direction === 'down')) { return this.state.range['x']; } ev.preventDefault && ev.preventDefault(); const { props } = this; const { coord, panSensitive = 1 } = props; const { width: coordWidth } = coord; const ratio = (deltaX / coordWidth) * panSensitive; const newRange = this._doPan(ratio, 'x'); return newRange; } _doYPan(ev) { const { direction, deltaY } = ev; if (this.props.mode.length === 1 && (direction === 'left' || direction === 'right')) { return this.state.range['y']; } ev.preventDefault && ev.preventDefault(); const { props } = this; const { coord, panSensitive = 1 } = props; const { height: coordHeight } = coord; const ratio = (-deltaY / coordHeight) * panSensitive; const newRange = this._doPan(ratio, 'y'); return newRange; } _doPan(ratio: number, dim: string) { const { startRange } = this; const [start, end] = startRange[dim]; const rangeLen = end - start; const rangeOffset = rangeLen * ratio; const newStart = start - rangeOffset; const newEnd = end - rangeOffset; const newRange = this.updateRange([newStart, newEnd], dim); return newRange; } _doXPinch(ev) { ev.preventDefault && ev.preventDefault(); const { zoom, center } = ev; const { props } = this; const { coord } = props; const { width: coordWidth, left, right } = coord; const leftLen = Math.abs(center.x - left); const rightLen = Math.abs(right - center.x); // 计算左右缩放的比例 const leftZoom = leftLen / coordWidth; const rightZoom = rightLen / coordWidth; const newRange = this._doPinch(leftZoom, rightZoom, zoom, 'x'); return newRange; } _doYPinch(ev) { ev.preventDefault && ev.preventDefault(); const { zoom, center } = ev; const { props } = this; const { coord } = props; const { height: coordHeight, top, bottom } = coord; const topLen = Math.abs(center.y - top); const bottomLen = Math.abs(bottom - center.y); // 计算左右缩放的比例 const topZoom = topLen / coordHeight; const bottomZoom = bottomLen / coordHeight; const newRange = this._doPinch(topZoom, bottomZoom, zoom, 'y'); return newRange; } _doPinch(startRatio: number, endRatio: number, zoom: number, dim: string) { const { startRange, minScale, props } = this; const { pinchSensitive = 1 } = props; const [start, end] = startRange[dim]; const zoomOffset = zoom < 1 ? (1 / zoom - 1) * pinchSensitive : (1 - zoom) * pinchSensitive; const rangeLen = end - start; const rangeOffset = rangeLen * zoomOffset; const startOffset = rangeOffset * startRatio; const endOffset = rangeOffset * endRatio; const newStart = Math.max(0, start - startOffset); const newEnd = Math.min(1, end + endOffset); const newRange: ZoomRange = [newStart, newEnd]; // 如果已经到了最小比例,则不能再继续再放大 if (newEnd - newStart < minScale) { return this.state.range[dim]; } return this.updateRange(newRange, dim); } updateRange(originalRange: ZoomRange, dim) { if (!originalRange) return; const [start, end] = originalRange; const rangeLength = end - start; // 处理边界值 let newRange: ZoomRange; if (start < 0) { newRange = [0, rangeLength]; } else if (end > 1) { newRange = [1 - rangeLength, 1]; } else { newRange = originalRange; } const { props, scale, originScale, state } = this; const { data, autoFit } = props; const { range } = state; if (range && isEqualRange(newRange, range[dim])) return newRange; // 更新主 scale updateRange(scale[dim], originScale[dim], newRange); if (autoFit) { const followScale = this._getFollowScales(dim); this.updateFollow(followScale, scale[dim], data); } return newRange; } updateFollow(scales: Scale[], mainScale: Scale, data: any[]) { updateFollow(scales, mainScale, data); } _getScale(dim) { const { coord, chart } = this.props; if (dim === 'x') { return coord.transposed ? chart.getYScales()[0] : chart.getXScales()[0]; } else { return coord.transposed ? chart.getXScales()[0] : chart.getYScales()[0]; } } _getFollowScales(dim) { const { coord, chart } = this.props; if (dim === 'x') { return coord.transposed ? chart.getXScales() : chart.getYScales(); } if (dim === 'y') { return coord.transposed ? chart.getYScales() : chart.getXScales(); } } renderRange(range) { const { state, props } = this; if (isEqualRange(range, state.range)) return; const { chart, onChange } = props; onChange && onChange({ range }); // 手势变化不执行动画 const { animate } = chart; chart.setAnimate(false); // 后面的 forceUpdate 会强制更新,所以不用 setState,直接更新 state.range = range; chart.forceUpdate(() => { chart.setAnimate(animate); }); } render() { return ; } }; };