UNPKG

@neo4j-ndl/react-charts

Version:

React implementation of charts from Neo4j Design System

435 lines 23.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.defaultThresholdLineSeriesOption = void 0; exports.Chart = Chart; const jsx_runtime_1 = require("react/jsx-runtime"); /** * * Copyright (c) "Neo4j" * Neo4j Sweden AB [http://neo4j.com] * * This file is part of Neo4j. * * Neo4j is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ const react_1 = require("@neo4j-ndl/react"); const echarts_1 = require("echarts"); const react_2 = require("react"); const server_1 = require("react-dom/server"); const ChartTooltip_1 = require("./ChartTooltip"); const Legend_1 = require("./Legend"); const ndl_echarts_theme_1 = require("./themes/ndl-echarts-theme"); const user_option_utils_1 = require("./user-option-utils"); const utils_1 = require("./utils"); // This returns a boolean if the condition is met // and also what to display in the tooltip. function checkCondition(value, condition, threshold) { switch (condition) { case 'greater': return { isConditionMet: value > threshold, conditionText: 'Above', }; case 'greaterOrEqual': return { isConditionMet: value >= threshold, conditionText: value > threshold ? 'Above' : value === threshold ? 'Equal' : undefined, }; case 'less': return { isConditionMet: value < threshold, conditionText: 'Below', }; case 'lessOrEqual': return { isConditionMet: value <= threshold, conditionText: value < threshold ? 'Below' : value === threshold ? 'Equal' : undefined, }; case 'equal': return { isConditionMet: value === threshold, conditionText: 'Equal', }; case 'notEqual': return { isConditionMet: value !== threshold, conditionText: 'Not equal', }; default: return { isConditionMet: false, conditionText: undefined }; } } exports.defaultThresholdLineSeriesOption = { condition: 'greater', }; function Chart({ dataset, option: userOption, xAxis: propXAxis, yAxis: propYAxis, series: propsSeries, style, settings = { notMerge: true, lazyUpdate: false, silent: false, }, isLoading, legend, callbacks, }) { const chartRef = (0, react_2.useRef)(null); const chartEchartRef = (0, react_2.useRef)(null); const chartLegendRef = (0, react_2.useRef)(null); const [isWaitingForFirstResize, setIsWaitingForFirstResize] = (0, react_2.useState)(true); const dataZoomOptions = userOption === null || userOption === void 0 ? void 0 : userOption.dataZoom; const toolboxOptions = userOption === null || userOption === void 0 ? void 0 : userOption.toolbox; const hasSliderZoom = Array.isArray(dataZoomOptions) ? dataZoomOptions.some((dataZoomOption) => { return (dataZoomOption === null || dataZoomOption === void 0 ? void 0 : dataZoomOption.type) === 'slider'; }) : (dataZoomOptions === null || dataZoomOptions === void 0 ? void 0 : dataZoomOptions.type) === 'slider'; const { theme } = (0, react_1.useNeedleTheme)(); const thresholdLines = (0, react_2.useMemo)(() => { const seriesArray = Array.isArray(propsSeries) ? propsSeries : [propsSeries]; const thresholdLineSeries = seriesArray.filter((currentSeries) => { return currentSeries.type === 'thresholdLine'; }); return thresholdLineSeries.map((currentThresholdLineSeriesOption) => { var _a; return Object.assign(Object.assign({}, currentThresholdLineSeriesOption), { condition: (_a = currentThresholdLineSeriesOption.condition) !== null && _a !== void 0 ? _a : exports.defaultThresholdLineSeriesOption.condition, value: currentThresholdLineSeriesOption.yAxis }); }); }, [propsSeries]); const dataZoom = (0, react_2.useMemo)(() => { return (0, user_option_utils_1.mergeDataZoom)(dataZoomOptions); }, [dataZoomOptions]); const series = (0, user_option_utils_1.mergeSeries)(propsSeries, theme); const xAxis = (0, user_option_utils_1.mergeXAxis)(propXAxis, theme); const yAxis = (0, user_option_utils_1.mergeYAxis)(propYAxis, theme); const hasCategoryXAxis = Array.isArray(xAxis) ? xAxis.some((x) => x.type === 'category') : (xAxis === null || xAxis === void 0 ? void 0 : xAxis.type) === 'category'; // The initial option used, the charts option is not necessarily // the same as this due to mutation via dispatch and setOption. // use getOption to get the current option of the chart. const initialOption = (0, react_2.useMemo)(() => { if (chartEchartRef.current === null) return; const option = Object.assign(Object.assign({ dataset, xAxis, yAxis }, userOption), { grid: Object.assign({ left: hasCategoryXAxis ? '15px' : '10px', right: hasCategoryXAxis ? '15px' : '10px', top: '10px', bottom: hasSliderZoom ? '60px' : '10px', type: 'solid', containLabel: true }, userOption === null || userOption === void 0 ? void 0 : userOption.grid), legend: { // Removes in-built echarts legend show: false, }, series, tooltip: { trigger: 'axis', confine: true, // Reset the default tooltip css styling padding: 0, borderRadius: 0, borderWidth: 0, extraCssText: 'box-shadow: none; background-color: transparent;', formatter: function (params) { var _a; const paramsArray = Array.isArray(params) ? params : [params]; const firstParam = paramsArray[0]; let valueFormatter = (value) => `${value}`; if (typeof (userOption === null || userOption === void 0 ? void 0 : userOption.tooltip) === 'object' && userOption.tooltip !== null && 'valueFormatter' in userOption.tooltip) { valueFormatter = userOption.tooltip.valueFormatter; } return `${(0, server_1.renderToString)((0, jsx_runtime_1.jsxs)("span", { className: "ndl-charts-chart-tooltip", children: [(0, jsx_runtime_1.jsx)(ChartTooltip_1.ChartTooltip.Title, { children: (_a = firstParam === null || firstParam === void 0 ? void 0 : firstParam.axisValueLabel) !== null && _a !== void 0 ? _a : '' }), paramsArray.map((series) => { const { value: seriesValueUnknown } = series; const seriesValue = Array.isArray(seriesValueUnknown) ? seriesValueUnknown[1] : seriesValueUnknown; const isThresholdLine = series.seriesName.startsWith('thresholdLine'); const notifications = thresholdLines .filter((threshold) => { const { value: thresholdValue, condition, customCondition, } = threshold; const isConditionMet = customCondition ? customCondition(seriesValue, thresholdValue) : checkCondition(seriesValue, condition, thresholdValue) .isConditionMet; return !isThresholdLine && isConditionMet; }) .map((threshold) => { const { notificationType, value: thresholdValue, condition, customConditionText, customCondition, } = threshold; return { id: `threshold-${notificationType}`, notificationType, leftElement: customCondition ? customConditionText : checkCondition(seriesValue, condition, thresholdValue) .conditionText + ' threshold', rightElement: thresholdValue, }; }); const value = (0, utils_1.extractValueFromTooltipSeries)(series.value, series.encode, series.axisDim); return ((0, jsx_runtime_1.jsx)(ChartTooltip_1.ChartTooltip.Content, { leftElement: isThresholdLine ? (0, utils_1.capitalizeFirstLetter)(series.seriesName.replace('thresholdLine-', '')) + ' threshold' : series.seriesName, rightElement: valueFormatter(value), indentSquareColor: series.color, notifications: notifications }, series.seriesName)); })] }))}`; }, }, dataZoom, toolbox: (0, user_option_utils_1.mergeToolbox)(toolboxOptions) }); // Update chart with initial option const chart = (0, echarts_1.getInstanceByDom)(chartEchartRef.current); chart === null || chart === void 0 ? void 0 : chart.setOption(option, settings); // Get option returns the current option of the chart. // This is slightly different than the option we gave as an argument. // Because we use useMemo in other areas we want to get this set first, // this is why we are setting then getting then returning. If we did this in // a useEffect it would run after the other useMemos which is not what we want. // This is purely for order of operations. const chartOption = chart === null || chart === void 0 ? void 0 : chart.getOption(); return chartOption; }, [ dataset, xAxis, yAxis, userOption, hasCategoryXAxis, hasSliderZoom, series, dataZoom, toolboxOptions, thresholdLines, settings, ]); (0, react_2.useEffect)(() => { var _a; // Initialize chart let chart; if (chartEchartRef.current !== null) { (0, echarts_1.registerTheme)('ndl-light', (0, ndl_echarts_theme_1.ndlEchartsTheme)('light')); (0, echarts_1.registerTheme)('ndl-dark', (0, ndl_echarts_theme_1.ndlEchartsTheme)('dark')); const currentChart = (0, echarts_1.getInstanceByDom)(chartEchartRef.current); if (currentChart) { chart = currentChart; } else { const echartsTheme = theme === 'light' ? 'ndl-light' : 'ndl-dark'; chart = (0, echarts_1.init)(chartEchartRef.current, echartsTheme, { renderer: 'svg', }); } } if (callbacks === null || callbacks === void 0 ? void 0 : callbacks.onZoom) { chart === null || chart === void 0 ? void 0 : chart.on('datazoom', () => { var _a; if (chartEchartRef.current === null) return; const currentChart = (0, echarts_1.getInstanceByDom)(chartEchartRef.current); if (!currentChart) return; const option = currentChart.getOption(); if (!option || !option.dataZoom) return; const dataZoom = Array.isArray(option.dataZoom) ? option.dataZoom[0] : option.dataZoom; const { startValue, endValue } = dataZoom; (_a = callbacks === null || callbacks === void 0 ? void 0 : callbacks.onZoom) === null || _a === void 0 ? void 0 : _a.call(callbacks, { startValue, endValue }); }); } // Add chart resize listener // ResizeObserver is leading to a bit janky UX function resizeChart() { // TODO: We need to revisit this. Right now using grid containLabel seems to work. // We still need to visit this for overflowing of the x-axis labels. // const calculateGridLeft = () => { // const textElements = chartEchartRef.current?.querySelectorAll('text'); // const filteredTextElements = Array.from(textElements || []).filter( // (element) => element.getAttribute('text-anchor') === 'end', // ); // let maxWidth = 0; // filteredTextElements.forEach((element) => { // const bbox = element.getBBox(); // maxWidth = Math.max(maxWidth, bbox.width); // }); // const tickLength = 5; // const tickPadding = 3; // return maxWidth + tickLength + tickPadding; // }; // // // Example of dynamically setting the grid's left // const gridLeft = calculateGridLeft(); // chart?.setOption({ // grid: { // left: gridLeft, // }, // }); var _a, _b, _c; // We want to fit the chart to the charts container. const chartContainerHeight = (_a = chartRef.current) === null || _a === void 0 ? void 0 : _a.clientHeight; const chartContainerWidth = (_b = chartRef.current) === null || _b === void 0 ? void 0 : _b.clientWidth; // Need to take legends height into consideration, otherwise it will overflow the parent. const chartLegendHeight = ((_c = chartLegendRef === null || chartLegendRef === void 0 ? void 0 : chartLegendRef.current) === null || _c === void 0 ? void 0 : _c.clientHeight) || 0; const height = chartContainerHeight ? chartContainerHeight - chartLegendHeight : undefined; chart === null || chart === void 0 ? void 0 : chart.resize({ width: chartContainerWidth, height, }); } window.addEventListener('resize', resizeChart); requestAnimationFrame(() => { resizeChart(); setIsWaitingForFirstResize(false); }); // Add chart zoom listeners const handleMouseMove = () => { // I do not like this at all: https://github.com/apache/echarts/issues/19819 // echarts updates the svgs on every mouse movement so we need to do this. chart === null || chart === void 0 ? void 0 : chart.getZr().setCursorStyle('default'); }; // We cannot use chart.getZr().on('mousemove', handleMouseMove) // This is because it doesn't respect our callbacks. It will // always run echarts code last instead of our callback. const chartChild = (_a = chartEchartRef.current) === null || _a === void 0 ? void 0 : _a.children[0]; chartChild.addEventListener('mousemove', handleMouseMove); const handleMouseDown = (params) => { const event = params.event; const amountOfMouseClicks = event.detail; const isDoubleClick = amountOfMouseClicks === 2; if (isDoubleClick) { // Reset zooming. if (chart === undefined) return; chart === null || chart === void 0 ? void 0 : chart.dispatchAction({ type: 'dataZoom', start: 0, end: 100, }); } }; chart === null || chart === void 0 ? void 0 : chart.getZr().on('mousedown', handleMouseDown); // Return cleanup function const chartRefCurrentElement = chartEchartRef.current; const element = chartRefCurrentElement === null || chartRefCurrentElement === void 0 ? void 0 : chartRefCurrentElement.children[0]; return () => { window.removeEventListener('resize', resizeChart); element === null || element === void 0 ? void 0 : element.removeEventListener('mousemove', handleMouseMove); // Remove chart zoom handlers if (chartRefCurrentElement) { const chart = (0, echarts_1.getInstanceByDom)(chartRefCurrentElement); chart === null || chart === void 0 ? void 0 : chart.getZr().off('mousedown', handleMouseDown); } }; }); (0, react_2.useEffect)(() => { if (chartEchartRef.current === null) { return; } const chart = (0, echarts_1.getInstanceByDom)(chartEchartRef === null || chartEchartRef === void 0 ? void 0 : chartEchartRef.current); isLoading || isWaitingForFirstResize ? chart === null || chart === void 0 ? void 0 : chart.showLoading() : chart === null || chart === void 0 ? void 0 : chart.hideLoading(); }, [isLoading, isWaitingForFirstResize]); const legendSeries = (0, react_2.useMemo)(() => { var _a; if (chartEchartRef.current === null || isWaitingForFirstResize) { return; } const chart = (0, echarts_1.getInstanceByDom)(chartEchartRef === null || chartEchartRef === void 0 ? void 0 : chartEchartRef.current); const optionSeries = (_a = initialOption === null || initialOption === void 0 ? void 0 : initialOption.series) !== null && _a !== void 0 ? _a : []; const optionDataset = initialOption === null || initialOption === void 0 ? void 0 : initialOption.dataset; const legendSeries = []; if (Array.isArray(optionSeries)) { optionSeries.forEach((currentSeries, index) => { var _a, _b, _c; if (currentSeries === null) { return; } else if (currentSeries.type === 'line' && typeof currentSeries.name === 'string' && ((_a = currentSeries.name) === null || _a === void 0 ? void 0 : _a.includes('thresholdLine'))) { return; } else if (currentSeries.type === 'pie') { const encodedItemName = (_b = currentSeries.encode) === null || _b === void 0 ? void 0 : _b.itemName; let currentDataset = Array.isArray(optionDataset) ? optionDataset[0] : optionDataset; if (currentSeries.datasetId && Array.isArray(optionDataset)) { currentDataset = optionDataset.find((ds) => ds.id === currentSeries.datasetId); } else if (currentSeries.datasetIndex !== undefined) { currentDataset = Array.isArray(optionDataset) ? optionDataset[currentSeries.datasetIndex] : optionDataset; } const firstDatasetRow = Array.isArray(currentDataset === null || currentDataset === void 0 ? void 0 : currentDataset.source) ? currentDataset === null || currentDataset === void 0 ? void 0 : currentDataset.source[0] : undefined; if (!firstDatasetRow) { return; } if (Array.isArray(firstDatasetRow)) { const firstDatasetRowIndex = firstDatasetRow.findIndex((item) => item === encodedItemName); if (firstDatasetRowIndex === -1) { return; } const source = currentDataset === null || currentDataset === void 0 ? void 0 : currentDataset.source; if (!source) { return; } const sourceLength = Array.isArray(source) ? source.length : 0; for (let rowIndex = 1; rowIndex < sourceLength; rowIndex++) { const row = source[rowIndex]; const color = chart === null || chart === void 0 ? void 0 : chart.getVisual({ dataIndexInside: rowIndex - 1, seriesIndex: index }, 'color'); legendSeries.push({ name: String(row[firstDatasetRowIndex]), color: typeof color === 'string' ? color : '#000000', }); } } else { // TODO: handle dictionary } return; } else { const name = Array.isArray(optionSeries) ? (_c = optionSeries[index]) === null || _c === void 0 ? void 0 : _c.name : undefined; if (name === undefined) { return null; } const color = chart === null || chart === void 0 ? void 0 : chart.getVisual({ seriesIndex: index }, 'color'); legendSeries.push({ name: String(name), color: typeof color === 'string' ? color : '#000000', }); } }); } return legendSeries.filter((currentSeries) => currentSeries !== null); }, [isWaitingForFirstResize, initialOption]); if (chartEchartRef.current !== null) { const chart = (0, echarts_1.getInstanceByDom)(chartEchartRef.current); if (chart) { // Needs to be re-set on every re-render. // Sets the selectable zoom area over the chart (not the slider). chart.dispatchAction({ type: 'takeGlobalCursor', key: 'dataZoomSelect', // Activate or inactivate. dataZoomSelectActive: true, }); } } return ((0, jsx_runtime_1.jsxs)("div", { ref: chartRef, className: "ndl-chart", style: style, children: [(0, jsx_runtime_1.jsx)("div", { ref: chartEchartRef }), (legend === null || legend === void 0 ? void 0 : legend.show) && !isWaitingForFirstResize && ((0, jsx_runtime_1.jsx)(Legend_1.Legend, { ref: chartLegendRef, wrappingType: legend.wrappingType, series: legendSeries !== null && legendSeries !== void 0 ? legendSeries : [], chartRef: chartEchartRef }))] })); } //# sourceMappingURL=Chart.js.map