feat: timebar component

This commit is contained in:
baizn 2020-08-28 17:52:22 +08:00 committed by Moyee
parent b1701eff22
commit 09266ec075
8 changed files with 1422 additions and 9 deletions

View File

@ -129,4 +129,4 @@
"webpack-cli": "^3.3.10",
"worker-loader": "^3.0.0"
}
}
}

View File

@ -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,

View File

@ -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]);

View 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];
}
}

View 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')
}
}

View 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();
}
}

View File

@ -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);

View File

@ -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++) {