diff --git a/packages/vchart/src/chart/base/base-chart.ts b/packages/vchart/src/chart/base/base-chart.ts index 3329709623..f9aebf79a4 100644 --- a/packages/vchart/src/chart/base/base-chart.ts +++ b/packages/vchart/src/chart/base/base-chart.ts @@ -971,6 +971,7 @@ export class BaseChart extends CompilableBase implements I this._layoutRect.y = this.padding.top; this._event.emit(ChartEvent.layoutRectUpdate, { chart: this }); + this._option?.chartPluginApply?.('onLayoutRectUpdate', { chart: this }); } /** 设置当前全局主题 */ diff --git a/packages/vchart/src/chart/interface/common.ts b/packages/vchart/src/chart/interface/common.ts index f5f3c869b0..849b81fee9 100644 --- a/packages/vchart/src/chart/interface/common.ts +++ b/packages/vchart/src/chart/interface/common.ts @@ -5,6 +5,7 @@ import type { IView } from '@visactor/vgrammar-core'; import type { IBoundsLike } from '@visactor/vutils'; import type { ISeriesSpecInfo } from '../../series/interface'; import type { IRegionSpecInfo } from '../../region/interface'; +import type { IChartPluginService } from '../../plugin/chart/interface'; export interface ILayoutParams { srView?: IView; @@ -36,6 +37,11 @@ export interface IChartOption * 是否关闭交互效果 */ disableTriggerEvent?: boolean; + + /** + * 图表插件应用方法 + */ + chartPluginApply?: (funcName: keyof IChartPluginService, ...args: any[]) => any; } export interface IChartSpecTransformerOption extends Partial { diff --git a/packages/vchart/src/chart/sankey/sankey-transformer.ts b/packages/vchart/src/chart/sankey/sankey-transformer.ts index fa77d2375b..c041f2bc0c 100644 --- a/packages/vchart/src/chart/sankey/sankey-transformer.ts +++ b/packages/vchart/src/chart/sankey/sankey-transformer.ts @@ -36,7 +36,8 @@ export class SankeyChartSpecTransformer< 'link', 'emphasis', 'inverse', - 'overflow' + 'overflow', + 'customLayout' ]); return series; diff --git a/packages/vchart/src/core/vchart.ts b/packages/vchart/src/core/vchart.ts index 3f81ffe53c..844ae24072 100644 --- a/packages/vchart/src/core/vchart.ts +++ b/packages/vchart/src/core/vchart.ts @@ -367,6 +367,9 @@ export class VChart implements IVChart { private _isReleased: boolean; private _chartPlugin?: IChartPluginService; + get chartPlugin() { + return this._chartPlugin; + } private _onResize?: () => void; constructor(spec: ISpec, options: IInitOption) { @@ -783,6 +786,7 @@ export class VChart implements IVChart { return false; } this._updateAnimateState(); + this._chartPluginApply('onAfterRender', this._spec); this._event.emit(ChartEvent.rendered, { chart: this._chart, vchart: this @@ -2189,7 +2193,9 @@ export class VChart implements IVChart { layout: this._option.layout, onError: this._onError, - disableTriggerEvent: this._option.disableTriggerEvent === true + disableTriggerEvent: this._option.disableTriggerEvent === true, + chartPluginApply: (funcName: keyof IChartPluginService, ...args: any[]) => + this._chartPluginApply(funcName, ...args) }; } } diff --git a/packages/vchart/src/data/transforms/sankey.ts b/packages/vchart/src/data/transforms/sankey.ts index e5e2c32ebf..a1eda7d4cf 100644 --- a/packages/vchart/src/data/transforms/sankey.ts +++ b/packages/vchart/src/data/transforms/sankey.ts @@ -7,6 +7,12 @@ export interface ISankeyOpt extends SankeyOptions { sourceField: string; valueField: string; view: () => { x0: number; x1: number; y0: number; y1: number }; + customLayout?: ( + layout: SankeyLayout, + originalData: SankeyData, + view: ReturnType, + option: ISankeyOpt + ) => ReturnType; } export const collectHierarchyField = (set: Set, data: any[], field: string) => { @@ -99,7 +105,11 @@ export const sankeyLayout = (data: SankeyData[], op: ISankeyOpt) => { const result = []; - result.push(layout.layout(originalData, view)); + if (op.customLayout) { + result.push(op.customLayout(layout, originalData, view, op)); + } else { + result.push(layout.layout(originalData, view)); + } return result; }; diff --git a/packages/vchart/src/plugin/chart/interface.ts b/packages/vchart/src/plugin/chart/interface.ts index c4715596cf..1bbe0b97d0 100644 --- a/packages/vchart/src/plugin/chart/interface.ts +++ b/packages/vchart/src/plugin/chart/interface.ts @@ -18,6 +18,8 @@ export interface IChartPlugin extends IBase actionSource: VChartRenderActionSource ) => MaybePromise; onBeforeInitChart?: (service: T, chartSpec: any, actionSource: VChartRenderActionSource) => MaybePromise; + onLayoutRectUpdate?: (service: T) => void; + onAfterRender?: (service: T) => void; } export interface IChartPluginConstructor { @@ -38,4 +40,6 @@ export interface IChartPluginService extends IBase actionSource: VChartRenderActionSource ) => MaybePromise; onBeforeInitChart?: (chartSpec: any, actionSource: VChartRenderActionSource) => MaybePromise; + onLayoutRectUpdate?: () => void; + onAfterRender?: () => void; } diff --git a/packages/vchart/src/plugin/chart/plugin-service.ts b/packages/vchart/src/plugin/chart/plugin-service.ts index 80e1d5b720..f2d7f965ee 100644 --- a/packages/vchart/src/plugin/chart/plugin-service.ts +++ b/packages/vchart/src/plugin/chart/plugin-service.ts @@ -15,6 +15,10 @@ export class ChartPluginService this.globalInstance = globalInstance; } + getPlugin(name: string): T | undefined { + return this._plugins.find(plugin => plugin.name === name); + } + onInit(chartSpec: any) { this._plugins.forEach(plugin => { plugin.onInit && plugin.onInit(this, chartSpec); @@ -46,6 +50,18 @@ export class ChartPluginService }); } + onLayoutRectUpdate() { + this._plugins.forEach(plugin => { + plugin.onLayoutRectUpdate && plugin.onLayoutRectUpdate(this); + }); + } + + onAfterRender() { + this._plugins.forEach(plugin => { + plugin.onAfterRender && plugin.onAfterRender(this); + }); + } + releaseAll(): void { super.releaseAll(); this.globalInstance = null; diff --git a/packages/vchart/src/plugin/chart/scroll/index.ts b/packages/vchart/src/plugin/chart/scroll/index.ts new file mode 100644 index 0000000000..031e728299 --- /dev/null +++ b/packages/vchart/src/plugin/chart/scroll/index.ts @@ -0,0 +1,6 @@ +/** + * @description export all modules of scroll plugin + * @since 1.0.0 + */ +export * from './scroll'; +export * from './interface'; diff --git a/packages/vchart/src/plugin/chart/scroll/interface.ts b/packages/vchart/src/plugin/chart/scroll/interface.ts new file mode 100644 index 0000000000..5900fa29eb --- /dev/null +++ b/packages/vchart/src/plugin/chart/scroll/interface.ts @@ -0,0 +1,24 @@ +import type { ScrollBarAttributes } from '@visactor/vrender-components'; +import type { IChartPlugin } from '../interface'; + +/** + * IScrollPlugin 接口定义 + * @since 1.0.0 + */ +export type IScrollPlugin = IChartPlugin; + +export interface IScrollPluginSpec { + x?: { + /** + * 是否支持水平滚动 + */ + enable: boolean; + } & Omit; + + y?: { + /** + * 是否支持垂直滚动 + */ + enable: boolean; + } & Omit; +} diff --git a/packages/vchart/src/plugin/chart/scroll/scroll.ts b/packages/vchart/src/plugin/chart/scroll/scroll.ts new file mode 100644 index 0000000000..718b5f808f --- /dev/null +++ b/packages/vchart/src/plugin/chart/scroll/scroll.ts @@ -0,0 +1,302 @@ +import { isValidNumber, merge } from '@visactor/vutils'; +import type { IGroup, IRectGraphicAttribute } from '@visactor/vrender-core'; +import { BasePlugin } from '../../base/base-plugin'; +import type { IChartPluginService } from '../interface'; +import { registerChartPlugin } from '../register'; +import type { IScrollPlugin, IScrollPluginSpec } from './interface'; +import { ScrollBar as ScrollBarComponent } from '@visactor/vrender-components'; +import { Event } from '../../../event/event'; +import type { ExtendEventParam } from '../../../event/interface'; + +// 由vrender透出, 接入新版本后需修改 +const SCROLLBAR_EVENT = 'scrollDrag'; +const SCROLLBAR_END_EVENT = 'scrollUp'; + +const DefaultTheme: { + size?: number; + railStyle?: Omit; + sliderStyle?: Omit; +} = { + size: 10, + railStyle: undefined, + sliderStyle: undefined +}; +/** + * ScrollPlugin 类 + * @since 1.0.0 + */ +export class ScrollPlugin extends BasePlugin implements IScrollPlugin { + static readonly pluginType: 'chart' = 'chart'; + static readonly type: string = 'chartScroll'; + readonly type: string = 'chartScroll'; + readonly name: string = ScrollPlugin.type; + + private _service: IChartPluginService; + + private _spec: IScrollPluginSpec; + private _lastScrollX = 0; + private _lastScrollY = 0; + + private _scrollLimit = { + x: { + min: 0, + max: 0, + size: 0, + percent: 0 + }, + y: { + min: 0, + max: 0, + size: 0, + percent: 0 + } + }; + + private _xScrollComponent: ScrollBarComponent; + private _yScrollComponent: ScrollBarComponent; + private _event: Event; + + constructor() { + super(ScrollPlugin.type); + } + + /** + * 初始化插件 + * @param service + * @param chartSpec + */ + onInit(service: IChartPluginService, chartSpec: any) { + this._spec = chartSpec[ScrollPlugin.type] ?? {}; + this._service = service; + this._bindEvent(service); + } + + onLayoutRectUpdate(service: IChartPluginService) { + const viewBoxSize = service.globalInstance.getChart().getViewRect(); + const canvasSize = service.globalInstance.getChart().getCanvasRect(); + this._scrollLimit.x.min = Math.min(canvasSize.width - viewBoxSize.width, 0); + this._scrollLimit.x.percent = Math.abs(canvasSize.width / viewBoxSize.width); + this._scrollLimit.x.size = viewBoxSize.width; + this._scrollLimit.y.min = Math.min(canvasSize.height - viewBoxSize.height, 0); + this._scrollLimit.y.percent = Math.abs(canvasSize.height / viewBoxSize.height); + this._scrollLimit.y.size = viewBoxSize.height; + + if (!this._event) { + this._event = new Event(this._service.globalInstance.getChart().getOption().eventDispatcher, null); + } + } + + onAfterRender() { + const rootMark = this.getRootMark(); + if (rootMark) { + if (!this._xScrollComponent) { + this._updateScrollX(rootMark, 0, 0); + } + if (!this._yScrollComponent) { + this._updateScrollY(rootMark, 0, 0); + } + } + } + + /** + * 释放插件资源 + */ + release() { + this._service.globalInstance.getStage()?.off('wheel', this.onWheel); + } + + protected _bindEvent(service: IChartPluginService) { + service.globalInstance.getStage()?.on('wheel', this.onWheel); + } + + protected getRootMark() { + return this._service.globalInstance.getStage()?.find(node => node.name === 'root', true) as IGroup; + } + + protected onWheel = (e: WheelEvent) => { + const scrollX = e.deltaX; + const scrollY = e.deltaY; + const rootMark = this.getRootMark(); + if (!rootMark) { + return; + } + const { percent: yPercent, y } = this._computeFinalScrollY(rootMark.attribute.y - scrollY) ?? {}; + const { percent: xPercent, x } = this._computeFinalScrollX(rootMark.attribute.x - scrollX) ?? {}; + const eventResult: { x?: number; y?: number } = {}; + if (isValidNumber(x)) { + this._updateScrollX(rootMark, x, xPercent); + eventResult.x = x; + } + if (isValidNumber(y)) { + this._updateScrollY(rootMark, y, yPercent); + eventResult.y = y; + } + + this._event.emit('chartScroll', eventResult as ExtendEventParam); + }; + + private _computeFinalScrollY(y: number) { + if (this._lastScrollY === y) { + return null; + } + this._lastScrollY = y; + if (this._spec.y?.enable === false) { + return null; + } + const finalY = Math.max(this._scrollLimit.y.min, Math.min(y, this._scrollLimit.y.max)); + const percent = Math.abs(finalY / this._scrollLimit.y.size); + return { + y: finalY, + percent + }; + } + private _computeFinalScrollX(x: number) { + if (this._lastScrollX === x) { + return null; + } + this._lastScrollX = x; + if (this._spec.x?.enable === false) { + return null; + } + const finalX = Math.max(this._scrollLimit.x.min, Math.min(x, this._scrollLimit.x.max)); + const percent = Math.abs(finalX / this._scrollLimit.x.size); + return { + x: finalX, + percent + }; + } + + private _updateScrollY(rootMark: IGroup, y: number, percent: number) { + const yScrollComponent = this._getYScrollComponent(); + yScrollComponent.setAttribute('range', [percent, percent + this._scrollLimit.y.percent]); + rootMark.setAttributes({ + y: y + }); + } + + private _getYScrollComponent() { + if (!this._yScrollComponent) { + const canvasSize = this._service.globalInstance.getChart().getCanvasRect(); + const viewSize = this._service.globalInstance.getChart().getViewRect(); + const { enable, ...rest } = this._spec?.y ?? ({} as IScrollPluginSpec['y']); + this._yScrollComponent = new ScrollBarComponent({ + ...rest, + zIndex: 9999, + x: canvasSize.width - DefaultTheme.size, + y: 0, + width: DefaultTheme.size, + height: canvasSize.height, + range: [0, canvasSize.height / viewSize.height], + direction: 'vertical', + delayTime: rest?.delayTime ?? 30, + realTime: rest?.realTime ?? true, + railStyle: DefaultTheme.railStyle, + sliderStyle: DefaultTheme.sliderStyle + }); + // 绑定事件,防抖,防止频繁触发 + this._yScrollComponent.addEventListener(SCROLLBAR_EVENT, (e: any) => { + const value = e.detail.value; + const { percent, y } = this._computeFinalScrollY(-value[0] * this._scrollLimit.y.size) ?? {}; + if (percent !== undefined && y !== undefined) { + this._updateScrollY(this.getRootMark(), y, percent); + this._event.emit('chartScroll', { y } as ExtendEventParam); + } + }); + this._yScrollComponent.addEventListener(SCROLLBAR_END_EVENT, (e: any) => { + const value = e.detail.value; + const { percent, y } = this._computeFinalScrollY(-value[0] * this._scrollLimit.y.size) ?? {}; + if (percent !== undefined && y !== undefined) { + this._updateScrollY(this.getRootMark(), y, percent); + this._event.emit('chartScroll', { y } as ExtendEventParam); + } + }); + this.getRootMark().parent?.addChild(this._yScrollComponent); + } + return this._yScrollComponent; + } + + private _updateScrollX(rootMark: IGroup, x: number, percent: number) { + const xScrollComponent = this._getXScrollComponent(); + xScrollComponent.setAttribute('range', [percent, percent + this._scrollLimit.x.percent]); + rootMark.setAttributes({ + x: x + }); + } + + private _getXScrollComponent() { + if (!this._xScrollComponent) { + const canvasSize = this._service.globalInstance.getChart().getCanvasRect(); + const viewSize = this._service.globalInstance.getChart().getViewRect(); + const { enable, ...rest } = this._spec?.x ?? ({} as IScrollPluginSpec['x']); + this._xScrollComponent = new ScrollBarComponent({ + ...rest, + zIndex: 9999, + x: 0, + y: canvasSize.height - DefaultTheme.size, + width: canvasSize.width, + height: DefaultTheme.size, + range: [0, canvasSize.width / viewSize.width], + direction: 'horizontal', + delayTime: rest?.delayTime ?? 30, + realTime: rest?.realTime ?? true, + sliderStyle: DefaultTheme.sliderStyle, + railStyle: DefaultTheme.railStyle + }); + // 绑定事件,防抖,防止频繁触发 + this._xScrollComponent.addEventListener(SCROLLBAR_EVENT, (e: any) => { + const value = e.detail.value; + const { percent, x } = this._computeFinalScrollX(-value[0] * this._scrollLimit.x.size) ?? {}; + if (percent !== undefined && x !== undefined) { + this._updateScrollX(this.getRootMark(), x, percent); + this._event.emit('chartScroll', { x } as ExtendEventParam); + } + }); + this._xScrollComponent.addEventListener(SCROLLBAR_END_EVENT, (e: any) => { + const value = e.detail.value; + const { percent, x } = this._computeFinalScrollX(-value[0] * this._scrollLimit.x.size) ?? {}; + if (percent !== undefined && x !== undefined) { + this._updateScrollX(this.getRootMark(), x, percent); + this._event.emit('chartScroll', { x } as ExtendEventParam); + } + }); + this.getRootMark().parent?.addChild(this._xScrollComponent); + } + return this._xScrollComponent; + } + + /** + * api + */ + scrollTo({ x, y }: { x?: number; y?: number }) { + const rootMark = this.getRootMark(); + if (rootMark) { + if (x !== undefined) { + const { x: finalX, percent } = this._computeFinalScrollX(x) ?? {}; + if (finalX !== undefined && percent !== undefined) { + this._updateScrollX(rootMark, finalX, percent); + } + } + if (y !== undefined) { + const { y: finalY, percent } = this._computeFinalScrollY(y) ?? {}; + if (finalY !== undefined && percent !== undefined) { + this._updateScrollY(rootMark, finalY, percent); + } + } + } + } +} + +/** + * 注册 ScrollPlugin + * @since 1.0.0 + */ +export const registerScrollPlugin = (theme?: { + size?: number; + railStyle?: Omit; + sliderStyle?: Omit; +}) => { + DefaultTheme.size = theme?.size ?? DefaultTheme.size; + DefaultTheme.railStyle = merge({}, DefaultTheme.railStyle ?? {}, theme?.railStyle ?? {}); + DefaultTheme.sliderStyle = merge({}, DefaultTheme.sliderStyle ?? {}, theme?.sliderStyle ?? {}); + registerChartPlugin(ScrollPlugin); +}; diff --git a/packages/vchart/src/series/sankey/interface.ts b/packages/vchart/src/series/sankey/interface.ts index 38076c8d3b..53221c2bfc 100644 --- a/packages/vchart/src/series/sankey/interface.ts +++ b/packages/vchart/src/series/sankey/interface.ts @@ -4,6 +4,8 @@ import type { IRectMarkSpec, ILinkPathMarkSpec } from '../../typings/visual'; import type { IAnimationSpec } from '../../animation/spec'; import type { SeriesMarkNameEnum } from '../interface/type'; import type { ILabelSpec } from '../../component/label/interface'; +import type { SankeyLayout } from '@visactor/vgrammar-sankey'; +import type { ISankeyOpt } from '../../data/transforms/sankey'; export type SankeyMark = 'node' | 'link' | 'label'; @@ -200,6 +202,16 @@ export interface ISankeySeriesSpec extends Omit, IAnimation // /** 是否开启进度条 */ // enable: boolean; // }; + + /** + * 自定义布局 + * @since 1.13.19 + */ + customLayout?: ( + layout: SankeyLayout, + originalData: SankeyData, + view: ReturnType + ) => ReturnType; } export interface SankeyLinkDatum { diff --git a/packages/vchart/src/series/sankey/sankey.ts b/packages/vchart/src/series/sankey/sankey.ts index c35603426b..8a70efef97 100644 --- a/packages/vchart/src/series/sankey/sankey.ts +++ b/packages/vchart/src/series/sankey/sankey.ts @@ -145,7 +145,8 @@ export class SankeySeries exten linkHeight: this._spec.linkHeight, equalNodeHeight: this._spec.equalNodeHeight, linkOverlap: this._spec.linkOverlap, - inverse: this._spec.inverse + inverse: this._spec.inverse, + customLayout: this._spec.customLayout } as ISankeyOpt, level: TransformLevel.sankeyLayout });