import { Timebar as TimebarComponent } from '@antv/component'; import { Canvas } from '@antv/g'; import { isArray, isDate, isNumber } from '@antv/util'; import { idOf } from '../utils/id'; import { parsePadding } from '../utils/padding'; import { BasePlugin } from './base-plugin'; import type { TimebarStyleProps as TimebarComponentStyleProps } from '@antv/component'; import type { RuntimeContext } from '../runtime/types'; import type { GraphData } from '../spec'; import type { ElementDatum, ElementType, ID, Padding } from '../types'; import type { BasePluginOptions } from './base-plugin'; import { createPluginCanvas } from './utils/canvas'; const prospectiveTimeKeys = ['timestamp', 'time', 'date', 'datetime']; /** * Timebar 时间条的配置项。 * The options of the Timebar. */ export interface TimebarOptions extends BasePluginOptions { /** * 给工具栏的 DOM 追加的类名,便于自定义样式 * * The class name appended to the menu DOM for custom styles * @defaultValue 'g6-timebar' */ className?: string; /** * X 位置 * * X position * @remarks * 设置后 `position` 会失效 * * `position` will be invalidated after setting `x` */ x?: number; /** * Y 位置 * * Y position * @remarks * 设置后 `position` 会失效 * * `position` will be invalidated after setting `y` */ y?: number; /** * 时间条宽度 * * Timebar width * @defaultValue 450 */ width?: number; /** * 时间条高度 * * Timebar height * @defaultValue 60 */ height?: number; /** * Timebar 的位置 * * Timebar location * @defaultValue 'bottom' */ position?: 'bottom' | 'top'; /** * 边距 * * Padding */ padding?: Padding; /** * 获取元素时间 * * Get element time */ getTime?: (datum: ElementDatum) => number; /** * 时间数据 * * Time data * @remarks * `timebarType` 为 `'chart'` 时,需要额外传入 `value` 字段作为图表数据 * * When `timebarType` is `'chart'`, you need to pass in the `value` field as chart data */ data: number[] | { time: number; value: number }[]; /** * Timebar 展示类型 * - `'time'`: 显示为时间轴 * - `'chart'`: 显示为趋势图 * * Timebar Displays the type * - `'time'`: Display as a timeline * - `'chart'`: Display as a trend chart * @defaultValue 'time' */ timebarType?: 'time' | 'chart'; /** * 筛选类型 * * Filter element types */ elementTypes?: ElementType[]; /** * 筛选模式 * - `'modify'`: 通过修改图数据进行筛选 * - `'visibility'`: 通过修改元素可见性进行筛选 * * Filter mode * - `'modify'`: Filter by modifying the graph data. * - `'visibility'`: Filter by modifying element visibility * @defaultValue 'modify' */ mode?: 'modify' | 'visibility'; /** * 当前时间值 * * Current time value */ values?: number | [number, number] | Date | [Date, Date]; /** * 图表模式下自定义时间格式化 * * Custom time formatting in chart mode */ labelFormatter?: (time: number | Date) => string; /** * 是否循环播放 * * Whether to loop * @defaultValue false */ loop?: boolean; /** * 时间区间变化时执行的回调 * * Callback executed when the time interval changes */ onChange?: (values: number | [number, number]) => void; /** * 重置时执行的回调 * * Callback executed when reset */ onReset?: () => void; /** * 播放速度变化时执行的回调 * * Callback executed when the playback speed changes */ onSpeedChange?: (speed: number) => void; /** * 开始播放时执行的回调 * * Callback executed when playback starts */ onPlay?: () => void; /** * 暂停时执行的回调 * * Callback executed when paused */ onPause?: () => void; /** * 后退时执行的回调 * * Callback executed when backward */ onBackward?: () => void; /** * 前进时执行的回调 * * Callback executed when forward */ onForward?: () => void; } /** * 时间组件 * * Timebar */ export class Timebar extends BasePlugin { static defaultOptions: Partial = { position: 'bottom', enable: true, timebarType: 'time', className: 'g6-timebar', width: 450, height: 60, zIndex: 3, elementTypes: ['node'], padding: 10, mode: 'modify', getTime: (datum) => inferTime(datum, prospectiveTimeKeys, undefined), loop: false, }; private timebar?: TimebarComponent; private canvas?: Canvas; private container?: HTMLElement; private originalData?: GraphData; private get padding() { return parsePadding(this.options.padding); } constructor(context: RuntimeContext, options: TimebarOptions) { super(context, Object.assign({}, Timebar.defaultOptions, options)); this.backup(); this.upsertTimebar(); } /** * 播放 * * Play */ public play() { this.timebar?.play(); } /** * 暂停 * * Pause */ public pause() { this.timebar?.pause(); } /** * 前进 * * Forward */ public forward() { this.timebar?.forward(); } /** * 后退 * * Backward */ public backward() { this.timebar?.backward(); } /** * 重置 * * Reset */ public reset() { this.timebar?.reset(); } /** * 更新时间条配置项 * * Update timebar configuration options * @param options - 配置项 | Options * @internal */ public update(options: Partial) { super.update(options); this.backup(); this.upsertTimebar(); } /** * 备份数据 * * Backup data */ private backup() { this.originalData = shallowCopy(this.context.graph.getData()); } private upsertTimebar() { const { canvas } = this.context; const { onChange, timebarType, data, x, y, width, height, mode, ...restOptions } = this.options; const canvasSize = canvas.getSize(); const [top] = this.padding; this.upsertCanvas().ready.then(() => { const style: TimebarComponentStyleProps = { x: canvasSize[0] / 2 - width / 2, y: top, onChange: (value) => { const range = (isArray(value) ? value : [value, value]).map((time) => isDate(time) ? time.getTime() : time, ) as [number, number]; if (this.options.mode === 'modify') this.filterElements(range); else this.hiddenElements(range); onChange?.(range); }, ...restOptions, data: data.map((datum) => (isNumber(datum) ? { time: datum, value: 0 } : datum)), width, height, type: timebarType, }; if (!this.timebar) { this.timebar = new TimebarComponent({ style }); this.canvas?.appendChild(this.timebar); } else { this.timebar.update(style); } }); } private upsertCanvas() { if (this.canvas) return this.canvas; const { className, height, position } = this.options; const graphCanvas = this.context.canvas; const [width] = graphCanvas.getSize(); const [top, , bottom] = this.padding; const [$container, canvas] = createPluginCanvas({ width, height: height + top + bottom, graphCanvas, className: 'timebar', placement: position, }); this.container = $container; if (className) $container.classList.add(className); this.canvas = canvas; return this.canvas; } private async filterElements(range: number | [number, number]) { if (!this.originalData) return; const { elementTypes, getTime } = this.options; const { graph, element } = this.context; const newData = shallowCopy(this.originalData); elementTypes.forEach((type) => { const key = `${type}s` as const; newData[key] = (this.originalData![key] || []).filter((datum) => { const time = getTime(datum); if (match(time, range)) return true; return false; }) as any; }); const nodeLikeIds = [...newData.nodes, ...newData.combos].map((datum) => idOf(datum)); newData.edges = newData.edges!.filter((edge) => { const source = edge.source; const target = edge.target; return nodeLikeIds.includes(source) && nodeLikeIds.includes(target); }); graph.setData(newData); await element!.draw({ animation: false, silence: true })?.finished; } private hiddenElements(range: number | [number, number]) { const { graph } = this.context; const { elementTypes, getTime } = this.options; const hideElementId: ID[] = []; const showElementId: ID[] = []; elementTypes.forEach((elementType) => { const key = `${elementType}s` as const; const elementData = this.originalData?.[key] || []; elementData.forEach((elementDatum) => { const id = idOf(elementDatum); const time = getTime(elementDatum); if (match(time, range)) showElementId.push(id); else hideElementId.push(id); }); }); graph.hideElement(hideElementId, false); graph.showElement(showElementId, false); } /** * 销毁时间条 * * Destroy the timebar * @internal */ public destroy(): void { const { graph } = this.context; this.originalData && graph.setData({ ...this.originalData }); this.timebar?.destroy(); this.canvas?.destroy(); this.container?.remove(); this.originalData = undefined; this.container = undefined; this.timebar = undefined; this.canvas = undefined; super.destroy(); } } const shallowCopy = (data: GraphData) => { const { nodes = [], edges = [], combos = [] } = data; return { nodes: [...nodes], edges: [...edges], combos: [...combos], }; }; const match = (time: number, range: number | [number, number]) => { if (isNumber(range)) return time === range; const [start, end] = range; return time >= start && time <= end; }; const inferTime = (datum: ElementDatum, optionsKeys: string[], defaultValue?: any): number => { for (let i = 0; i < optionsKeys.length; i++) { const key = optionsKeys[i]; const val = datum.data?.[key]; if (val) return val as number; } return defaultValue; };