@bluecateng/pelagos-charts
Version:
487 lines • 17 kB
JavaScript
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { scaleQuantize } from 'd3-scale';
import { select } from 'd3-selection';
import { curveLinear, curveMonotoneX, curveNatural, curveStep, line } from 'd3-shape';
import identity from 'lodash-es/identity';
import { addResizeObserver, useRandomId } from '@bluecateng/pelagos';
import 'core-js/actual/iterator/map';
import { axisPropType, colorPropType, dataPropType, hintPropType, pointsPropType, legendPropType } from './ChartPropTypes';
import getDefaultClass from './getDefaultClass';
import extractLineDataFromTidy from './extractLineDataFromTidy';
import extractLineDataFromColumns from './extractLineDataFromColumns';
import getDomain from './getDomain';
import getColorClass from './getColorClass';
import getColorVariant from './getColorVariant';
import createScale from './createScale';
import MultiHint from './MultiHint';
import getPlotBottom from './getPlotBottom';
import getTicks from './getTicks';
import drawLeftAxis from './drawLeftAxis';
import drawBottomAxis from './drawBottomAxis';
import drawGrid from './drawGrid';
import scaleProperties from './scaleProperties';
import tickFormatters from './tickFormatters';
import hintFormatters from './hintFormatters';
import updateHint from './updateHint';
import legendDirections from './legendDirections';
import Legend from './Legend';
import drawLoadingGrid from './drawLoadingGrid';
import ChartAxes from './ChartAxes';
import LoadingGrid from './LoadingGrid';
import './Chart.less';
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
const curves = {
linear: curveLinear,
monotone: curveMonotoneX,
natural: curveNatural,
step: curveStep
};
const dataExtractors = {
tidy: extractLineDataFromTidy,
columns: extractLineDataFromColumns,
native: identity
};
const hideHint = hintData => ({
...hintData,
visible: false
});
const renderSelection = (startX, endX, startKey, endKey, plotBottom, setHintData, formatter) => {
const width = Math.abs(startX - endX);
const forward = startX < endX;
setHintData({
visible: true,
style: {
left: Math.min(startX, endX),
top: 0
},
content: /*#__PURE__*/_jsxs(_Fragment, {
children: [/*#__PURE__*/_jsx("div", {
className: "Chart__selection",
style: {
left: 0,
width,
height: plotBottom
}
}), /*#__PURE__*/_jsx("div", {
className: "Chart__range",
style: {
left: width / 2
},
children: `${formatter(forward ? startKey : endKey)} - ${formatter(forward ? endKey : startKey)}`
})]
})
});
};
const buildMouseDrag = (dragState, bounds, setHintData) => event => {
event.preventDefault();
event.stopPropagation();
const {
hintHeaderFormatter,
bottomScale,
finder,
plotBottom,
startX,
startKey
} = dragState;
const {
left,
right
} = bounds;
const [rangeStart, rangeEnd] = bottomScale.range();
const list = finder.range();
const mx = event.clientX;
let endKey, endX;
if (mx <= left + rangeStart) {
endKey = list[0];
endX = rangeStart;
} else if (mx >= right) {
endKey = list[list.length - 1];
endX = rangeEnd;
} else {
endKey = finder(mx - left);
endX = bottomScale(endKey);
}
dragState.endKey = endKey;
dragState.endX = endX;
renderSelection(startX, endX, startKey, endKey, plotBottom, setHintData, hintHeaderFormatter);
};
const buildMouseUp = (dragState, bounds, setHintData) => event => {
event.preventDefault();
event.stopPropagation();
const {
onClick,
onDrag,
bottomScale,
handleMouseDrag,
handleMouseUp,
startX,
endX,
startKey,
endKey
} = dragState;
const {
left,
right,
top,
bottom
} = bounds;
const chartLeft = left + bottomScale.range()[0];
document.removeEventListener('mousemove', handleMouseDrag, true);
document.removeEventListener('mouseup', handleMouseUp, true);
dragState.dragging = false;
setHintData(hideHint);
const mx = event.clientX;
const my = event.clientY;
if (mx >= chartLeft - 20 && mx <= right + 20 && my >= top && my <= bottom) {
if (startKey === endKey) {
onClick == null || onClick(startKey);
} else {
const forward = startX < endX;
onDrag == null || onDrag(forward ? startKey : endKey, forward ? endKey : startKey);
}
}
};
const LineChart = ({
id,
className,
data,
curve = 'monotone',
dataOptions,
color,
bottomAxis,
leftAxis,
points,
legend,
hint,
getStrokeClass = getDefaultClass,
getFillClass = getDefaultClass,
getBgClass = getDefaultClass,
onClick,
onDrag,
onLegendClick,
onSelectionChange,
...props
}) => {
id = useRandomId(id);
const {
format: dataFormat,
groupFormatter: dataGroupFormatter,
groupMapsTo: dataGroupMapsTo,
loading: dataLoading,
selectedGroups: dataSelectedGroups
} = {
format: 'tidy',
groupFormatter: identity,
groupMapsTo: 'group',
loading: false,
...dataOptions
};
const dataExtract = dataExtractors[dataFormat];
const {
groupCount: colorGroupCount,
option: colorOption
} = {
groupCount: null,
option: 1,
...(color == null ? void 0 : color.pairing)
};
const {
domain: bottomDomain,
scaleType: bottomScaleType,
title: bottomTitle
} = {
scaleType: 'labels',
...bottomAxis
};
const bottomMapsTo = (bottomAxis == null ? void 0 : bottomAxis.mapsTo) || scaleProperties[bottomScaleType];
const {
formatter: bottomTickFormatter
} = {
formatter: tickFormatters[bottomScaleType],
...(bottomAxis == null ? void 0 : bottomAxis.ticks)
};
const {
domain: leftDomain,
scaleType: leftScaleType,
title: leftTitle
} = {
scaleType: 'linear',
...leftAxis
};
const leftMapsTo = (leftAxis == null ? void 0 : leftAxis.mapsTo) || scaleProperties[leftScaleType];
const {
formatter: leftTickFormatter
} = {
formatter: tickFormatters[leftScaleType],
...(leftAxis == null ? void 0 : leftAxis.ticks)
};
const {
enabled: pointsEnabled,
filled: pointsFilled,
radius: pointsRadius
} = {
enabled: true,
filled: false,
radius: 3,
...points
};
const {
alignment: legendAlignment,
clickable: legendClickable,
enabled: legendEnabled,
order: legendOrder,
position: legendPosition
} = {
alignment: 'start',
clickable: true,
enabled: true,
position: 'bottom',
...legend
};
const {
enabled: hintEnabled,
headerFormatter: hintHeaderFormatter,
showTotal: hintShowTotal,
title: hintTitle,
totalLabel: hintTotalLabel,
valueFormatter: hintValueFormatter
} = {
enabled: true,
headerFormatter: hintFormatters[bottomScaleType],
showTotal: true,
title: bottomTitle || 'x',
totalLabel: `Total`,
// TODO translate
valueFormatter: hintFormatters[leftScaleType],
...hint
};
const gradientId = `${id}-loading`;
const state = useMemo(() => {
if (dataLoading) {
return {
groupIndex: new Map()
};
}
const {
groups,
groupIndex,
hintValues,
leftList,
bottomList,
pointList
} = dataExtract(data, dataSelectedGroups, dataGroupMapsTo, bottomMapsTo, leftMapsTo);
return {
groups,
groupIndex,
hintValues,
pointList,
bottomList,
leftDomain: getDomain(leftDomain, leftScaleType, leftList),
bottomDomain: getDomain(bottomDomain, bottomScaleType, bottomList)
};
}, [bottomDomain, bottomMapsTo, bottomScaleType, data, dataExtract, dataGroupMapsTo, dataLoading, dataSelectedGroups, leftDomain, leftMapsTo, leftScaleType]);
const ref = useRef(null);
const drawRef = useRef(null);
const hintRef = useRef(null);
const [hintData, setHintData] = useState({});
useLayoutEffect(() => {
const {
groups,
groupIndex,
hintValues,
pointList,
leftDomain,
bottomDomain,
bottomList
} = state;
const hasData = !!(bottomList != null && bottomList.length);
const colorVariant = getColorVariant(colorGroupCount, groupIndex.size);
const draw = drawRef.current = ({
width,
height
}) => {
const svg = ref.current;
if (dataLoading) {
drawLoadingGrid(svg, width, height);
return;
}
const nodes = svg.childNodes;
const axes = nodes[1];
const plotBottom = getPlotBottom(height, bottomTitle);
const leftScale = createScale(leftScaleType, leftDomain, [plotBottom, 0], 0.25);
const leftTickCount = height / 80;
const leftTicks = getTicks(leftScale, leftTickCount);
const plotLeft = drawLeftAxis(axes.firstChild, leftScale, leftTitle, leftTickCount, leftTicks, leftTickFormatter, plotBottom);
const bottomScale = createScale(bottomScaleType, bottomDomain, [plotLeft, width], 0.25);
const bottomTickCount = width / 80;
const bottomTicks = getTicks(bottomScale, bottomTickCount);
drawBottomAxis(axes.lastChild, bottomScale, bottomTitle, bottomTickCount, bottomTicks, bottomTickFormatter, width, height, plotBottom, plotLeft);
drawGrid(nodes[0], leftTicks, leftScale, bottomTicks, bottomScale, width, plotLeft, plotBottom);
const needsZero = leftScaleType === 'linear' && leftDomain[0] < 0;
const zero = needsZero ? leftScale(0) : 0;
select(nodes[4]).selectAll('line').data(needsZero ? [1] : []).join('line').attr('x1', plotLeft).attr('x2', width).attr('y1', zero).attr('y2', zero);
const ruler = nodes[5];
ruler.setAttribute('y2', plotBottom);
const grid = select(nodes[0]);
if (hasData) {
const finder = scaleQuantize().domain(bottomScale.range()).range(bottomList);
const dragState = {
hintHeaderFormatter,
onClick,
onDrag,
bottomScale,
finder,
plotBottom
};
grid.on('mousedown', event => {
if (event.button === 0) {
event.preventDefault();
const bounds = svg.getBoundingClientRect();
dragState.handleMouseDrag = buildMouseDrag(dragState, bounds, setHintData);
dragState.handleMouseUp = buildMouseUp(dragState, bounds, setHintData);
document.addEventListener('mousemove', dragState.handleMouseDrag, true);
document.addEventListener('mouseup', dragState.handleMouseUp, true);
const key = finder(event.clientX - bounds.left);
const x = bottomScale(key);
dragState.dragging = true;
dragState.startKey = dragState.endKey = key;
dragState.startX = dragState.endX = x;
ruler.style.opacity = 0;
renderSelection(x, x, key, key, plotBottom, setHintData, hintHeaderFormatter);
}
});
if (hintEnabled) {
let currentKey, currentPosition, hint;
grid.on('mousemove', event => {
if (dragState.dragging) return;
const {
clientX,
clientY
} = event;
const bounds = svg.parentNode.getBoundingClientRect();
const chartX = clientX - bounds.left;
const chartY = clientY - bounds.top;
const key = finder(chartX);
if (key !== currentKey) {
currentKey = key;
currentPosition = bottomScale(key);
ruler.setAttribute('x1', currentPosition);
ruler.setAttribute('x2', currentPosition);
hint = /*#__PURE__*/_jsx(MultiHint, {
title: hintTitle,
headerValue: key,
values: hintValues.get(key),
groupIndex: groupIndex,
showTotal: hintShowTotal,
totalLabel: hintTotalLabel,
headerFormatter: hintHeaderFormatter,
groupFormatter: dataGroupFormatter,
valueFormatter: hintValueFormatter,
getBgClass: getBgClass,
variant: colorVariant,
option: colorOption
});
}
updateHint(true, chartX, chartY, bounds.width, bounds.height, currentPosition, 24, width, plotLeft, plotBottom, setHintData, hint, ruler);
}).on('mouseleave', () => {
if (dragState.dragging) return;
currentKey = null;
ruler.style.opacity = 0;
setHintData(hideHint);
});
} else {
grid.on('mousemove', null).on('mouseleave', null);
}
} else {
grid.on('mousedown', null).on('mousemove', null).on('mouseleave', null);
}
if (hasData) {
const bottomSetScaled = bottomList.map(bottomScale);
const lineFn = line().curve(curves[curve]).defined(d => d !== null).x((_, i) => bottomSetScaled[i]).y(d => leftScale(d));
select(nodes[2]).selectAll('path').data(groups.entries()).join('path').attr('class', ([group]) => getStrokeClass(group, null, null, getColorClass('stroke', colorVariant, colorOption, groupIndex.get(group)))).attr('d', ([, list]) => lineFn(list)).attr('role', 'graphics-symbol').attr('aria-roledescription', `line`) // TODO translate
.attr('aria-label', d => `${dataGroupFormatter(d[0])}, ${d[1].join(', ')}`);
} else {
nodes[2].replaceChildren();
}
if (pointsEnabled && hasData) {
const modifier = pointsFilled ? 'filled' : 'hollow';
select(nodes[3]).selectAll('circle').data(pointList).join('circle').attr('class', d => {
const [group, key, value] = d;
const index = groupIndex.get(group);
const strokeClass = getStrokeClass(group, key, value, getColorClass('stroke', colorVariant, colorOption, index));
const fillClass = getFillClass(group, key, value, getColorClass('fill', colorVariant, colorOption, index));
return `${modifier} ${strokeClass} ${fillClass}`;
}).attr('r', pointsRadius).attr('cx', d => bottomScale(d[1])).attr('cy', d => leftScale(d[2]));
} else {
nodes[3].replaceChildren();
}
};
draw(ref.current.getBoundingClientRect());
}, [bottomScaleType, bottomTickFormatter, bottomTitle, colorGroupCount, colorOption, curve, dataGroupFormatter, dataLoading, getBgClass, getFillClass, getStrokeClass, hintEnabled, hintHeaderFormatter, hintShowTotal, hintTitle, hintTotalLabel, hintValueFormatter, leftScaleType, leftTickFormatter, leftTitle, onClick, onDrag, pointsEnabled, pointsFilled, pointsRadius, state]);
useEffect(() => addResizeObserver(ref.current, r => drawRef.current(r)), []);
return /*#__PURE__*/_jsxs("div", {
...props,
id: id,
className: `Chart Chart__wrapper${className ? ` ${className}` : ''}`,
children: [useMemo(() => /*#__PURE__*/_jsxs("svg", {
className: "Chart__chart",
"data-chromatic": "ignore",
ref: ref,
children: [/*#__PURE__*/_jsxs("g", {
className: "Chart__grid",
children: [/*#__PURE__*/_jsx("rect", {}), /*#__PURE__*/_jsx("g", {}), /*#__PURE__*/_jsx("g", {})]
}), /*#__PURE__*/_jsx(ChartAxes, {}), /*#__PURE__*/_jsx("g", {
className: "Chart__lines",
role: "group",
"aria-label": /* TODO translate */`data`
}), /*#__PURE__*/_jsx("g", {
className: "Chart__dots",
pointerEvents: "none"
}), /*#__PURE__*/_jsx("g", {
className: "Chart__zero"
}), /*#__PURE__*/_jsx("line", {
className: "Chart__ruler"
}), dataLoading && /*#__PURE__*/_jsx(LoadingGrid, {
gradientId: gradientId
})]
}), [dataLoading, gradientId]), legendEnabled && /*#__PURE__*/_jsx(Legend, {
className: `${legendAlignment} ${legendPosition}`,
groups: legendOrder || Array.from(state.groupIndex.keys()),
formatter: dataGroupFormatter,
direction: legendDirections[legendPosition],
clickable: legendClickable,
selected: dataSelectedGroups,
color: color,
getBgClass: getBgClass,
onClick: onLegendClick,
onChange: onSelectionChange
}), /*#__PURE__*/_jsx("div", {
className: `Chart__hintContainer${hintData.visible ? ' visible' : ''}`,
style: hintData.style,
ref: hintRef,
children: hintData.content
})]
});
};
LineChart.propTypes = {
id: PropTypes.string,
className: PropTypes.string,
data: PropTypes.any,
curve: PropTypes.oneOf(['linear', 'monotone', 'natural', 'step']),
dataOptions: dataPropType,
color: colorPropType,
bottomAxis: axisPropType,
leftAxis: axisPropType,
points: pointsPropType,
legend: legendPropType,
hint: hintPropType,
getStrokeClass: PropTypes.func,
getFillClass: PropTypes.func,
getBgClass: PropTypes.func,
onClick: PropTypes.func,
onDrag: PropTypes.func,
onLegendClick: PropTypes.func,
onSelectionChange: PropTypes.func
};
export default LineChart;