@bluecateng/pelagos-charts
Version:
Chart components
417 lines • 15.4 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 { stack as d3Stack, stackOffsetDiverging, stackOffsetExpand, stackOffsetNone, stackOffsetSilhouette, stackOffsetWiggle, stackOrderAppearance, stackOrderAscending, stackOrderDescending, stackOrderInsideOut, stackOrderNone, stackOrderReverse } from 'd3-shape';
import identity from 'lodash-es/identity';
import { addResizeObserver, useRandomId } from '@bluecateng/pelagos';
import 'core-js/actual/array/to-reversed';
import { axisPropType, colorPropType, dataPropType, hintPropType, legendPropType } from './ChartPropTypes';
import { getDefaultClass, getGroup } from './Getters';
import extractStackDataFromTidy from './extractStackDataFromTidy';
import extractStackDataFromColumns from './extractStackDataFromColumns';
import mappers from './mappers';
import tickFormatters from './tickFormatters';
import hintFormatters from './hintFormatters';
import extendDomain from './extendDomain';
import getPlotBottom from './getPlotBottom';
import createScale from './createScale';
import getTicks from './getTicks';
import drawLeftAxis from './drawLeftAxis';
import drawBottomAxis from './drawBottomAxis';
import drawGrid from './drawGrid';
import getColorVariant from './getColorVariant';
import getColorClass from './getColorClass';
import MultiHint from './MultiHint';
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, jsxs as _jsxs } from "react/jsx-runtime";
const {
min
} = Math;
const minBarWidth = 48;
const stackOrders = {
appearance: stackOrderAppearance,
ascending: stackOrderAscending,
descending: stackOrderDescending,
insideOut: stackOrderInsideOut,
none: stackOrderNone,
reverse: stackOrderReverse
};
const stackOffsets = {
expand: stackOffsetExpand,
diverging: stackOffsetDiverging,
none: stackOffsetNone,
silhouette: stackOffsetSilhouette,
wiggle: stackOffsetWiggle
};
const dataExtractors = {
tidy: extractStackDataFromTidy,
columns: extractStackDataFromColumns,
native: identity
};
const seriesExtent = series => {
let min = 0;
let max = 0;
for (const list of series) {
for (const [a, b] of list) {
if (a < min) min = a;
if (b > max) max = b;
}
}
return extendDomain([min, max]);
};
const StackedBarChart = ({
id,
className,
data,
dataOptions,
stack,
color,
bottomAxis,
leftAxis,
legend,
hint,
getFillClass = getDefaultClass,
getBgClass = getDefaultClass,
onClick,
onLegendClick,
onSelectionChange,
...props
}) => {
id = useRandomId(id);
const {
format: dataFormat,
groupFormatter: dataGroupFormatter,
groupMapper: dataGroupMapper,
loading: dataLoading,
selectedGroups: dataSelectedGroups
} = {
format: 'tidy',
groupFormatter: identity,
groupMapper: getGroup,
loading: false,
...dataOptions
};
const dataExtract = dataExtractors[dataFormat];
const {
order: stackOrder,
offset: stackOffset
} = {
order: 'none',
offset: 'none',
...stack
};
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 bottomMapper = (bottomAxis == null ? void 0 : bottomAxis.mapper) || mappers[bottomScaleType];
const {
formatter: bottomTickFormatter
} = {
formatter: tickFormatters[bottomScaleType],
...(bottomAxis == null ? void 0 : bottomAxis.ticks)
};
const {
domain: leftDomain,
scaleType: leftScaleType,
title: leftTitle
} = {
scaleType: 'linear',
...leftAxis
};
const leftMapper = (leftAxis == null ? void 0 : leftAxis.mapper) || mappers[leftScaleType];
const {
formatter: leftTickFormatter
} = {
formatter: tickFormatters[leftScaleType],
...(leftAxis == null ? void 0 : leftAxis.ticks)
};
const vertical = bottomScaleType === 'labels';
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[vertical ? bottomScaleType : leftScaleType],
showTotal: true,
title: vertical ? bottomTitle || 'x' : leftTitle || 'y',
totalLabel: `Total`,
// TODO translate
valueFormatter: hintFormatters[vertical ? leftScaleType : bottomScaleType],
...hint
};
const gradientId = `${id}-loading`;
const state = useMemo(() => {
if (dataLoading) {
return {
groupIndex: new Map()
};
}
const {
stackData,
groupSet,
groupIndex,
hintValues,
labelSet
} = dataExtract(data, dataSelectedGroups, dataGroupMapper, vertical ? bottomMapper : leftMapper, vertical ? leftMapper : bottomMapper);
const series = d3Stack().keys(groupSet).value(([, group], key) => {
var _group$get;
return (_group$get = group.get(key)) != null ? _group$get : 0;
}).order(stackOrders[stackOrder]).offset(stackOffsets[stackOffset])(stackData);
return {
series,
groupIndex,
hintValues,
labelSet,
leftDomain: leftDomain || (vertical ? seriesExtent(series) : labelSet),
bottomDomain: bottomDomain || (vertical ? labelSet : seriesExtent(series))
};
}, [bottomDomain, bottomMapper, data, dataExtract, dataGroupMapper, dataLoading, dataSelectedGroups, leftDomain, leftMapper, stackOffset, stackOrder, vertical]);
const ref = useRef(null);
const drawRef = useRef(null);
const hintRef = useRef(null);
const [hintData, setHintData] = useState({});
useLayoutEffect(() => {
const {
series,
groupIndex,
hintValues,
labelSet,
leftDomain,
bottomDomain
} = state;
const hasData = !!(series != null && series.length);
const colorVariant = getColorVariant(colorGroupCount, groupIndex.size);
const bottomTickFormatterFn = bottomTickFormatter || identity;
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.5);
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.5);
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);
if (vertical) {
const needsZero = leftScaleType === 'linear' && leftDomain[0] < 0;
const zero = needsZero ? leftScale(0) : 0;
select(nodes[3]).selectAll('line').data(needsZero ? [1] : []).join('line').attr('x1', plotLeft).attr('x2', width).attr('y1', zero).attr('y2', zero);
} else {
const needsZero = bottomScaleType === 'linear' && bottomDomain[0] < 0;
const zero = needsZero ? bottomScale(0) : 0;
select(nodes[3]).selectAll('line').data(needsZero ? [1] : []).join('line').attr('x1', zero).attr('x2', zero).attr('y1', 0).attr('y2', plotBottom);
}
const ruler = nodes[4];
if (vertical) {
ruler.setAttribute('y2', plotBottom);
} else {
ruler.setAttribute('x1', plotLeft);
ruler.setAttribute('x2', width);
}
const barWidth = min(minBarWidth, (vertical ? bottomScale : leftScale).step() / 2);
const offset = barWidth / 2;
const buildPath = vertical ? d => {
const [d0, d1] = d;
const x = bottomScale(d.data[0]) - offset;
const y0 = leftScale(d0);
const y1 = leftScale(d1);
return `m${x},${y1}h${barWidth}v${y0 - y1}h${-barWidth}z`;
} : d => {
const [d0, d1] = d;
const y = leftScale(d.data[0]) - offset;
const x0 = bottomScale(d0);
const x1 = bottomScale(d1);
return `m${x1},${y}v${barWidth}h${x0 - x1}v${-barWidth}z`;
};
select(nodes[2]).selectAll('g').data(series).join('g').attr('class', d => getFillClass(d.key, null, null, getColorClass('fill', colorVariant, colorOption, groupIndex.get(d.key)))).selectAll('path').data(D => D.filter(d => d.data[1].has(D.key)).map(d => (d.group = D.key, d))).join('path').attr('d', buildPath).attr('role', 'graphics-symbol').attr('aria-roledescription', `bar`) // TODO translate
.attr('aria-label', d => {
const {
group,
data: [key, map]
} = d;
return `${dataGroupFormatter(group)}, ${bottomTickFormatterFn(key)}, ${map.get(group)}`;
});
const grid = select(nodes[0]);
if (hasData) {
const quantize = vertical ? scaleQuantize().domain(bottomScale.range()).range(labelSet) : scaleQuantize().domain(leftScale.range().toReversed()).range(Array.from(labelSet).reverse());
if (onClick) {
grid.on('click', event => {
const {
clientX,
clientY
} = event;
const bounds = svg.getBoundingClientRect();
const chartX = clientX - bounds.left;
const chartY = clientY - bounds.top;
const key = quantize(vertical ? chartX : chartY);
onClick(key);
});
} else {
grid.on('click', null);
}
if (hintEnabled) {
let currentKey, currentPosition, hint;
grid.on('mousemove', event => {
const {
clientX,
clientY
} = event;
const bounds = svg.parentNode.getBoundingClientRect();
const chartX = clientX - bounds.left;
const chartY = clientY - bounds.top;
const key = quantize(vertical ? chartX : chartY);
if (key !== currentKey) {
currentKey = key;
if (vertical) {
currentPosition = bottomScale(key);
ruler.setAttribute('x1', currentPosition);
ruler.setAttribute('x2', currentPosition);
} else {
currentPosition = leftScale(key);
ruler.setAttribute('y1', currentPosition);
ruler.setAttribute('y2', 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(vertical, chartX, chartY, bounds.width, bounds.height, currentPosition, offset, width, plotLeft, plotBottom, setHintData, hint, ruler);
}).on('mouseleave', () => {
currentKey = null;
ruler.style.opacity = 0;
setHintData(hintData => ({
...hintData,
visible: false
}));
});
} else {
grid.on('mousemove', null).on('mouseleave', null);
}
} else {
grid.on('click', null).on('mousemove', null).on('mouseleave', null);
}
};
draw(ref.current.getBoundingClientRect());
}, [bottomScaleType, bottomTickFormatter, bottomTitle, colorGroupCount, colorOption, dataGroupFormatter, dataLoading, getBgClass, getFillClass, hintEnabled, hintHeaderFormatter, hintShowTotal, hintTitle, hintTotalLabel, hintValueFormatter, leftScaleType, leftTickFormatter, leftTitle, onClick, state, vertical]);
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__stackBars",
role: "group",
"aria-label": /* TODO translate */`data`
}), /*#__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
})]
});
};
StackedBarChart.propTypes = {
id: PropTypes.string,
className: PropTypes.string,
data: PropTypes.any,
dataOptions: dataPropType,
stack: PropTypes.shape({
order: PropTypes.oneOf(['appearance', 'ascending', 'descending', 'insideOut', 'none', 'reverse']),
offset: PropTypes.oneOf(['expand', 'diverging', 'none', 'silhouette', 'wiggle'])
}),
color: colorPropType,
bottomAxis: axisPropType,
leftAxis: axisPropType,
legend: legendPropType,
hint: hintPropType,
getFillClass: PropTypes.func,
getBgClass: PropTypes.func,
onClick: PropTypes.func,
onLegendClick: PropTypes.func,
onSelectionChange: PropTypes.func
};
export default StackedBarChart;