UNPKG

wix-style-react

Version:
419 lines (379 loc) • 11.7 kB
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 /> &emsp;- `value` - a number that represents value in chart<br /> &emsp;- `label` - a label that represents value description around the chart<br /> &emsp;- `color` - data point color.<br /> &emsp;- `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 /> &emsp;- `value` - a number representing scale<br /> &emsp;- `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;