@neo4j-ndl/react-charts
Version:
React implementation of charts from Neo4j Design System
435 lines • 23.5 kB
JavaScript
"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