zz-chart
Version:
Alauda Chart components by Alauda Frontend Team
551 lines • 21.4 kB
JavaScript
import { cloneDeep, merge, mergeWith, omit, isFunction, get } from 'lodash-es';
import UPlot from 'uplot';
import { autoPadRight } from '../components/uplot-lib/axis.js';
import { ChartEvent } from '../types/index.js';
import { generateName, POLAR_SHAPE_TYPES, SHAPE_TYPES, } from '../utils/index.js';
import { ViewStrategy } from './abstract.js';
import { UPLOT_DEFAULT_OPTIONS } from './config.js';
import { Quadtree } from './quadtree.js';
const CURSOR_X = '.u-cursor-x';
const SHAPES = SHAPE_TYPES;
/**
* 渲染策略
* uPlot 渲染图表
*/
export class UPlotViewStrategy extends ViewStrategy {
constructor() {
super(...arguments);
this.shapes = SHAPES;
this.recordActive = false;
/**
* 获取 uPlot 配置
* 将原始 option 转换 uPlot 配置
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
this.getOption = () => {
const { width, height } = this.ctrl.size;
const ctrlOption = this.ctrl.getOption();
const series = this.getSeries();
const theme = this.getThemeOption();
const plugins = this.getPlugins();
const shapeOptions = this.getShapeChartOption();
const coordinate = this.ctrl.coordinateInstance.getOptions();
const axis = this.ctrl.components.get('axis').getOptions();
const scale = this.ctrl.components.get('scale').getOptions();
const annotation = this.ctrl.components.get('annotation').getOptions();
const interaction = this.getInteractionOption();
const [dt, dr, db, dl] = ctrlOption.padding || UPLOT_DEFAULT_OPTIONS.padding;
const paddingRight = isFunction(dr) ? dr : autoPadRight(dr);
const source = {
width,
height: height + 90,
...cloneDeep(UPLOT_DEFAULT_OPTIONS),
padding: [dt, paddingRight, db, dl],
plugins,
// fmtDate: () => UPlot.fmtDate('{HH}:{mm}'),
hooks: {
drawClear: [
(u) => {
this.qt =
this.qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
this.qt.clear();
},
],
setSeries: [
(_u, id) => {
this.activeId = id;
},
],
ready: [
(u) => {
this.cursor = document.createElement('div');
this.cursor.className = 'cursor';
this.cursor.style.position = 'absolute';
this.cursor.style.top = '0';
this.cursor.style.left = '0';
u.over.append(this.cursor);
this.ctrl.emit(ChartEvent.U_PLOT_READY);
requestAnimationFrame(() => {
this.render();
});
},
],
},
series,
};
return mergeWith(source, coordinate, annotation, axis, scale, theme, shapeOptions, interaction, (objValue, srcValue, key) => {
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
if (key === 'plugins' || key === 'drawClear') {
return objValue.concat(srcValue);
}
if (key === 'axes') {
return merge(objValue, srcValue);
}
const objLonger = objValue.length > srcValue.length;
const source = objLonger ? objValue : srcValue;
const minSource = objLonger ? srcValue : objValue;
return source.map((value, index) => merge(value, minSource[index]));
}
});
};
}
get name() {
return 'uPlot';
}
get component() {
return ['axis', 'scale', 'tooltip', 'annotation', ...SHAPES];
}
get isElementAction() {
return this.ctrl.isElementAction;
}
init() {
this.getChartEvent();
}
get transposed() {
return this.ctrl.getCoordinate().isTransposed;
}
/**
* 监听 chart 事件
*/
getChartEvent() {
// 监听 主题改变
this.ctrl.on(ChartEvent.THEME_CHANGE, () => {
if (this.uPlot) {
this.uPlot.redraw();
}
POLAR_SHAPE_TYPES.map(name => {
const comp = this.ctrl.shapeComponents.get(name);
comp?.redraw();
});
});
// 监听 legend item click
this.ctrl.on(ChartEvent.LEGEND_ITEM_CLICK, (data) => {
if (this.uPlot) {
const index = this.uPlot.series
.filter(d => d.scale === 'y')
.findIndex(item => item.label === data.name);
this.uPlot.setSeries(index + 1, { show: data.activated }, true);
}
});
this.ctrl.on(ChartEvent.DATA_CHANGE, () => {
if (this.uPlot) {
this.uPlot.redraw();
const data = this.getData();
const ySeries = this.uPlot.series.filter(s => s.scale === 'y');
const series = this.getSeries();
const legend = this.ctrl.components.get('legend');
ySeries.forEach((s) => {
if (series.every(d => d.label !== s.label)) {
this.uPlot.delSeries(this.uPlot.series.findIndex(res => res.label === s.label));
}
});
this.getSeries().forEach((s, index) => {
const no = ySeries.every(d => d.label !== s.label);
if (legend.inactivatedSet.has(s.label)) {
s.show = false;
}
if (no && index) {
this.uPlot.addSeries(s, this.uPlot.series.length);
return;
}
this.uPlot.setSeries(this.uPlot.series.length + 1, { show: false });
});
this.uPlot.setData(data);
legend.update();
}
});
this.ctrl.on(ChartEvent.HOOKS_REDRAW, () => {
this.uPlot?.redraw();
});
}
render(size) {
const option = this.getOption();
if (!this.uPlot && option.series.length > 1) {
const data = option.data?.length ? option.data : this.getData();
this.uPlot = new UPlot(option, data, this.ctrl.container);
}
this.changeSize(size || this.ctrl.size);
}
/**
* 修改 uPlot 视图大小
* @param size 宽 高
*/
changeSize(size) {
// TODO: 设置 uPlot padding 留空间给header header 使用 position 定位
if (this.uPlot) {
const position = get(this.options.legend, 'position', '');
const legendEl = this.ctrl.chartContainer.querySelector(`.${generateName('legend')}`);
const legendH = position.includes('bottom')
? legendEl?.clientHeight || 0
? (legendEl?.clientHeight || 0) + 8
: 0
: 0;
const headerH = this.ctrl.chartContainer.querySelector(`.${generateName('header')}`)
?.clientHeight || 0;
this.uPlot.setSize({
...size,
height: size.height - (headerH + legendH),
});
}
}
/**
* 获取数据
* 原始数据转换为 uPlot 数据
* @returns uPlot.AlignedData
*/
getData() {
const data = this.ctrl.getData();
return this.handleData(data);
}
/**
* 将数据处理成 uPlot 数据格式
* @param data 源数据
* @returns uPlot 数据源哥是
*/
handleData(data) {
if (!data.length) {
return [];
}
const labels = this.ctrl.getShapeDataName();
const values = data
.filter(v => labels.includes(v.name))
.sort((a, b) => labels.indexOf(a.id || a.name) - labels.indexOf(a.id || b.name))
.map(value => value.values);
// type-coverage:ignore-next-line
const x = values[0].map(value => value.x);
// const yItem = values.map(data => data.map(d => d.y));
const yItem = values.map(data => data.map(d => isNaN(+d.y) || !isFinite(+d.y) || d.y === null ? null : +d.y));
return [x, ...yItem];
}
/**
* 获取 series
* @param data 源数据
* @returns uPlot series
*/
getSeries() {
const series = this.ctrl
.getShapeList()
.map((shape) => {
return shape.getSeries();
})
.flat();
return [{}, ...series];
}
/**
* 获取 shape uPlot 配置
* @returns uPlot option
*/
getShapeChartOption() {
return this.shapes.reduce((prev, name) => {
const comp = this.ctrl.shapeComponents.get(name);
return comp ? merge(prev, comp.getOptions()) : prev;
}, {});
}
getInteractionOption() {
return {
cursor: {
bind: {
// 始终开启 drag x 根据 interaction brush x 是否开启触发 handle
mousedown: (_u, _t, handler) => {
return (e) => {
if (this.ctrl.interactions.get('brush-x')) {
handler(e);
}
};
},
},
drag: {
x: true,
},
},
};
}
/**
* 获取主题 配置
*/
getThemeOption() {
if (!this.ctrl.getTheme()) {
return;
}
return {
axes: [
{
stroke: () => this.ctrl.getTheme().xAxis.stroke,
ticks: {
stroke: () => this.ctrl.getTheme().xAxis.tickStroke,
},
border: {
stroke: () => this.ctrl.getTheme().xAxis.tickStroke,
},
},
{
stroke: () => this.ctrl.getTheme().yAxis.stroke,
grid: {
stroke: () => this.ctrl.getTheme().yAxis.gridStroke,
},
ticks: {
stroke: () => this.ctrl.getTheme().yAxis.tickStroke,
},
border: {
stroke: () => this.ctrl.getTheme().xAxis.tickStroke,
},
},
],
};
}
getUPlotChart() {
return this.uPlot;
}
getPlugins() {
return [this.getTooltipPlugin()];
}
getTooltipPlugin() {
let over;
let bound;
// let bLeft: number;
// let bTop: number;
let cacheData;
let overBbox;
return {
hooks: {
init: (u) => {
over = u.over;
bound = over;
const cursorX = u.over.querySelector(CURSOR_X);
if (cursorX) {
cursorX.style.visibility = 'hidden';
}
over.addEventListener('click', () => {
const { left, top } = u.cursor;
const data = this.ctrl.getData();
const x = u.data[0][u.valToIdx(u.posToVal(this.transposed ? top : left, 'x'))];
const values = data.reduce((pre, cur) => {
const items = cur.values.find(c => c.x === x);
const values = { ...items, ...omit(cur, 'values') };
return [...pre, values];
}, []);
this.ctrl.emit(ChartEvent.PLOT_CLICK, {
title: x,
values,
});
});
over.addEventListener('mouseenter', () => {
if (u.series.some(d => d.scale === 'y' && d.show)) {
this.ctrl.emit(ChartEvent.PLOT_MOUSEMOVE);
const noData = !Array.from(u.data.slice(1))
.flat()
.some(d => d !== null);
if (!noData && !this.ctrl.hideTooltip && !this.isElementAction) {
this.ctrl.components.get('tooltip').showTooltip();
}
}
});
over.addEventListener('mouseleave', () => {
this.ctrl.emit(ChartEvent.PLOT_MOUSELEAVE);
this.ctrl.components.get('tooltip').hideTooltip();
});
},
syncRect: (_, rect) => (overBbox = rect),
// eslint-disable-next-line sonarjs/cognitive-complexity
setCursor: (u) => {
if (!overBbox) {
return;
}
const { left, top, idx, idxs } = u.cursor;
const x = u.data[0][idx];
const data = this.ctrl.getData();
const ySeries = u.series.filter(d => d.scale === 'y');
const values = data.reduce((prev, curr, index) => {
const allow = this.isElementAction ? idxs[index + 1] : true;
return ySeries[index]?.show && allow
? [
...prev,
{
name: curr.name,
color: curr.color,
value: curr.values[idx || 0]?.y,
activated: this.activeId === index + 1,
...curr.values[idx || 0],
},
]
: prev;
}, []);
const cursorX = u.over.querySelector(CURSOR_X);
const cursorY = u.over.querySelector('.u-cursor-y');
const noData = !values.some(d => d.y !== null);
const visibility = noData ? 'hidden' : 'visible';
const visibilityX = noData || this.ctrl.hideTooltip ? 'hidden' : 'visible';
if (cursorX) {
cursorX.style.visibility = visibilityX;
const tooltip = this.ctrl.components.get('tooltip');
if (visibilityX === 'hidden') {
tooltip.hideTooltip();
}
else {
tooltip.showTooltip();
}
}
if (cursorY) {
cursorY.style.visibility = visibility;
}
if (noData && !this.isElementAction) {
return;
}
if (!this.isElementAction) {
cacheData = {
title: x,
values,
};
}
if (idxs.some(Boolean)) {
cacheData = {
title: x,
values,
};
if (!this.recordActive) {
this.recordActive = true;
this.ctrl.emit(ChartEvent.ELEMENT_MOUSEMOVE);
if (!noData && !this.ctrl.hideTooltip) {
this.ctrl.components.get('tooltip').showTooltip();
}
}
}
else {
if (this.recordActive) {
this.ctrl.emit(ChartEvent.ELEMENT_MOUSELEAVE);
this.recordActive = false;
this.ctrl.components.get('tooltip').hideTooltip();
}
}
if (cacheData && this.cursor) {
this.cursor.style.transform = `translate(${left}px,${top}px)`;
this.ctrl.emit(ChartEvent.U_PLOT_SET_CURSOR, {
bound,
anchor: this.cursor || u.over.querySelector(CURSOR_X),
title: cacheData.title,
values: cacheData.values,
});
// this.ctrl.emit(ChartEvent.U_PLOT_SET_CURSOR, {
// bound,
// anchor,
// title: cacheData.title,
// values: cacheData.values,
// });
}
},
setSelect: [
(u) => {
if (u.select.width) {
const start = u.data[0][u.valToIdx(u.posToVal(u.select.left, 'x'))];
const end = u.data[0][u.valToIdx(u.posToVal(u.select.left + u.select.width, 'x'))];
// manually hide selected region (since cursor.drag.setScale = false)
/* @ts-ignore */
u.setSelect({ left: 0, width: 0 }, false);
this.ctrl.emit(ChartEvent.PLOT_MOUSEUP, { start, end });
}
},
],
},
};
}
destroy() {
this.components = [];
this.uPlot?.destroy();
}
}
function isCursorOutsideCanvas({ left, top }, canvas) {
if (left === undefined || top === undefined) {
return false;
}
return left < 0 || left > canvas.width || top < 0 || top > canvas.height;
}
/**
* Finds y axis midpoint for point at given idx (css pixels relative to uPlot canvas)
* @internal
**/
// eslint-disable-next-line sonarjs/cognitive-complexity
export function findMidPointYPosition(u, idx) {
let y;
let sMaxIdx = 1;
let sMinIdx = 1;
// assume min/max being values of 1st series
let max = u.data[1][idx];
let min = u.data[1][idx];
// find min max values AND ids of the corresponding series to get the scales
for (let i = 1; i < u.data.length; i++) {
const sData = u.data[i];
const sVal = sData[idx];
if (sVal != null) {
if (max == null) {
max = sVal;
}
else {
if (sVal > max) {
max = u.data[i][idx];
sMaxIdx = i;
}
}
if (min == null) {
min = sVal;
}
else {
if (sVal < min) {
min = u.data[i][idx];
sMinIdx = i;
}
}
}
}
if (min == null && max == null) {
// no tooltip to show
y = undefined;
}
else if (min != null && max != null) {
// find median position
y =
(u.valToPos(min, u.series[sMinIdx].scale) +
u.valToPos(max, u.series[sMaxIdx].scale)) /
2;
}
else {
// snap tooltip to min OR max point, one of those is not null :)
y = u.valToPos((min || max), u.series[(sMaxIdx || sMinIdx)].scale);
}
// if y is out of canvas bounds, snap it to the bottom
if (y !== undefined && y < 0) {
y = u.bbox.height / devicePixelRatio;
}
return y;
}
/**
* Given uPlot cursor position, figure out position of the tooltip withing the canvas bbox
* Tooltip is positioned relatively to a viewport
* @internal
**/
export function positionTooltip(u, bbox) {
let x, y;
const cL = u.cursor.left || 0;
const cT = u.cursor.top || 0;
if (isCursorOutsideCanvas(u.cursor, bbox)) {
const idx = u.posToIdx(cL);
// when cursor outside of uPlot's canvas
if (cT < 0 || cT > bbox.height) {
const pos = findMidPointYPosition(u, idx);
if (pos) {
y = bbox.top + pos;
if (cL >= 0 && cL <= bbox.width) {
// find x-scale position for a current cursor left position
x =
bbox.left +
u.valToPos(u.data[0][u.posToIdx(cL)], u.series[0].scale);
}
}
}
}
else {
x = bbox.left + cL;
y = bbox.top + cT;
}
return { x, y };
}
//# sourceMappingURL=uplot-strategy.js.map