UNPKG

zz-chart

Version:

Alauda Chart components by Alauda Frontend Team

578 lines 22.6 kB
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); const ii = this.uPlot.series.findIndex(res => res.label === s.label); this.uPlot.setSeries(ii, { show: !legend.inactivatedSet.has(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 nData = data .filter(v => labels.includes(v.name)) .sort((a, b) => labels.indexOf(a.id || a.name) - labels.indexOf(a.id || b.name)); if (nData[0].floatValues?.length) { const values = nData.map(value => value.floatValues); const yItem = values.map(data => { return data[1]; }); return [values[0][0], ...yItem]; } const values = nData.map(value => value.values); const x = values[0].map(v => v.x); // const yItem = values.map(data => data.map(d => d.y)); const yItem = values.map(data => { return data.map(d => 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) => { if (cur.floatValues.length) { const index = cur.floatValues[0].findIndex(c => c === x); return { ...{ x: cur.floatValues[0][index], y: cur.floatValues[1][index], }, ...omit(cur, 'values'), }; } 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; const value = curr.floatValues?.length ? curr.floatValues[1][idx || 0] : curr.values[idx || 0]?.y; return ySeries[index]?.show && allow ? [ ...prev, { ...curr?.values?.[idx || 0], name: curr.name, color: curr.color, value: value, activated: this.activeId === index + 1, }, ] : prev; }, []); const cursorX = u.over.querySelector(CURSOR_X); const cursorY = u.over.querySelector('.u-cursor-y'); const noData = !values.some(d => d.value !== 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