feat: slice type timebar and tooltp for timebar.

This commit is contained in:
Yanyan-Wang 2020-09-17 18:46:28 +08:00 committed by Moyee
parent ef57f7909a
commit 9e03a2d359
5 changed files with 865 additions and 1 deletions

View File

@ -0,0 +1,486 @@
/**
* G
*/
import GCanvas from '@antv/g-canvas/lib/canvas';
import GSVGCanvas from '@antv/g-svg/lib/canvas';
import { 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 TimeBarTooltip from './timeBarTooltip';
import { SliderOption, ControllerCfg } from './trendTimeBar'
import { IGraph } from '../../interface/graph';
import { GraphData } from '../../types';
interface TrendConfig {
readonly slider?: SliderOption;
// 数据
readonly data: {
date: string;
value: string;
}[];
// 位置大小
readonly x?: number;
readonly y?: number;
readonly width?: number;
readonly height?: number;
// 样式
readonly smooth?: boolean;
readonly isArea?: boolean;
readonly backgroundStyle?: object;
readonly lineStyle?: object;
readonly areaStyle?: object;
}
interface TickStyle {
fill?: string,
stroke?: string,
lineWidth?: number,
opacity?: number,
fillOpacity?: number,
strokeOpacity?: number
}
interface TimeBarConfig extends IPluginBaseConfig {
// position size
readonly x?: number;
readonly y?: number;
readonly width?: number;
readonly height?: number;
readonly padding?: number;
// styles
readonly selectedTickStyle?: TickStyle;
readonly unselectedTickStyle?: TickStyle
readonly tooltipBackgroundColor?: string;
// 趋势图配置项
readonly trend?: TrendConfig;
// 滑块、及前后背景的配置
readonly slider?: SliderOption;
// 自定义标签格式化函数
readonly tickLabelFormatter?: (d: any) => string | boolean;
// 自定义 tooltip 内容格式化函数
readonly tooltipFomatter?: (d: any) => string;
// 控制按钮
readonly controllerCfg?: ControllerCfg;
// readonly opti
rangeChange?: (graph: IGraph, minValue: string, maxValue: string) => void;
valueChange?: (graph: IGraph, value: string) => void;
}
export default class TimeBarSlice extends Base {
private cacheGraphData: GraphData;
public getDefaultCfgs(): TimeBarConfig {
return {
container: null,
className: 'g6-component-timebar',
padding: 2,
trend: {
data: [],
isArea: false,
smooth: true
},
controllerCfg: {
speed: 2,
loop: false,
},
slider: {
minLimit: 0,
maxLimit: 1,
start: 0.1,
end: 0.9,
minText: 'min',
maxText: 'max',
},
selectedTickStyle: {
fill: '#5B8FF9'
},
unselectedTickStyle: {
fill: '#e6e8e9'
}
};
}
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;'></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.renderSlices()
// this.renderTimeLine()
this.initEvent()
}
private renderSlices() {
const self = this;
const ratio = 0.6
const { width, height, x, y, padding, trend, slider, tickLabelFormatter } = self._cfgs
const { data, ...other } = trend
const { start, end } = slider;
// const realHeight = height - 2 * padding
const realWidth = width - 2 * padding
const fontSize = 10;
const labelLineHeight = 4;
const labelAreaHeight = 3 * padding + labelLineHeight + fontSize;
const ticksAreaHeight = height - labelAreaHeight - 2 * padding;
// styles
const selectedTickStyle = self.get('selectedTickStyle');
const unselectedTickStyle = self.get('unselectedTickStyle');
const gap = 2;
const ticksLength = data.length;
const tickWidth = (realWidth - gap * (ticksLength - 1)) / ticksLength;
self.set('tickWidth', tickWidth);
const group = self.get('timeBarGroup');
const tickRects = [];
const labels = [];
const startTickId = Math.round(ticksLength * start);
const endTickId = Math.round(ticksLength * end);
self.set('startTickRectId', startTickId);
self.set('endickRectId', endTickId);
data.forEach((d, i) => {
// draw the tick rects
const selected = i >= startTickId && i <= endTickId;
const tickStyle = selected ? selectedTickStyle : unselectedTickStyle
const rect = group.addShape('rect', {
attrs: {
x: padding + i * (tickWidth + gap),
y: padding,
width: tickWidth,
height: ticksAreaHeight,
...tickStyle
},
draggable: true,
name: `tick-rect-${i}`
});
// draw the pick tick rects
const pickRect = group.addShape('rect', {
attrs: {
x: padding + i * tickWidth + gap * (2 * i - 1) / 2,
y: padding,
width: (i === 0 || i === ticksLength - 1) ? (tickWidth + gap / 2) : (tickWidth + gap),
height: ticksAreaHeight,
fill: '#fff',
opacity: 0
},
draggable: true,
name: `pick-rect-${i}`
});
const rectBBox = rect.getBBox();
const centerX = (rectBBox.minX + rectBBox.maxX) / 2;
tickRects.push({
rect,
pickRect,
value: d.date,
x: centerX,
y: rectBBox.minY
});
let label = undefined;
if (tickLabelFormatter) {
label = tickLabelFormatter(d);
if (!isString(label) && label) { // return true
label = d.date;
}
} else if (i % Math.round(ticksLength / 10) === 0) {
label = d.date;
}
if (label) {
labels.push(label);
// draw tick lines
const lineStartY = rectBBox.maxY + padding * 2;
group.addShape('line', {
attrs: {
stroke: '#BFBFBF',
x1: centerX,
y1: lineStartY,
x2: centerX,
y2: lineStartY + labelLineHeight
}
});
const labelStartY = lineStartY + labelLineHeight + padding;
const text = group.addShape('text', {
attrs: {
fill: '#8c8c8c',
stroke: '#fff',
lineWidth: 1,
x: centerX,
y: labelStartY,
textAlign: 'center',
text: label,
textBaseline: 'top',
fontSize: 10
},
capture: false
});
const textBBox = text.getBBox();
if (textBBox.maxX > width) {
text.attr('textAlign', 'right');
} else if (textBBox.minX < 0) {
text.attr('textAlign', 'left');
}
// draw tick labels
}
});
self.set('tickRects', tickRects);
}
private filterData(evt) {
const { value } = evt;
const { data: trendData } = this._cfgs.trend
const rangeChange = this.get('rangeChange');
const graph: IGraph = this.get('graph');
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;
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 self = this;
const { start, end } = self._cfgs.slider;
const graph: IGraph = self.get('graph');
graph.on('afterrender', () => {
self.filterData({ value: [start, end] });
});
const group = self.get('timeBarGroup');
group.on('click', e => {
const tickRects = self.get('tickRects');
// cancel the selected ticks
const unselectedTickStyle = self.get('unselectedTickStyle');
tickRects.forEach(tickRect => {
tickRect.rect.attr(unselectedTickStyle);
})
const targetRect = e.target;
if (targetRect.get('type') !== 'rect') return;
const id = parseInt(targetRect.get('name').split('-')[2]);
const selectedTickStyle = self.get('selectedTickStyle');
tickRects[id].rect.attr(selectedTickStyle);
self.set('startTickRectId', id);
self.set('endTickRectId', id);
const ticksLength = tickRects.length;
const start = id / ticksLength;
this.filterData({ value: [start, start] });
});
group.on('dragstart', e => {
const tickRects = self.get('tickRects');
// cancel the selected ticks
const unselectedTickStyle = self.get('unselectedTickStyle');
tickRects.forEach(tickRect => {
tickRect.rect.attr(unselectedTickStyle);
})
const targetRect = e.target;
const id = parseInt(targetRect.get('name').split('-')[2]);
const selectedTickStyle = self.get('selectedTickStyle');
tickRects[id].rect.attr(selectedTickStyle);
self.set('startTickRectId', id);
const ticksLength = tickRects.length;
const start = id / ticksLength;
this.filterData({ value: [start, start] });
self.set('dragging', true);
});
group.on('dragover', e => {
if (!self.get('dragging')) return;
if (e.target.get('type') !== 'rect') return;
const id = parseInt(e.target.get('name').split('-')[2]);
const startTickRectId = self.get('startTickRectId');
const tickRects = self.get('tickRects');
const selectedTickStyle = self.get('selectedTickStyle');
const unselectedTickStyle = self.get('unselectedTickStyle');
for (let i = 0; i < tickRects.length; i++) {
const style = i >= startTickRectId && i <= id ? selectedTickStyle : unselectedTickStyle;
tickRects[i].rect.attr(style);
}
const ticksLength = tickRects.length;
self.set('endTickRectId', id);
const start = startTickRectId / ticksLength;
const end = id / ticksLength;
this.filterData({ value: [start, end] });
});
group.on('drop', e => {
if (!self.get('dragging')) return;
self.set('dragging', false);
if (e.target.get('type') !== 'rect') return;
const startTickRectId = self.get('startTickRectId');
const id = parseInt(e.target.get('name').split('-')[2]);
if (id < startTickRectId) return;
const selectedTickStyle = self.get('selectedTickStyle');
const tickRects = self.get('tickRects');
tickRects[id].rect.attr(selectedTickStyle);
self.set('endTickRectId', id);
const ticksLength = tickRects.length;
const start = startTickRectId / ticksLength;
const end = id / ticksLength;
this.filterData({ value: [start, end] });
});
// tooltip
const { tooltipBackgroundColor, tooltipFomatter } = self._cfgs;
const tooltip = new TimeBarTooltip({
container: self.get('container') as HTMLElement,
backgroundColor: tooltipBackgroundColor
});
const tickRects = self.get('tickRects');
const canvas = self.get('canvas');
tickRects.forEach(tickRect => {
const pickRect = tickRect.pickRect;
pickRect.on('mouseenter', e => {
const rect = e.target;
if (rect.get('type') !== 'rect') return;
const id = parseInt(rect.get('name').split('-')[2]);
const clientPoint = canvas.getClientByPoint(tickRects[id].x, tickRects[id].y)
tooltip.show({
x: tickRects[id].x,
y: tickRects[id].y,
clientX: clientPoint.x,
clientY: clientPoint.y,
text: tooltipFomatter ? tooltipFomatter(tickRects[id].value) : tickRects[id].value
})
});
pickRect.on('mouseleave', e => {
tooltip.hide();
})
})
}
public destroy() {
super.destroy();
const group = this.get('timeBarGroup')
group.off('playPauseBtn:click')
}
}

View File

@ -0,0 +1,161 @@
import { IGroup, IShape } from '@antv/g-base';
import createDOM from '@antv/dom-util/lib/create-dom'
import { dataToPath, linePathToAreaPath } from './path';
import { isNumber, isString } from '@antv/util';
import modifyCSS from '@antv/dom-util/lib/modify-css';
export const BACKGROUND_STYLE = {
opacity: 0.5,
fill: '#000',
};
export const ARROW_STYLE = {
opacity: 0.5,
fill: '#000',
r: 5
};
export const TEXT_STYLE = {
fill: '#fff',
fontSize: 12
};
interface ArrowStyle {
r?: number,
fill?: string,
stroke?: string,
lineWidth?: number
};
export interface TimeBarTooltipCfg {
readonly container: HTMLElement | string;
readonly className?: string;
// 位置大小
readonly x?: number;
readonly y?: number;
readonly width?: number;
readonly height?: number;
readonly padding?: number[];
// 文本
readonly text?: string;
// 样式
readonly backgroundColor?: string;
readonly textColor?: string;
readonly opacity?: number;
readonly fontSize?: number;
}
/**
*
*/
export default class TimeBarTooltip {
private container: HTMLElement | string;
private className: string;
// 生成的 shape
public backgroundDOM: HTMLElement;
public arrowDOM: HTMLElement;
// 位置大小配置
private x: number;
private y: number;
// style
private padding: number[];
private backgroundColor: string;
private textColor: string;
private parentHeight: number;
private parentWidth: number;
private opacity: number;
private fontSize: number;
private text: string;
constructor(cfg?: TimeBarTooltipCfg) {
const {
x = 0,
y = 0,
container,
text,
padding = [4, 4, 4, 4],
className = 'g6-component-timebar-tooltip',
backgroundColor = '#000',
textColor = '#fff',
opacity = 0.8,
fontSize = 12
} = cfg;
this.container = container;
this.className = className;
this.backgroundColor = backgroundColor;
this.textColor = textColor;
this.x = x;
this.y = y;
this.text = text;
this.padding = padding;
this.opacity = opacity;
this.fontSize = fontSize;
this.render();
}
/**
*
* @private
*/
private render() {
const self = this;
const { className, x, y, backgroundColor,
textColor, text, padding, opacity, fontSize } = self;
let parentNode: string | HTMLElement = self.container;
const container: HTMLElement = createDOM(
`<div class='${className}' style="position: sticky; width: fit-content; height: fit-content; opacity: ${opacity}"></div>`,
);
if (isString(parentNode)) {
parentNode = document.getElementById(parentNode) as HTMLElement;
}
parentNode.appendChild(container);
self.parentHeight = parentNode.offsetHeight;
self.parentWidth = parentNode.offsetWidth;
modifyCSS(container, { visibility: 'hidden', top: 0, left: 0 });
const background: HTMLElement = createDOM(`
<div style='position: absolute; white-space:nowrap; background-color: ${backgroundColor}; font-size: ${fontSize}px; border-radius: 4px; width: fit-content; height: fit-content; color: ${textColor}; padding: ${padding[0]}px ${padding[1]}px ${padding[2]}px ${padding[3]}px'></div>`);
background.innerHTML = text;
container.appendChild(background);
self.backgroundDOM = background;
const arrow: HTMLElement = createDOM(
`<div style='position: absolute; width: 0px; height: 0px; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 10px solid ${backgroundColor}'></div>`
)
container.appendChild(arrow);
self.arrowDOM = arrow;
self.container = container;
}
public show(cfg) {
const self = this;
const { text, x, y, clientX, clientY } = cfg;
self.backgroundDOM.innerHTML = text;
const backgroundWidth = self.backgroundDOM.offsetWidth;
const backgroundHeight = self.backgroundDOM.offsetHeight;
const arrowWidth = self.arrowDOM.offsetWidth;
const arrowHeight = self.arrowDOM.offsetHeight;
const containerHeight = backgroundHeight + arrowHeight;
modifyCSS(self.container as HTMLElement, { marginTop: `${-self.parentHeight - containerHeight - 8}px`, left: `${clientX}px`, visibility: 'visible', });
modifyCSS(self.backgroundDOM, { marginLeft: `${-backgroundWidth / 2}px` })
modifyCSS(self.arrowDOM, { marginLeft: `${-arrowWidth / 2}px`, top: `${backgroundHeight}px` });
const bbox = (self.backgroundDOM as HTMLElement).getBoundingClientRect();
if (bbox.left < 0) {
modifyCSS(self.backgroundDOM, { marginLeft: `${-backgroundWidth / 2 - bbox.left}px` })
} else if (bbox.right > self.parentWidth) {
modifyCSS(self.backgroundDOM, { marginLeft: `${-backgroundWidth / 2 - bbox.right + self.parentWidth + 12}px` })
}
}
public hide() {
const self = this;
//modifyCSS(self.container as HTMLElement, { top: 0, left: 0, visibility: 'hidden', });
}
}

View File

@ -0,0 +1,123 @@
import React, { useEffect } from 'react';
import G6, { Graph } from '../../../src';
import { IGraph } from '../../../src/interface/graph';
import TimeBarSlice from '../../../src/plugins/timeBar/timeBarSlice';
let graph: IGraph = null;
const TimeBarS = () => {
const container = React.useRef();
const data = {
nodes: [],
edges: [],
};
for (let i = 1; i < 60; i++) {
const id = `node-${i}`;
const month = i < 30 ? '01' : '02';
const day = i % 30 < 10 ? `0${i % 30}` : `${i % 30}`;
data.nodes.push({
id,
date: parseInt(`2020${month}${day}`),
value: Math.round(Math.random() * 300),
label: parseInt(`2020${i}`),
});
data.edges.push({
source: `node-${Math.round(Math.random() * 90)}`,
target: `node-${Math.round(Math.random() * 90)}`,
});
data.edges.push({
source: `node-${Math.round(Math.random() * 90)}`,
target: `node-${Math.round(Math.random() * 90)}`,
});
}
const timeBarData = [];
const nodeSize = 20;
for (let i = 1; i < 60; i++) {
const month = i < 30 ? '01' : '02';
const day = i % 30 < 10 ? `0${i % 30}` : `${i % 30}`;
timeBarData.push({
date: parseInt(`2020${month}${day}`),
value: Math.round(Math.random() * 300),
});
}
console.log('timeBarData', timeBarData)
let count = 0;
const timebar = new TimeBarSlice({
x: 0,
y: 0,
width: 500,
height: 42,
padding: 2,
trend: {
data: timeBarData,
isArea: true,
lineStyle: {
stroke: 'green',
lineWidth: 3
}
},
slider: {
textStyle: {
fill: 'red',
fontSize: 16,
},
},
tickLabelFormatter: d => {
count++;
const dateStr = `${d.date}`
if ((count - 1) % 10 === 0) {
return `${dateStr.substr(0, 4)}-${dateStr.substr(4, 2)}-${dateStr.substr(6, 2)}`;
}
return false;
},
tooltipFomatter: d => {
const dateStr = `${d}`
return `${dateStr.substr(0, 4)}-${dateStr.substr(4, 2)}-${dateStr.substr(6, 2)}`;
}
});
useEffect(() => {
if (!graph) {
const toolbar = new G6.ToolBar();
graph = new Graph({
container: container.current as string | HTMLElement,
width: 500,
height: 500,
plugins: [timebar],
layout: {
type: 'force',
preventOverlap: true,
},
defaultNode: {
size: nodeSize,
type: 'circle',
style: {
fill: '#DEE9FF',
stroke: '#5B8FF9',
},
},
modes: {
default: ['drag-node'],
},
});
graph.data(data);
graph.render();
}
});
return (
<div>
<div style={{ left: '100px' }} ref={container}></div>
</div>
);
};
export default TimeBarS;

View File

@ -0,0 +1,90 @@
import React, { useEffect } from 'react';
import G6, { Graph } from '../../../src';
import { IGraph } from '../../../src/interface/graph';
let graph: IGraph = null;
const TimeBar = () => {
const container = React.useRef();
const data = {
nodes: [],
edges: [],
};
for (let i = 0; i < 100; i++) {
const id = `node-${i}`;
data.nodes.push({
id,
date: `2020${i}`,
value: Math.round(Math.random() * 300),
});
data.edges.push({
source: `node-${Math.round(Math.random() * 90)}`,
target: `node-${Math.round(Math.random() * 90)}`,
});
}
const timeBarData = [];
const nodeSize = 20;
for (let i = 0; i < 100; i++) {
timeBarData.push({
date: `2020${i}`,
value: Math.round(Math.random() * 300),
});
}
const timebar = new G6.TimeBar({
width: 600,
timebar: {
width: 550,
trend: {
data: timeBarData,
isArea: false,
smooth: true,
},
start: 0.4,
end: 0.5,
},
});
useEffect(() => {
if (!graph) {
const toolbar = new G6.ToolBar();
graph = new Graph({
container: container.current as string | HTMLElement,
width: 500,
height: 500,
plugins: [timebar],
layout: {
type: 'force',
preventOverlap: true,
},
defaultNode: {
size: nodeSize,
type: 'circle',
style: {
fill: '#DEE9FF',
stroke: '#5B8FF9',
},
},
modes: {
default: ['drag-node'],
},
});
graph.data(data);
graph.render();
}
});
return (
<div>
<div style={{ left: '100px' }} ref={container}></div>
</div>
);
};
export default TimeBar;

View File

@ -10,6 +10,8 @@ import Tooltip from './component/tooltip';
import ContextMenu from './component/comtextMenu';
import ToolBar from './component/toolbar';
import EdgeFilter from './component/edge-filter';
import TimeBar from './component/timebar';
import TimeBarS from './component/timebar-slice';
export default { title: 'Plugins' };
@ -24,4 +26,6 @@ storiesOf('Plugins', module)
.add('tooltip', () => <Tooltip />)
.add('context menu', () => <ContextMenu />)
.add('tool bar', () => <ToolBar />)
.add('edge filter', () => <EdgeFilter />);
.add('edge filter', () => <EdgeFilter />)
.add('time bar', () => <TimeBar />)
.add('time bar slice', () => <TimeBarS />);