wix-style-react
Version:
419 lines (379 loc) • 11.7 kB
JavaScript
import React, { useState, useRef, useMemo, useEffect, memo } from 'react';
import PropTypes from 'prop-types';
import { scaleLinear } from 'd3-scale';
import { select } from 'd3-selection';
import { lineRadial, curveCardinalClosed } from 'd3-shape';
import { dataHooks } from './constants';
import { st, classes } from './RadarChart.st.css';
import { stVars as colorsStVars } from '../Foundation/stylable/colors.st.css';
import Tooltip from '../Tooltip';
const BLUE_COLOR = '#3899ec';
const DISABLED_COLOR = '#879cae';
const DOT_RADIUS = 2;
const LINE_DY = 0.35;
const LINE_HEIGHT = 1.4;
const PADDING = 12;
const AXIS_LABEL_DY = 12;
const getXY = (value, index, angle, rScale, shift = 0) => ({
x: (rScale(value) + shift) * Math.cos(angle * index - Math.PI / 2),
y: (rScale(value) + shift) * Math.sin(angle * index - Math.PI / 2),
});
const CirclesSVG = memo(({ scale, rScale }) => {
return scale
.map(({ value }, index) => (
<circle
key={index}
data-hook={dataHooks.radarChartScaleLine}
className={classes.circles}
r={rScale(value)}
cx={0}
cy={0}
/>
))
.reverse();
});
const WebChartSVG = memo(({ disabled, dots }) => {
if (disabled || dots.length === 0) {
return;
}
return (
<defs>
<linearGradient id="gradient" x1="0" x2="1" y1="0" y2="0">
<stop offset="0" stopColor={dots[dots.length - 1].color} />
<stop offset="1" stopColor={dots[0].color} />
</linearGradient>
</defs>
);
});
const OuterLabels = memo(
({
data,
maxScaleValue,
angle,
rScale,
labelDistance,
disabled,
internalHoverIndex,
width,
setInternalHoverIndex,
}) => {
const outerCircleDots = data.map(({ label }, index) => ({
label,
labelPoint: getXY(maxScaleValue, index, angle, rScale, labelDistance),
linePoint: getXY(maxScaleValue, index, angle, rScale),
}));
return outerCircleDots.map(({ label, labelPoint, linePoint }, index) => (
<g key={index} data-hook={dataHooks.radarChartDataItem}>
<line
className={classes.axis}
x1={0}
y1={0}
x2={linePoint.x}
y2={linePoint.y}
/>
<text
className={st(classes.axisLabel, {
hovered: index === internalHoverIndex,
size: width < 360 ? 'small' : 'large',
disabled,
})}
data-hook={`axisName${index}`}
x={labelPoint.x}
y={labelPoint.y}
dy={`${LINE_DY}em`}
textAnchor="middle"
onMouseEnter={() => !disabled && setInternalHoverIndex(index)}
onMouseLeave={() => !disabled && setInternalHoverIndex(null)}
>
{label}
</text>
</g>
));
},
);
const ScaleValues = memo(({ scale, rScale }) => {
const scaleValues = scale.map(({ value, label }) => ({
value,
label,
...getXY(value, 0, 0, rScale),
}));
return scaleValues.map(({ label, y }, index) => (
<text
key={index}
className={classes.scaleLabel}
x={0}
y={y}
dy={AXIS_LABEL_DY}
textAnchor="middle"
>
{label}
</text>
));
});
const DataPoints = memo(
({ dots, disabled, internalHoverIndex, setInternalHoverIndex }) =>
dots.map((dot, index) => (
<g key={index}>
<circle
r={
!disabled && index === internalHoverIndex
? DOT_RADIUS * 2
: DOT_RADIUS
}
cx={dot.x}
cy={dot.y}
fill={disabled ? DISABLED_COLOR : dots[index].color}
/>
<circle
className={st(classes.dataPoint, { disabled })}
r={DOT_RADIUS * 4}
cx={dot.x}
cy={dot.y}
fillOpacity={0}
onMouseEnter={() => !disabled && setInternalHoverIndex(index)}
onMouseLeave={() => !disabled && setInternalHoverIndex(null)}
{...(!disabled && {
focusable: true,
tabIndex: 0,
onFocus: () => !disabled && setInternalHoverIndex(index),
onBlur: () => !disabled && setInternalHoverIndex(null),
})}
/>
</g>
)),
);
const DataPointsTooltips = memo(
({ dots, containerWidth, internalHoverIndex, disabled }) =>
dots.map(({ x, y, tooltipContent, value }, index) => (
<div
key={index}
style={{
position: 'absolute',
left: x + containerWidth / 2,
top: y + containerWidth / 2 - 7,
}}
>
<Tooltip
className={classes.tooltip}
shown={index === internalHoverIndex}
content={tooltipContent ?? value}
disabled={disabled}
>
<div />
</Tooltip>
</div>
)),
);
const WebChartLine = memo(({ disabled, data, angle, rScale }) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current && data.length) {
select(ref.current).selectAll("[data-hook='curvePart']").remove();
const drawCurve = lineRadial()
.curve(curveCardinalClosed)
.radius(d => rScale(d.value))
.angle((_d, i) => i * angle);
const blob = select(ref.current)
.selectAll("[data-hook='curvePart']")
.data([data])
.enter()
.append('g')
.attr('data-hook', 'curvePart');
blob
.append('path')
.attr('d', d => drawCurve(d))
.style('stroke', BLUE_COLOR)
.style('stroke-width', '1px')
.style('stroke', disabled ? DISABLED_COLOR : 'url(#gradient)')
.style('fill', disabled ? DISABLED_COLOR : 'url(#gradient)')
.style('stroke-dasharray', disabled ? 6 : null)
.style('fill-opacity', 0.3);
}
}, [ref, data, disabled, rScale, angle]);
return <g ref={ref} />;
});
const RadarChart = ({
dataHook,
data,
scale,
disabled,
width,
labelDistance,
hoverIndex,
labelWidth,
onDataPointHover,
}) => {
const [internalHoverIndex, setInternalHoverIndex] = useState(hoverIndex);
const containerWidth = useMemo(
() => width + labelDistance * 4 + PADDING * 2,
[width, labelDistance],
);
const maxScaleValue = useMemo(
() => Math.max(...scale.map(({ value }) => value)),
[scale],
);
const rScale = useMemo(
() =>
scaleLinear()
.range([0, width / 2])
.domain([0, maxScaleValue]),
[width, maxScaleValue],
);
const angle = useMemo(() => (Math.PI * 2) / data.length, [data]);
const dots = useMemo(
() =>
data.map(({ value, label, color, tooltipContent }, index) => ({
value,
label,
color: colorsStVars[color] || color || colorsStVars['A1'],
tooltipContent,
...getXY(value, index, angle, rScale),
})),
[data, angle, rScale],
);
useEffect(() => setInternalHoverIndex(hoverIndex), [hoverIndex]);
useEffect(() => {
if (internalHoverIndex === null) {
return;
}
onDataPointHover(data[internalHoverIndex], internalHoverIndex);
}, [internalHoverIndex, onDataPointHover, data]);
useEffect(() => {
data.forEach(({ label }, index) => {
const text = select(containerRef.current).select(
`[data-hook='axisName${index}']`,
);
const words = label.split(/\s+/);
let word;
let line = [];
let lineNumber = 0;
let tspan = text
.text(null)
.append('tspan')
.attr('x', text.attr('x'))
.attr('y', text.attr('y'))
.attr('dy', `${LINE_DY}em`);
while (words.length) {
word = words.shift();
line.push(word);
tspan.text(line.join(' '));
if (
tspan.node &&
tspan.node().getComputedTextLength &&
tspan.node().getComputedTextLength() > labelWidth
) {
line.pop();
tspan.text(line.join(' '));
line = [word];
tspan = text
.append('tspan')
.attr('x', text.attr('x'))
.attr('y', text.attr('y'))
.attr('dy', `${++lineNumber * LINE_HEIGHT + LINE_DY}em`)
.text(word);
}
}
});
});
const containerRef = useRef(null);
return (
<div
data-hook={dataHook}
className={classes.root}
style={{ width: containerWidth, height: containerWidth }}
data-disabled={disabled}
data-hover-index={internalHoverIndex}
>
<svg width={containerWidth} height={containerWidth} ref={containerRef}>
<g
transform={`translate(${containerWidth / 2}, ${containerWidth / 2})`}
>
<WebChartSVG dots={dots} disabled={disabled} />
<CirclesSVG scale={scale} rScale={rScale} />
<OuterLabels
data={data}
maxScaleValue={maxScaleValue}
angle={angle}
rScale={rScale}
labelDistance={labelDistance}
disabled={disabled}
internalHoverIndex={internalHoverIndex}
width={width}
setInternalHoverIndex={setInternalHoverIndex}
/>
<ScaleValues scale={scale} rScale={rScale} />
<WebChartLine
disabled={disabled}
data={data}
angle={angle}
rScale={rScale}
/>
<DataPoints
dots={dots}
disabled={disabled}
internalHoverIndex={internalHoverIndex}
setInternalHoverIndex={setInternalHoverIndex}
/>
</g>
</svg>
<DataPointsTooltips
dots={dots}
disabled={disabled}
containerWidth={containerWidth}
internalHoverIndex={internalHoverIndex}
setInternalHoverIndex={setInternalHoverIndex}
/>
</div>
);
};
RadarChart.displayName = 'RadarChart';
RadarChart.propTypes = {
/** Applies a data-hook HTML attribute that can be used in tests */
dataHook: PropTypes.string,
/** Defines data points that construct spider web-like charts. Available properties for array items:<br />
 - `value` - a number that represents value in chart<br />
 - `label` - a label that represents value description around the chart<br />
 - `color` - data point color.<br />
 - `tooltipContent` - data point tooltip content */
data: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.number,
label: PropTypes.string,
color: PropTypes.string,
tooltipContent: PropTypes.node,
}),
),
/** Defines a number of scale circles in the chart. Available properties for each scale:<br />
 - `value` - a number representing scale<br />
 - `suffix` - a string that represents value meaning (i.e. % or $) */
scale: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.number,
label: PropTypes.string,
}),
),
/** Specifies whether graph is disabled */
disabled: PropTypes.bool,
/** Controls the width of a graph. Minimum width is 150 pixels. */
width: PropTypes.number,
/** Controls label distance from a chart */
labelDistance: PropTypes.number,
/** Defines the index of a data point in hover state */
hoverIndex: PropTypes.number,
/** Defines maximum width of data labels */
labelWidth: PropTypes.number,
/** Defines a callback function which is called every time user hovers over a data point. Includes all data point data as first argument and index as second. */
onDataPointHover: PropTypes.func,
};
RadarChart.defaultProps = {
data: [],
scale: [
{ value: 50, label: '50%' },
{ value: 100, label: '100%' },
],
width: 150,
labelDistance: 50,
labelWidth: 100,
onDataPointHover: () => {},
};
export default RadarChart;