mirror of
https://gitee.com/antv/g6.git
synced 2024-12-02 03:38:20 +08:00
feat: timebar component
This commit is contained in:
parent
b1701eff22
commit
09266ec075
@ -129,4 +129,4 @@
|
||||
"webpack-cli": "^3.3.10",
|
||||
"worker-loader": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
@ -132,7 +132,7 @@ export default class Handler {
|
||||
},
|
||||
name: `${name}-handler`
|
||||
});
|
||||
|
||||
|
||||
this.handleGroup.addShape('circle', {
|
||||
attrs: {
|
||||
x: width / 2,
|
||||
@ -146,7 +146,7 @@ export default class Handler {
|
||||
},
|
||||
name: `${name}-handler`
|
||||
})
|
||||
|
||||
|
||||
this.handleGroup.addShape('circle', {
|
||||
attrs: {
|
||||
x: width / 2,
|
||||
@ -174,7 +174,7 @@ export default class Handler {
|
||||
},
|
||||
name: `${name}-handler`
|
||||
})
|
||||
|
||||
|
||||
this.handleGroup.addShape('circle', {
|
||||
attrs: {
|
||||
x: width / 2,
|
||||
|
@ -90,7 +90,7 @@ export function dataToRectPath(data: number[], width: number, height: number): a
|
||||
});
|
||||
|
||||
const rectPoints = []
|
||||
for(let i = 0; i < points.length; i++) {
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const point = points[i]
|
||||
const param = {
|
||||
x: point[0],
|
||||
@ -190,7 +190,7 @@ export function getRectPoints(pointInfo): { x: number, y: number }[] {
|
||||
* @param isClosed path 是否需要闭合
|
||||
* @returns 返回矩形的 path
|
||||
*/
|
||||
export function getRectPath(points: {x: number, y: number}[], isClosed: boolean = true) {
|
||||
export function getRectPath(points: { x: number, y: number }[], isClosed: boolean = true) {
|
||||
const path = [];
|
||||
const firstPoint = points[0];
|
||||
path.push(['M', firstPoint.x, firstPoint.y]);
|
||||
|
527
src/plugins/timeBar/slider.ts
Normal file
527
src/plugins/timeBar/slider.ts
Normal file
@ -0,0 +1,527 @@
|
||||
/**
|
||||
* @file 基于 G 的缩略轴组件(Slider 组件)
|
||||
* @author hustcc
|
||||
*/
|
||||
|
||||
import { Event, IGroup, ICanvas, IShape } from '@antv/g-base';
|
||||
import { get, size, assign, each } from '@antv/util';
|
||||
import Trend, { TrendCfg } from './trend';
|
||||
import Handler from './handler';
|
||||
import { IGraph } from '../../interface/graph';
|
||||
import { ShapeStyle } from '../../types';
|
||||
|
||||
/**
|
||||
* 一些默认的样式配置
|
||||
*/
|
||||
|
||||
export const BACKGROUND_STYLE = {
|
||||
fill: '#416180',
|
||||
opacity: 0.05,
|
||||
};
|
||||
|
||||
export const FOREGROUND_STYLE = {
|
||||
fill: 'red',
|
||||
opacity: 0.15,
|
||||
cursor: 'move',
|
||||
};
|
||||
|
||||
export const DEFAULT_HANDLER_WIDTH = 10;
|
||||
|
||||
export const HANDLER_STYLE = {
|
||||
width: DEFAULT_HANDLER_WIDTH,
|
||||
height: 24,
|
||||
};
|
||||
|
||||
export const TEXT_STYLE = {
|
||||
textBaseline: 'middle',
|
||||
fill: '#000',
|
||||
opacity: 0.45,
|
||||
};
|
||||
|
||||
export const VALUE_CHANGE = 'valueChange';
|
||||
|
||||
export type SliderOption = Partial<{
|
||||
readonly backgroundStyle?: ShapeStyle;
|
||||
readonly foregroundStyle?: ShapeStyle;
|
||||
// 滑块样式
|
||||
readonly handlerStyle?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
style?: ShapeStyle;
|
||||
};
|
||||
readonly textStyle?: ShapeStyle;
|
||||
// 允许滑动位置
|
||||
readonly minLimit?: number;
|
||||
readonly maxLimit?: number;
|
||||
// 初始位置
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
// 滑块文本
|
||||
readonly minText: string;
|
||||
readonly maxText: string;
|
||||
}>;
|
||||
|
||||
interface SliderCfg extends SliderOption {
|
||||
readonly graph: IGraph;
|
||||
readonly canvas: ICanvas;
|
||||
readonly group: IGroup;
|
||||
// position size
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
|
||||
// style
|
||||
readonly trendCfg?: TrendCfg;
|
||||
}
|
||||
|
||||
export default class Slider {
|
||||
private group: IGroup;
|
||||
private graph: IGraph;
|
||||
private canvas: ICanvas;
|
||||
// 位置大小配置
|
||||
public x: number;
|
||||
public y: number;
|
||||
public width: number;
|
||||
public height: number;
|
||||
|
||||
private trendCfg: TrendCfg;
|
||||
// 样式配置
|
||||
private backgroundStyle: any;
|
||||
private foregroundStyle: any;
|
||||
private handlerStyle: any;
|
||||
private textStyle: any;
|
||||
/* 前景框,选中的区域 */
|
||||
private foregroundShape: IShape;
|
||||
/* 左侧(上侧)的按钮 */
|
||||
private minHandlerShape: Handler;
|
||||
/* 左侧文本 */
|
||||
private minTextShape: IShape;
|
||||
/* 由侧(下侧)的按钮 */
|
||||
private maxHandlerShape: Handler;
|
||||
/* 右侧文本 */
|
||||
private maxTextShape: IShape;
|
||||
|
||||
// 交互相关的数据信息
|
||||
private start: number;
|
||||
private end: number;
|
||||
private minText: string;
|
||||
private maxText: string;
|
||||
|
||||
private currentHandler: Handler | IShape;
|
||||
private prevX: number = 0;
|
||||
private prevY: number = 0;
|
||||
private sliderGroup: IGroup;
|
||||
|
||||
constructor(cfg: SliderCfg) {
|
||||
|
||||
const {
|
||||
x = 0,
|
||||
y = 0,
|
||||
width = 100,
|
||||
height = 16,
|
||||
trendCfg,
|
||||
backgroundStyle = {},
|
||||
foregroundStyle = {},
|
||||
handlerStyle = {},
|
||||
textStyle = {},
|
||||
// 缩略轴的初始位置
|
||||
start = 0,
|
||||
end = 1,
|
||||
minText = '',
|
||||
maxText = '',
|
||||
group,
|
||||
graph,
|
||||
canvas
|
||||
} = cfg;
|
||||
|
||||
this.graph = graph
|
||||
this.canvas = canvas
|
||||
this.group = group
|
||||
// position size
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
|
||||
this.trendCfg = trendCfg;
|
||||
// style
|
||||
this.backgroundStyle = { ...BACKGROUND_STYLE, ...backgroundStyle };
|
||||
this.foregroundStyle = { ...FOREGROUND_STYLE, ...foregroundStyle };
|
||||
this.handlerStyle = { ...HANDLER_STYLE, ...handlerStyle };
|
||||
this.textStyle = { ...TEXT_STYLE, ...textStyle };
|
||||
|
||||
// 初始信息
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
|
||||
this.minText = minText;
|
||||
this.maxText = maxText;
|
||||
|
||||
this.renderSlider();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
* @param cfg
|
||||
*/
|
||||
public update(cfg: Partial<SliderCfg>) {
|
||||
const { x, y, width, height, minText, maxText, start, end } = cfg;
|
||||
|
||||
// start、end 只能是 0~1 范围
|
||||
this.start = Math.min(1, Math.max(start, 0));
|
||||
this.end = Math.min(1, Math.max(end, 0));
|
||||
|
||||
// 如果传了则更新,没有传则不更新
|
||||
// @ts-ignore
|
||||
assign(this, {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
minText,
|
||||
maxText,
|
||||
});
|
||||
|
||||
// 更新 ui,不自动绘制
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
public setText(minText: string, maxText: string) {
|
||||
this.minTextShape.attr('text', minText)
|
||||
this.maxTextShape.attr('text', maxText)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化组件结构
|
||||
* @private
|
||||
*/
|
||||
private renderSlider() {
|
||||
const width = this.width;
|
||||
const height = this.height;
|
||||
|
||||
// 趋势图数据
|
||||
if (size(get(this.trendCfg, 'data'))) {
|
||||
new Trend({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
...this.trendCfg,
|
||||
group: this.group
|
||||
});
|
||||
}
|
||||
|
||||
const sliderGroup = this.group.addGroup({
|
||||
name: 'slider-group'
|
||||
})
|
||||
|
||||
this.sliderGroup = sliderGroup
|
||||
// 1. 背景
|
||||
sliderGroup.addShape('rect', {
|
||||
attrs: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width,
|
||||
height,
|
||||
...this.backgroundStyle,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. 左右文字
|
||||
this.minTextShape = sliderGroup.addShape('text', {
|
||||
attrs: {
|
||||
// x: 0,
|
||||
y: height / 2,
|
||||
textAlign: 'right',
|
||||
text: this.minText,
|
||||
silent: false,
|
||||
...this.textStyle,
|
||||
},
|
||||
capture: false
|
||||
});
|
||||
|
||||
this.maxTextShape = sliderGroup.addShape('text', {
|
||||
attrs: {
|
||||
// x: 0,
|
||||
y: height / 2,
|
||||
textAlign: 'left',
|
||||
text: this.maxText,
|
||||
silent: false,
|
||||
|
||||
...this.textStyle,
|
||||
},
|
||||
capture: false
|
||||
});
|
||||
|
||||
// 3. 前景 选中背景框
|
||||
this.foregroundShape = sliderGroup.addShape('rect', {
|
||||
attrs: {
|
||||
// x: 0,
|
||||
y: 0,
|
||||
// width: 0,
|
||||
height,
|
||||
...this.foregroundStyle,
|
||||
},
|
||||
});
|
||||
|
||||
// 滑块相关的大小信息
|
||||
const handlerWidth = get(this.handlerStyle, 'width', 10);
|
||||
const handlerHeight = get(this.handlerStyle, 'height', 24);
|
||||
|
||||
const minHandleGroup = this.group.addGroup({
|
||||
name: 'minHandlerShape'
|
||||
})
|
||||
// 4. 左右滑块
|
||||
this.minHandlerShape = new Handler({
|
||||
name: 'minHandlerShape',
|
||||
group: minHandleGroup,
|
||||
x: 0,
|
||||
y: 0,//(height - handlerHeight) / 2,
|
||||
width: handlerWidth,
|
||||
height: handlerHeight,
|
||||
cursor: 'ew-resize',
|
||||
...this.handlerStyle,
|
||||
});
|
||||
|
||||
const maxHandleGroup = this.group.addGroup({
|
||||
name: 'maxHandlerShape'
|
||||
})
|
||||
this.maxHandlerShape = new Handler({
|
||||
name: 'maxHandlerShape',
|
||||
group: maxHandleGroup,
|
||||
x: 0,
|
||||
y: 0,//(height - handlerHeight) / 2,
|
||||
width: handlerWidth,
|
||||
height: handlerHeight,
|
||||
cursor: 'ew-resize',
|
||||
...this.handlerStyle,
|
||||
});
|
||||
|
||||
// 根据 start end 更新 ui 的位置信息
|
||||
this.updateUI();
|
||||
|
||||
// 移动到对应的位置
|
||||
this.sliderGroup.move(this.x, this.y);
|
||||
|
||||
// 绑定事件鼠标事件
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定事件:
|
||||
* - 点击
|
||||
* - 滑动
|
||||
* - 拖拽
|
||||
* - 滚动
|
||||
* @private
|
||||
*/
|
||||
private bindEvents() {
|
||||
// 1. 左滑块的滑动
|
||||
const minHandleShapeGroup = this.group.find(group => group.get('name') === 'minHandlerShape')
|
||||
if (minHandleShapeGroup) {
|
||||
minHandleShapeGroup.on('minHandlerShape-rect:mousedown', this.onMouseDown(this.minHandlerShape))
|
||||
minHandleShapeGroup.on('minHandlerShape-rect:touchstart', this.onMouseDown(this.minHandlerShape));
|
||||
}
|
||||
|
||||
const maxHandleShapeGroup = this.group.find(group => group.get('name') === 'maxHandlerShape')
|
||||
// 2. 右滑块的滑动
|
||||
if (maxHandleShapeGroup) {
|
||||
maxHandleShapeGroup.on('maxHandlerShape-rect:mousedown', this.onMouseDown(this.maxHandlerShape));
|
||||
maxHandleShapeGroup.on('maxHandlerShape-rect:touchstart', this.onMouseDown(this.maxHandlerShape));
|
||||
}
|
||||
|
||||
// 3. 前景选中区域
|
||||
this.foregroundShape.on('mousedown', this.onMouseDown(this.foregroundShape));
|
||||
this.foregroundShape.on('touchstart', this.onMouseDown(this.foregroundShape));
|
||||
}
|
||||
|
||||
private onMouseDown = (handler: Handler | IShape) => (event: Event) => {
|
||||
// 1. 记录点击的滑块
|
||||
this.currentHandler = handler;
|
||||
|
||||
// 2. 存储当前点击位置
|
||||
// const { event } = e;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// 兼容移动端获取数据
|
||||
this.prevX = get(event, 'touches.0.pageX', event.x);
|
||||
this.prevY = get(event, 'touches.0.pageY', event.y);
|
||||
|
||||
// 3. 开始滑动的时候,绑定 move 和 up 事件
|
||||
const containerDOM = this.canvas.get('container');
|
||||
|
||||
containerDOM.addEventListener('mousemove', this.onMouseMove);
|
||||
containerDOM.addEventListener('mouseup', this.onMouseUp);
|
||||
containerDOM.addEventListener('mouseleave', this.onMouseUp);
|
||||
|
||||
// 移动端事件
|
||||
containerDOM.addEventListener('touchmove', this.onMouseMove);
|
||||
containerDOM.addEventListener('touchend', this.onMouseUp);
|
||||
containerDOM.addEventListener('touchcancel', this.onMouseUp);
|
||||
};
|
||||
|
||||
private onMouseMove = (e: MouseEvent) => {
|
||||
// 滑动过程中,计算偏移,更新滑块,然后 emit 数据出去
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const x = get(e, 'touches.0.pageX', e.pageX);
|
||||
const y = get(e, 'touches.0.pageY', e.pageY);
|
||||
console.log('slider move', e, x, y)
|
||||
|
||||
// 横向的 slider 只处理 x
|
||||
const offsetX = x - this.prevX;
|
||||
|
||||
const offsetXRange = this.adjustOffsetRange(offsetX / this.width);
|
||||
|
||||
// 更新 start end range 范围
|
||||
this.updateStartEnd(offsetXRange);
|
||||
// 更新 ui
|
||||
this.updateUI();
|
||||
|
||||
this.prevX = x;
|
||||
this.prevY = y;
|
||||
|
||||
// this.get('canvas').draw();
|
||||
|
||||
// 因为存储的 start、end 可能不一定是按大小存储的,所以排序一下,对外是 end >= start
|
||||
this.graph.emit(VALUE_CHANGE, {value: [this.start, this.end].sort()});
|
||||
};
|
||||
|
||||
private onMouseUp = () => {
|
||||
// 结束之后,取消绑定的事件
|
||||
if (this.currentHandler) {
|
||||
this.currentHandler = undefined;
|
||||
}
|
||||
|
||||
const containerDOM = this.canvas.get('container');
|
||||
if (containerDOM) {
|
||||
containerDOM.removeEventListener('mousemove', this.onMouseMove);
|
||||
containerDOM.removeEventListener('mouseup', this.onMouseUp);
|
||||
// 防止滑动到 canvas 外部之后,状态丢失
|
||||
containerDOM.removeEventListener('mouseleave', this.onMouseUp);
|
||||
|
||||
// 移动端事件
|
||||
containerDOM.removeEventListener('touchmove', this.onMouseMove);
|
||||
containerDOM.removeEventListener('touchend', this.onMouseUp);
|
||||
containerDOM.removeEventListener('touchcancel', this.onMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 调整 offsetRange,因为一些范围的限制
|
||||
* @param offsetRange
|
||||
*/
|
||||
private adjustOffsetRange(offsetRange: number): number {
|
||||
// 针对不同的滑动组件,处理的方式不同
|
||||
switch (this.currentHandler) {
|
||||
case this.minHandlerShape: {
|
||||
const min = 0 - this.start;
|
||||
const max = 1 - this.start;
|
||||
|
||||
return Math.min(max, Math.max(min, offsetRange));
|
||||
}
|
||||
case this.maxHandlerShape: {
|
||||
const min = 0 - this.end;
|
||||
const max = 1 - this.end;
|
||||
|
||||
return Math.min(max, Math.max(min, offsetRange));
|
||||
}
|
||||
case this.foregroundShape: {
|
||||
const min = 0 - this.start;
|
||||
const max = 1 - this.end;
|
||||
|
||||
return Math.min(max, Math.max(min, offsetRange));
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private updateStartEnd(offsetRange: number) {
|
||||
// 操作不同的组件,反馈不一样
|
||||
switch (this.currentHandler) {
|
||||
case this.minHandlerShape:
|
||||
this.start += offsetRange;
|
||||
break;
|
||||
case this.maxHandlerShape:
|
||||
this.end += offsetRange;
|
||||
break;
|
||||
case this.foregroundShape:
|
||||
this.start += offsetRange;
|
||||
this.end += offsetRange;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据移动的比例来更新 ui
|
||||
* @private
|
||||
*/
|
||||
private updateUI() {
|
||||
const min = this.start * this.width;
|
||||
const max = this.end * this.width;
|
||||
|
||||
// 1. foreground
|
||||
this.foregroundShape.attr('x', min);
|
||||
this.foregroundShape.attr('width', max - min);
|
||||
|
||||
// 滑块相关的大小信息
|
||||
const handlerWidth = get(this.handlerStyle, 'width', DEFAULT_HANDLER_WIDTH);
|
||||
|
||||
// 设置文本
|
||||
this.minTextShape.attr('text', this.minText);
|
||||
this.maxTextShape.attr('text', this.maxText);
|
||||
|
||||
const [minAttrs, maxAttrs] = this.dodgeText([min, max]);
|
||||
// 2. 左侧滑块和文字位置
|
||||
this.minHandlerShape.setX(min - handlerWidth / 2);
|
||||
// this.minText.attr('x', min);
|
||||
each(minAttrs, (v, k) => this.minTextShape.attr(k, v));
|
||||
|
||||
// 3. 右侧滑块和文字位置
|
||||
this.maxHandlerShape.setX(max - handlerWidth / 2);
|
||||
// this.maxText.attr('x', max);
|
||||
each(maxAttrs, (v, k) => this.maxTextShape.attr(k, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整 text 的位置,自动躲避
|
||||
* 根据位置,调整返回新的位置
|
||||
* @param range
|
||||
*/
|
||||
private dodgeText(range: [number, number]): [object, object] {
|
||||
const PADDING = 2;
|
||||
const handlerWidth = get(this.handlerStyle, 'width', DEFAULT_HANDLER_WIDTH);
|
||||
let minTextShape = this.minTextShape;
|
||||
let maxTextShape = this.maxTextShape;
|
||||
|
||||
let [min, max] = range;
|
||||
let sorted = false;
|
||||
|
||||
// 如果交换了位置,则对应的 min max 也交互
|
||||
if (min > max) {
|
||||
[min, max] = [max, min];
|
||||
[minTextShape, maxTextShape] = [maxTextShape, minTextShape];
|
||||
sorted = true;
|
||||
}
|
||||
|
||||
// 避让规则,优先显示在两侧,只有显示不下的时候,才显示在中间
|
||||
const minBBox = minTextShape.getBBox();
|
||||
const maxBBox = maxTextShape.getBBox();
|
||||
|
||||
const minAttrs =
|
||||
minBBox.width > min - PADDING
|
||||
? { x: min + handlerWidth / 2 + PADDING, textAlign: 'left' }
|
||||
: { x: min - handlerWidth / 2 - PADDING, textAlign: 'right' };
|
||||
|
||||
const maxAttrs =
|
||||
maxBBox.width > this.width - max - PADDING
|
||||
? { x: max - handlerWidth / 2 - PADDING, textAlign: 'right' }
|
||||
: { x: max + handlerWidth / 2 + PADDING, textAlign: 'left' };
|
||||
|
||||
return !sorted ? [minAttrs, maxAttrs] : [maxAttrs, minAttrs];
|
||||
}
|
||||
}
|
299
src/plugins/timeBar/timeBar.ts
Normal file
299
src/plugins/timeBar/timeBar.ts
Normal file
@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 基于 G 的时间轴组件
|
||||
*/
|
||||
import GCanvas from '@antv/g-canvas/lib/canvas';
|
||||
import GSVGCanvas from '@antv/g-svg/lib/canvas';
|
||||
import { IGroup, ICanvas } from '@antv/g-base';
|
||||
import createDOM from '@antv/dom-util/lib/create-dom'
|
||||
import { isString } from '@antv/util'
|
||||
import Base, { IPluginBaseConfig } from '../base';
|
||||
import Slider, { SliderOption, VALUE_CHANGE } from './slider'
|
||||
import TimeLine, { TimeLineCfg, TIMELINE_CHANGE } from './timeLine'
|
||||
import { IGraph } from '../../interface/graph';
|
||||
import { GraphData } from '../../types';
|
||||
import { TrendCfg } from './trend';
|
||||
|
||||
interface Callback {
|
||||
originValue: number[];
|
||||
value: number[];
|
||||
target: IGroup;
|
||||
}
|
||||
|
||||
interface TrendConfig extends TrendCfg{
|
||||
readonly slider?: SliderOption;
|
||||
}
|
||||
|
||||
interface TimeBarConfig extends IPluginBaseConfig {
|
||||
// position size
|
||||
readonly x?: number;
|
||||
readonly y?: number;
|
||||
readonly width?: number;
|
||||
readonly height?: number;
|
||||
readonly padding?: number;
|
||||
|
||||
// 趋势图配置项,包括滑块、及前后背景的配置
|
||||
readonly trend?: TrendConfig;
|
||||
|
||||
// 时间线的配置项
|
||||
readonly timeline?: TimeLineCfg;
|
||||
|
||||
// readonly opti
|
||||
rangeChange?: (graph: IGraph, minValue: string, maxValue: string) => void;
|
||||
valueChange?: (graph: IGraph, value: string) => void;
|
||||
}
|
||||
|
||||
export default class TimeBar extends Base {
|
||||
private cacheGraphData: GraphData;
|
||||
|
||||
public getDefaultCfgs(): TimeBarConfig {
|
||||
return {
|
||||
container: null,
|
||||
className: 'g6-component-timebar',
|
||||
padding: 10,
|
||||
speed: 2,
|
||||
loop: false,
|
||||
trend: {
|
||||
data: [],
|
||||
group: null,
|
||||
isArea: false,
|
||||
smooth: true,
|
||||
slider: {
|
||||
minLimit: 0,
|
||||
maxLimit: 1,
|
||||
start: 0.1,
|
||||
end: 0.9,
|
||||
minText: 'min',
|
||||
maxText: 'max',
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
constructor(cfgs?: TimeBarConfig) {
|
||||
super(cfgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 TimeBar 的容器
|
||||
*/
|
||||
public initContainer() {
|
||||
const graph: IGraph = this.get('graph');
|
||||
const { width, height } = this._cfgs
|
||||
const className: string = this.get('className') || 'g6-component-timebar';
|
||||
let parentNode: string | HTMLElement = this.get('container');
|
||||
const container: HTMLElement = createDOM(
|
||||
`<div class='${className}' style='position: absolute; width: ${width}px; height: ${height}px; overflow: hidden'></div>`,
|
||||
);
|
||||
|
||||
if (isString(parentNode)) {
|
||||
parentNode = document.getElementById(parentNode) as HTMLElement;
|
||||
}
|
||||
|
||||
if (parentNode) {
|
||||
parentNode.appendChild(container);
|
||||
} else {
|
||||
graph.get('container').appendChild(container);
|
||||
}
|
||||
|
||||
this.set('container', container);
|
||||
|
||||
let canvas;
|
||||
const renderer = graph.get('renderer');
|
||||
if (renderer !== 'SVG') {
|
||||
canvas = new GSVGCanvas({
|
||||
container: container,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
} else {
|
||||
canvas = new GCanvas({
|
||||
container: container,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
this.set('canvas', canvas);
|
||||
}
|
||||
|
||||
public init() {
|
||||
this.initContainer()
|
||||
const canvas: ICanvas = this.get('canvas')
|
||||
const timeBarGroup = canvas.addGroup({
|
||||
name: 'timebar-group'
|
||||
})
|
||||
|
||||
this.set('timeBarGroup', timeBarGroup)
|
||||
|
||||
this.renderTrend()
|
||||
this.renderTimeLine()
|
||||
this.initEvent()
|
||||
}
|
||||
|
||||
private renderTrend() {
|
||||
const ratio = 0.6
|
||||
const { width, height, x, y, padding, trend } = this._cfgs
|
||||
const { slider, ...other } = trend
|
||||
|
||||
const realHeight = height - 2 * padding
|
||||
const realWidth = width - 2 * padding
|
||||
const sliderComponent = new Slider({
|
||||
graph: this.get('graph'),
|
||||
canvas: this.get('canvas'),
|
||||
group: this.get('timeBarGroup'),
|
||||
x,
|
||||
y,
|
||||
width: realWidth,
|
||||
height: realHeight * ratio,
|
||||
trendCfg: {
|
||||
...other,
|
||||
data: other.data.map(d => d.value)
|
||||
},
|
||||
...trend.slider,
|
||||
handlerStyle: {
|
||||
...slider.handlerStyle,
|
||||
height: realHeight * ratio
|
||||
}
|
||||
})
|
||||
|
||||
this.set('slider', sliderComponent)
|
||||
}
|
||||
|
||||
private renderTimeLine() {
|
||||
const ratio = 0.2
|
||||
const { width, height, x, y, padding, timeline } = this._cfgs
|
||||
const { speed, defaultCurrentTick, loop, axisCfg = {}, controllerCfg = {}, ticks } = timeline
|
||||
const realHeight = height - 2 * padding
|
||||
const realWidth = width - 2 * padding
|
||||
|
||||
const timelineComponent = new TimeLine({
|
||||
graph: this.get('graph'),
|
||||
canvas: this.get('canvas'),
|
||||
group: this.get('timeBarGroup'),
|
||||
x,
|
||||
y: realHeight * 0.6,
|
||||
width: realWidth,
|
||||
height: realHeight * ratio,
|
||||
speed,
|
||||
defaultCurrentTick,
|
||||
loop,
|
||||
ticks,
|
||||
axisCfg,
|
||||
controllerCfg: {
|
||||
x,
|
||||
y: realHeight * (1 - ratio),
|
||||
width: realWidth,
|
||||
height: realHeight * ratio,
|
||||
...controllerCfg
|
||||
}
|
||||
})
|
||||
|
||||
this.set('timeline', timelineComponent)
|
||||
}
|
||||
|
||||
private filterData(evt) {
|
||||
const { value } = evt;
|
||||
const { data: trendData } = this._cfgs.trend
|
||||
const rangeChange = this.get('rangeChange');
|
||||
const graph: IGraph = this.get('graph');
|
||||
const slider = this.get('slider');
|
||||
|
||||
const min = Math.round(trendData.length * value[0]);
|
||||
let max = Math.round(trendData.length * value[1]);
|
||||
max = max >= trendData.length ? trendData.length - 1 : max;
|
||||
|
||||
const minText = trendData[min].date;
|
||||
const maxText = trendData[max].date;
|
||||
|
||||
slider.setText(minText, maxText)
|
||||
|
||||
if (rangeChange) {
|
||||
rangeChange(graph, minText, maxText);
|
||||
} else {
|
||||
// 自动过滤数据,并渲染 graph
|
||||
const graphData = graph.save() as GraphData;
|
||||
|
||||
if (
|
||||
!this.cacheGraphData ||
|
||||
(this.cacheGraphData.nodes && this.cacheGraphData.nodes.length === 0)
|
||||
) {
|
||||
this.cacheGraphData = graphData;
|
||||
}
|
||||
|
||||
// 过滤不在 min 和 max 范围内的节点
|
||||
const filterData = this.cacheGraphData.nodes.filter(
|
||||
(d: any) => d.date >= minText && d.date <= maxText,
|
||||
);
|
||||
|
||||
const nodeIds = filterData.map((node) => node.id);
|
||||
|
||||
// 过滤 source 或 target 不在 min 和 max 范围内的边
|
||||
const fileterEdges = this.cacheGraphData.edges.filter(
|
||||
(edge) => nodeIds.includes(edge.source) && nodeIds.includes(edge.target),
|
||||
);
|
||||
|
||||
graph.changeData({
|
||||
nodes: filterData,
|
||||
edges: fileterEdges,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderCurrentData(value: string) {
|
||||
const valueChange = this.get('valueChange');
|
||||
const graph: IGraph = this.get('graph');
|
||||
|
||||
if (valueChange) {
|
||||
valueChange(graph, value);
|
||||
} else {
|
||||
// 自动过滤数据,并渲染 graph
|
||||
const graphData = graph.save() as GraphData;
|
||||
|
||||
if (
|
||||
!this.cacheGraphData ||
|
||||
(this.cacheGraphData.nodes && this.cacheGraphData.nodes.length === 0)
|
||||
) {
|
||||
this.cacheGraphData = graphData;
|
||||
}
|
||||
|
||||
// 过滤当前的节点
|
||||
const filterData = this.cacheGraphData.nodes.filter(
|
||||
(d: any) => d.date === value,
|
||||
);
|
||||
|
||||
const nodeIds = filterData.map((node) => node.id);
|
||||
|
||||
// 过滤 source 或 target
|
||||
const fileterEdges = this.cacheGraphData.edges.filter(
|
||||
(edge) => nodeIds.includes(edge.source) && nodeIds.includes(edge.target),
|
||||
);
|
||||
|
||||
graph.changeData({
|
||||
nodes: filterData,
|
||||
edges: fileterEdges,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private initEvent() {
|
||||
const { start, end } = this._cfgs.trend.slider;
|
||||
const graph: IGraph = this.get('graph');
|
||||
graph.on('afterrender', () => {
|
||||
this.filterData({ value: [start, end] });
|
||||
});
|
||||
|
||||
graph.on(VALUE_CHANGE, (evt: Callback) => {
|
||||
this.filterData(evt);
|
||||
});
|
||||
|
||||
// 时间轴的值发生改变的事件
|
||||
graph.on(TIMELINE_CHANGE, (value: string) => {
|
||||
this.renderCurrentData(value)
|
||||
})
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
super.destroy();
|
||||
const group = this.get('timeBarGroup')
|
||||
group.off('playPauseBtn:click')
|
||||
}
|
||||
}
|
587
src/plugins/timeBar/timeLine.ts
Normal file
587
src/plugins/timeBar/timeLine.ts
Normal file
@ -0,0 +1,587 @@
|
||||
/**
|
||||
* @file 基于 G 的播放轴组件
|
||||
*/
|
||||
import { Event, IGroup, IShape, ICanvas } from '@antv/g-base';
|
||||
import { deepMix, findIndex, get } from '@antv/util'
|
||||
import Button from './timeButton';
|
||||
import { IGraph } from '../../interface/graph';
|
||||
import { ShapeStyle } from '../../types';
|
||||
|
||||
export const TIMELINE_START = 'timelinestart';
|
||||
export const TIMELINE_CHANGE = 'timelinechange';
|
||||
export const TIMELINE_END = 'timelineend';
|
||||
|
||||
const DEFAULT_RECT_FILL = '#ccc'
|
||||
const DEFAULT_RECT_STROKE = 'green'
|
||||
const DEFAULT_PLAYBTN_STYLE = {
|
||||
fill: '#607889'
|
||||
}
|
||||
|
||||
const DEFAULT_PREBTN_STYLE = {
|
||||
fill: 'red'
|
||||
}
|
||||
|
||||
const DEFAULT_NEXTBTN_STYLE = {
|
||||
fill: 'green'
|
||||
}
|
||||
|
||||
const PADDING_LEFT = 20;
|
||||
const PADDING_RIGHT = 20;
|
||||
|
||||
export type ControllerCfg = Partial<{
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly fill: string;
|
||||
readonly stroke: string;
|
||||
readonly preBtnStyle: ShapeStyle;
|
||||
readonly nextBtnStyle: ShapeStyle;
|
||||
readonly playBtnStyle: ShapeStyle;
|
||||
}>
|
||||
|
||||
export type AxisCfg = Partial<{
|
||||
fill: string;
|
||||
stroke: string;
|
||||
ratio: number;
|
||||
textStyle: ShapeStyle;
|
||||
}>;
|
||||
|
||||
/** 播放轴配置项 */
|
||||
export interface TimeLineCfg {
|
||||
readonly graph: IGraph;
|
||||
readonly canvas: ICanvas;
|
||||
readonly group: IGroup;
|
||||
// readonly rangeChange?: (graph: IGraph, min: number, max: number) => void;
|
||||
|
||||
/** 播放轴位置数据 */
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
|
||||
/** 刻度值 */
|
||||
readonly ticks: string[];
|
||||
/** 播放速度,1 个 tick 花费时间 */
|
||||
readonly speed?: number;
|
||||
/** 默认当前刻度值 */
|
||||
readonly defaultCurrentTick?: string;
|
||||
/** 是否循环播放 */
|
||||
readonly loop?: boolean;
|
||||
readonly controllerCfg?: ControllerCfg;
|
||||
readonly axisCfg?: AxisCfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参考示例
|
||||
* https://www.gapminder.org/tools/#$state$time$value=1870&delay:100;;&chart-type=bubbles
|
||||
*/
|
||||
export default class TimeLine {
|
||||
/** 是否处于播放状态 */
|
||||
private isPlay: boolean;
|
||||
/** 当前处于刻度值 */
|
||||
private currentTick: string;
|
||||
/** 刻度位置预处理 */
|
||||
private tickPosList: number[];
|
||||
|
||||
/** 组件 */
|
||||
private timeLineButton: Button;
|
||||
private preStepButton: IShape;
|
||||
private nextStepButton: IShape;
|
||||
|
||||
private timeLine: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
shape: IShape;
|
||||
textList: IShape[];
|
||||
|
||||
/** 刻度值 */
|
||||
readonly ticks: string[];
|
||||
/** 播放速度,1 个 tick 花费时间 */
|
||||
readonly speed?: number;
|
||||
/** 默认当前刻度值 */
|
||||
readonly defaultCurrentTick?: string;
|
||||
/** 是否循环播放 */
|
||||
readonly loop?: boolean;
|
||||
readonly controllerCfg?: ControllerCfg;
|
||||
readonly axisCfg: AxisCfg;
|
||||
};
|
||||
private timeSelect: IShape;
|
||||
private timeSelectText: IShape;
|
||||
|
||||
/** 偏移量 */
|
||||
private prevX: number;
|
||||
|
||||
/** 动画 id */
|
||||
private playHandler: number;
|
||||
|
||||
private canvas: ICanvas;
|
||||
private graph: IGraph;
|
||||
private group: IGroup;
|
||||
|
||||
constructor(cfg: TimeLineCfg) {
|
||||
this.timeLine = deepMix(
|
||||
{},
|
||||
{
|
||||
speed: 1,
|
||||
loop: false,
|
||||
},
|
||||
cfg
|
||||
);
|
||||
this.canvas = cfg.canvas
|
||||
this.graph = cfg.graph
|
||||
this.group = cfg.group
|
||||
this.init()
|
||||
// this.timeLine
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
public update(cfg: Partial<TimeLineCfg>) {
|
||||
this.timeLine = deepMix({}, this.timeLine, cfg)
|
||||
const { ticks } = this.timeLine;
|
||||
this.currentTick = ticks.includes(this.currentTick) ? this.currentTick : ticks[0];
|
||||
this.renderPlayButton();
|
||||
this.renderTimeLine();
|
||||
this.renderTimeSelect(this.currentTick);
|
||||
}
|
||||
|
||||
public init() {
|
||||
|
||||
const { ticks, defaultCurrentTick } = this.timeLine;
|
||||
|
||||
if (ticks && ticks.length) {
|
||||
this.currentTick = ticks.includes(defaultCurrentTick) ? defaultCurrentTick : ticks[0];
|
||||
this.renderPlayButton();
|
||||
this.renderTimeLine();
|
||||
this.renderTimeSelect(this.currentTick);
|
||||
this.initEvent();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取播放键 marker path */
|
||||
private getNextMarkerPath(x, y, len) {
|
||||
// const len = r * 0.5;
|
||||
// return [
|
||||
// ['M', x - len / Math.sqrt(2) / 2, y - len / 2],
|
||||
// ['L', x + len / Math.sqrt(2), y],
|
||||
// ['L', x - len / Math.sqrt(2) / 2, y + len / 2],
|
||||
// ];
|
||||
return [
|
||||
['M', x, y - len],
|
||||
['L', x + len, y],
|
||||
['L', x, y + len],
|
||||
];
|
||||
}
|
||||
|
||||
private getPreMarkerPath(x, y, len) {
|
||||
return [
|
||||
['M', x, y - len],
|
||||
['L', x - len, y],
|
||||
['L', x, y + len],
|
||||
];
|
||||
}
|
||||
|
||||
private renderPlayButton() {
|
||||
const { controllerCfg } = this.timeLine;
|
||||
const { width, height, x, y,
|
||||
fill = DEFAULT_RECT_FILL, stroke = DEFAULT_RECT_STROKE,
|
||||
playBtnStyle = DEFAULT_PLAYBTN_STYLE,
|
||||
preBtnStyle = DEFAULT_PREBTN_STYLE,
|
||||
nextBtnStyle = DEFAULT_NEXTBTN_STYLE } = controllerCfg
|
||||
const r = height / 2 - 5;
|
||||
const realY = y + 10
|
||||
|
||||
// 绘制最外层的矩形包围框
|
||||
this.group.addShape('rect', {
|
||||
attrs: {
|
||||
x,
|
||||
y: realY,
|
||||
width,
|
||||
height,
|
||||
stroke,
|
||||
fill
|
||||
}
|
||||
})
|
||||
|
||||
if (this.timeLineButton) {
|
||||
this.timeLineButton.update({
|
||||
x: width / 2,
|
||||
y: realY,
|
||||
r,
|
||||
});
|
||||
} else {
|
||||
this.timeLineButton = new Button({
|
||||
group: this.group,
|
||||
x: width / 2,
|
||||
y: realY + r + 5,
|
||||
r,
|
||||
isPlay: this.isPlay,
|
||||
style: playBtnStyle
|
||||
});
|
||||
}
|
||||
|
||||
// 点击前进按钮
|
||||
this.preStepButton = this.group.addShape('circle', {
|
||||
attrs: {
|
||||
x: width / 2 - 5 * r,
|
||||
y: realY + r + 5,
|
||||
r,
|
||||
...preBtnStyle
|
||||
},
|
||||
name: 'preStepBtn'
|
||||
})
|
||||
this.group.addShape('path', {
|
||||
attrs: {
|
||||
path: this.getPreMarkerPath(width / 2 - 5 * r, realY + r + 5, r * 0.5),
|
||||
fill: '#fff'
|
||||
},
|
||||
capture: false
|
||||
})
|
||||
this.group.addShape('path', {
|
||||
attrs: {
|
||||
path: this.getPreMarkerPath(width / 2 - 4.5 * r, realY + r + 5, r * 0.5),
|
||||
fill: '#fff'
|
||||
},
|
||||
capture: false
|
||||
})
|
||||
|
||||
// 点击后退按钮
|
||||
this.nextStepButton = this.group.addShape('circle', {
|
||||
attrs: {
|
||||
x: width / 2 + 5 * r,
|
||||
y: realY + r + 5,
|
||||
r,
|
||||
...nextBtnStyle
|
||||
},
|
||||
name: 'nextStepBtn'
|
||||
})
|
||||
this.group.addShape('path', {
|
||||
attrs: {
|
||||
path: this.getNextMarkerPath(width / 2 + 5 * r, realY + r + 5, r * 0.5),
|
||||
fill: '#fff'
|
||||
},
|
||||
capture: false
|
||||
})
|
||||
this.group.addShape('path', {
|
||||
attrs: {
|
||||
path: this.getNextMarkerPath(width / 2 + 4.5 * r, realY + r + 5, r * 0.5),
|
||||
fill: '#fff'
|
||||
},
|
||||
capture: false
|
||||
})
|
||||
}
|
||||
|
||||
private getTimeLinePath() {
|
||||
const { x, y, width, height } = this.timeLine;
|
||||
const r = height / 2;
|
||||
|
||||
if (width > 0) {
|
||||
return (
|
||||
`M${x}, ${y}` +
|
||||
`A${r},${r} 90 0,0 ${x},${y + height}` +
|
||||
`L${x + width}, ${y + height}` +
|
||||
`A${r},${r} 90 0,0 ${x + width},${y}` +
|
||||
`L${x}, ${y}`
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private renderTimeLine() {
|
||||
const { width, height, ticks, x, y } = this.timeLine;
|
||||
|
||||
if (!this.timeLine) {
|
||||
this.timeLine = {} as any;
|
||||
}
|
||||
|
||||
/** 默认高度是真实高度 15% */
|
||||
this.timeLine.height = height * 0.15;
|
||||
this.timeLine.x = x + height / 2;
|
||||
this.timeLine.y = y + this.timeLine.height + 10
|
||||
this.timeLine.width = width - this.timeLine.x - PADDING_RIGHT;
|
||||
|
||||
if (this.timeLine && this.timeLine.shape) {
|
||||
this.timeLine.shape.attr('path', this.getTimeLinePath());
|
||||
} else {
|
||||
this.timeLine.shape = this.group.addShape('path', {
|
||||
attrs: {
|
||||
path: this.getTimeLinePath(),
|
||||
fill: '#607889',
|
||||
opacity: 0.2,
|
||||
stroke: 'red'
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const interval = this.timeLine.width / (ticks.length - 1);
|
||||
this.tickPosList = [];
|
||||
if (this.timeLine.textList && this.timeLine.textList.length) {
|
||||
this.timeLine.textList.forEach((text) => {
|
||||
text.destroy();
|
||||
});
|
||||
}
|
||||
let lastX = -Infinity;
|
||||
this.timeLine.textList = ticks.map((tick, index) => {
|
||||
this.tickPosList.push(this.timeLine.x + index * interval);
|
||||
|
||||
const text = this.group.addShape('text', {
|
||||
attrs: {
|
||||
x: this.timeLine.x + index * interval,
|
||||
y: this.timeLine.y + this.timeLine.height + 5,
|
||||
text: tick,
|
||||
textAlign: 'center',
|
||||
textBaseline: 'top',
|
||||
fill: '#607889',
|
||||
opacity: 0.35,
|
||||
},
|
||||
});
|
||||
|
||||
const bbox = text.getBBox();
|
||||
|
||||
// 抽样,标签与标签间距不小于 10
|
||||
if (bbox.minX > lastX) {
|
||||
text.show();
|
||||
lastX = bbox.minX + bbox.width + 10;
|
||||
} else {
|
||||
text.hide();
|
||||
}
|
||||
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
private renderTimeSelect(tickValue: string) {
|
||||
const { ticks, height } = this.timeLine;
|
||||
const interval = this.timeLine.width / (ticks.length - 1);
|
||||
const index = findIndex(ticks, (tick) => tick === tickValue);
|
||||
const x = this.timeLine.x + index * interval;
|
||||
const r = height
|
||||
const y = this.timeLine.y + r / 2 /// 2 + height;
|
||||
|
||||
if (this.timeSelect) {
|
||||
this.timeSelect.attr('x', x);
|
||||
this.timeSelect.attr('y', y);
|
||||
this.timeSelect.attr('r', r);
|
||||
} else {
|
||||
this.timeSelect = this.group.addShape('circle', {
|
||||
attrs: {
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
fill: '#607889',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.timeSelectText) {
|
||||
this.timeSelectText.attr('x', x);
|
||||
this.timeSelectText.attr('y', y - height * 0.15 - 14);
|
||||
this.timeSelectText.attr('text', this.currentTick);
|
||||
} else {
|
||||
this.timeSelectText = this.group.addShape('text', {
|
||||
attrs: {
|
||||
x,
|
||||
y: y - height - 14,
|
||||
text: this.currentTick,
|
||||
textAlign: 'center',
|
||||
textBaseline: 'top',
|
||||
fill: '#607889',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 输入当前圆点位置,输出离哪个 tick 的位置最近 */
|
||||
private adjustTickIndex(timeSelectX: number) {
|
||||
for (let i = 0; i < this.tickPosList.length - 1; i++) {
|
||||
if (this.tickPosList[i] <= timeSelectX && timeSelectX <= this.tickPosList[i + 1]) {
|
||||
return Math.abs(this.tickPosList[i] - timeSelectX) < Math.abs(timeSelectX - this.tickPosList[i + 1])
|
||||
? i
|
||||
: i + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 拖动或自动播放过程中,设置 TimeSelect 的位置 */
|
||||
private setTimeSelectX(offsetX: number) {
|
||||
let timeSelectX = this.timeSelect.attr('x') + offsetX;
|
||||
// 防止左右溢出
|
||||
if (timeSelectX < this.timeLine.x) {
|
||||
timeSelectX = this.timeLine.x;
|
||||
}
|
||||
if (timeSelectX > this.timeLine.x + this.timeLine.width) {
|
||||
timeSelectX = this.timeLine.x + this.timeLine.width;
|
||||
// 正在播放场景
|
||||
if (this.isPlay) {
|
||||
// 如果是循环
|
||||
if (this.timeLine.loop) {
|
||||
// 当前滑动点已经处于最后一个 tick 上,才能重置回去,继续循环
|
||||
if (this.timeSelect.attr('x') === this.timeLine.x + this.timeLine.width) {
|
||||
timeSelectX = this.timeLine.x;
|
||||
}
|
||||
} else {
|
||||
this.isPlay = false;
|
||||
this.changePlayStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
this.timeSelect.attr('x', timeSelectX);
|
||||
this.timeSelectText.attr('x', timeSelectX);
|
||||
|
||||
const index = this.adjustTickIndex(timeSelectX);
|
||||
if (this.currentTick !== this.timeLine.ticks[index]) {
|
||||
this.currentTick = this.timeLine.ticks[index];
|
||||
this.timeSelectText.attr('text', this.currentTick);
|
||||
this.graph.emit(TIMELINE_CHANGE, this.currentTick);
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步圆点到 currnentTick */
|
||||
private syncCurrnentTick() {
|
||||
const { ticks } = this.timeLine;
|
||||
const interval = this.timeLine.width / (ticks.length - 1);
|
||||
const index = findIndex(ticks, (tick) => tick === this.currentTick);
|
||||
const x = this.timeLine.x + index * interval;
|
||||
this.timeSelect.attr('x', x);
|
||||
this.timeSelectText.attr('x', x);
|
||||
}
|
||||
|
||||
private onTimeSelectMouseMove = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const x = get(e, 'touches.0.pageX', e.pageX);
|
||||
const offsetX = x - this.prevX;
|
||||
|
||||
this.setTimeSelectX(offsetX);
|
||||
|
||||
this.prevX = x;
|
||||
};
|
||||
|
||||
private onTimeSelectMouseUp = (e: Event) => {
|
||||
this.syncCurrnentTick();
|
||||
|
||||
this.graph.emit(TIMELINE_END, null);
|
||||
|
||||
// 取消事件
|
||||
const containerDOM = this.canvas.get('container');
|
||||
if (containerDOM) {
|
||||
containerDOM.removeEventListener('mousemove', this.onTimeSelectMouseMove);
|
||||
containerDOM.removeEventListener('mouseup', this.onTimeSelectMouseUp);
|
||||
// 防止滑动到 canvas 外部之后,状态丢失
|
||||
containerDOM.removeEventListener('mouseleave', this.onTimeSelectMouseUp);
|
||||
// 移动端事件
|
||||
containerDOM.removeEventListener('touchmove', this.onTimeSelectMouseMove);
|
||||
containerDOM.removeEventListener('touchend', this.onTimeSelectMouseUp);
|
||||
containerDOM.removeEventListener('touchcancel', this.onTimeSelectMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
private onTimeSelectMouseDown = (e: Event) => {
|
||||
// const { event } = e;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (this.isPlay === false) {
|
||||
this.graph.emit(TIMELINE_START, null);
|
||||
} else {
|
||||
// 取消播放状态
|
||||
this.isPlay = false;
|
||||
// 拖动过程中的播放暂停不需要调整 tick 位置,防止偏移
|
||||
this.changePlayStatus(false);
|
||||
}
|
||||
|
||||
this.prevX = get(e, 'touches.0.pageX', e.clientX);
|
||||
|
||||
// 开始滑动的时候,绑定 move 和 up 事件
|
||||
const containerDOM = this.canvas.get('container');
|
||||
containerDOM.addEventListener('mousemove', this.onTimeSelectMouseMove);
|
||||
containerDOM.addEventListener('mouseup', this.onTimeSelectMouseUp);
|
||||
containerDOM.addEventListener('mouseleave', this.onTimeSelectMouseUp);
|
||||
// 移动端事件
|
||||
containerDOM.addEventListener('touchmove', this.onTimeSelectMouseMove);
|
||||
containerDOM.addEventListener('touchend', this.onTimeSelectMouseUp);
|
||||
containerDOM.addEventListener('touchcancel', this.onTimeSelectMouseUp);
|
||||
};
|
||||
|
||||
private startPlay() {
|
||||
return window.requestAnimationFrame(() => {
|
||||
const { speed, ticks, width } = this.timeLine
|
||||
|
||||
const tickInterval = width / ticks.length;
|
||||
const offsetX = tickInterval / ((speed * 1000) / 60);
|
||||
|
||||
this.setTimeSelectX(offsetX);
|
||||
|
||||
if (this.isPlay) {
|
||||
this.playHandler = this.startPlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private changePlayStatus(isSync = true) {
|
||||
this.timeLineButton.update({
|
||||
isPlay: this.isPlay,
|
||||
});
|
||||
if (this.isPlay) {
|
||||
// 开始播放
|
||||
this.playHandler = this.startPlay();
|
||||
this.graph.emit(TIMELINE_START, null);
|
||||
} else {
|
||||
// 结束播放
|
||||
if (this.playHandler) {
|
||||
window.cancelAnimationFrame(this.playHandler);
|
||||
if (isSync) {
|
||||
this.syncCurrnentTick();
|
||||
this.graph.emit(TIMELINE_END, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private initEvent() {
|
||||
/** 播放/暂停事件 */
|
||||
this.group.on('playPauseBtn:click', () => {
|
||||
this.isPlay = !this.isPlay;
|
||||
this.changePlayStatus();
|
||||
})
|
||||
|
||||
// 处理前进一步的事件
|
||||
this.group.on('nextStepBtn:click', () => {
|
||||
this.nextStepButton.attr('fill', 'green')
|
||||
const { width, ticks } = this.timeLine;
|
||||
const index = findIndex(ticks, (tick) => tick === this.currentTick);
|
||||
const offsetX = width / (ticks.length - 1);
|
||||
this.setTimeSelectX(offsetX);
|
||||
|
||||
if (index === ticks.length - 2) {
|
||||
this.preStepButton.attr('fill', '#ccc')
|
||||
}
|
||||
})
|
||||
|
||||
// 处理后退一步的事件
|
||||
this.group.on('preStepBtn:click', () => {
|
||||
this.preStepButton.attr('fill', 'red')
|
||||
const { width, ticks } = this.timeLine;
|
||||
const index = findIndex(ticks, (tick) => tick === this.currentTick);
|
||||
|
||||
const offsetX = width / (ticks.length - 1);
|
||||
this.setTimeSelectX(-offsetX);
|
||||
|
||||
if (index === 1) {
|
||||
this.nextStepButton.attr('fill', '#ccc')
|
||||
}
|
||||
})
|
||||
|
||||
/** 播放轴上圆点滑动事件 */
|
||||
this.timeSelect.on('mousedown', this.onTimeSelectMouseDown);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.group.off('playPauseBtn:click')
|
||||
this.timeSelect.off();
|
||||
}
|
||||
}
|
@ -103,7 +103,7 @@ export default class Trend {
|
||||
const trendGroup = this.group.addGroup({
|
||||
name: 'trend-group'
|
||||
})
|
||||
|
||||
|
||||
// 背景
|
||||
trendGroup.addShape('rect', {
|
||||
attrs: {
|
||||
@ -124,7 +124,7 @@ export default class Trend {
|
||||
...lineStyle,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// 在 line 的基础上,绘制面积图
|
||||
if (isArea) {
|
||||
const areaPath = linePathToAreaPath(path, width, height, data);
|
||||
|
@ -44,7 +44,7 @@ for (let i = 0; i < 100; i++) {
|
||||
|
||||
describe('timeline play with timebar', () => {
|
||||
it('trend timebar', () => {
|
||||
|
||||
|
||||
const timeBarData = [];
|
||||
|
||||
for (let i = 1; i < 60; i++) {
|
||||
|
Loading…
Reference in New Issue
Block a user