grommet/grommet

View on GitHub
src/js/components/DataChart/DataChart.js

Summary

Maintainability
F
1 wk
Test Coverage
import React, { forwardRef, useContext, useMemo, useState } from 'react';
import { ThemeContext } from 'styled-components';
import { Box } from '../Box';
import { Chart, calcs, calcBounds } from '../Chart';
import { Grid } from '../Grid';
import { Stack } from '../Stack';
import { Text } from '../Text';
import { parseMetricToNum } from '../../utils';
import { Detail } from './Detail';
import { Legend } from './Legend';
import { XAxis } from './XAxis';
import { YAxis } from './YAxis';
import { XGuide } from './XGuide';
import { YGuide } from './YGuide';
import {
  createDateFormat,
  halfPad,
  heightYGranularity,
  largestSize,
  maximum,
  minimum,
  points,
} from './utils';
import { DataChartPropTypes } from './propTypes';

const stackedChartType = {
  areas: 'area',
  bars: 'bar',
  lines: 'line',
};

// DataChart takes a generic data array of objects plus as few properties
// as possible, and creates a Stack of Charts with x and y axes, a legend,
// and interactive detail.
// Much of the code here-in involves the "few properties" aspect where we
// normalize and automatically handle whatever the caller didn't specify.

const DataChart = forwardRef(
  (
    {
      a11yTitle,
      axis: axisProp = true,
      bounds: boundsProp = 'align',
      chart: chartProp,
      data = [],
      detail,
      direction = 'vertical',
      gap = 'small',
      guide: guideProp,
      legend,
      offset,
      placeholder,
      pad: padProp,
      series: seriesProp,
      size,
      ...rest
    },
    ref,
  ) => {
    const theme = useContext(ThemeContext) || defaultProps.theme;

    // legend interaction, if any
    const [activeProperty, setActiveProperty] = useState();

    const horizontal = useMemo(() => direction === 'horizontal', [direction]);

    // normalize seriesProp to an array of objects, one per property
    const series = useMemo(() => {
      if (Array.isArray(seriesProp))
        return seriesProp
          .filter((s) => s.property || typeof s === 'string')
          .map((s) => (typeof s === 'string' ? { property: s } : s));
      if (typeof seriesProp === 'string') return [{ property: seriesProp }];
      if (seriesProp) return [seriesProp];
      return [];
    }, [seriesProp]);

    const getPropertySeries = (prop) =>
      series.find(({ property }) => prop === property);

    // Normalize chartProp to an array of objects.
    // Each chart has one or more properties associated with it.
    // A stacked bar or area chart has an array of properties.
    // A point chart can have x, y, thickness, and color each driven
    // by a separate property.
    const charts = useMemo(() => {
      if (!chartProp) {
        if (series.length === 1)
          return series
            .filter((s) => s.property)
            .map((s) => ({ property: s.property }));
        // if we have more than one property, we'll use the first for
        // the x-axis and we'll plot the rest
        return series.slice(1).map((s) => ({ property: s.property }));
      }
      if (Array.isArray(chartProp))
        return chartProp
          .map((c) => (typeof c === 'string' ? { property: c } : c))
          .filter(({ property }) => property);
      if (typeof chartProp === 'string') return [{ property: chartProp }];
      if (chartProp) return [chartProp];
      return [];
    }, [chartProp, series]);

    // map the series property values into their own arrays
    const seriesValues = useMemo(() => {
      const result = {};
      series.forEach(({ property }) => {
        result[property] = data.map((d) => d[property]);
      });
      return result;
    }, [data, series]);

    // Setup the values property for each chart.
    // The index into 'charts' can be used to index into 'chartValues'.
    const chartValues = useMemo(
      () =>
        charts.map(({ opacity, property, type }) => {
          if (property) {
            if (Array.isArray(property)) {
              // A range chart or a stacked bar or area chart has multiple
              // properties.
              // In this case, this returns an array of values,
              // one per property.
              if (stackedChartType[type]) {
                // Further down, where we render, each property is rendered
                // using a separate Chart component and the values are stacked
                // such that they line up appropriately.
                const totals = [];
                return property.map((cp) => {
                  // handle object or string
                  const aProperty = cp.property || cp;
                  const values = seriesValues[aProperty];
                  if (!values) return undefined; // property name isn't valid
                  return values.map((v, i) => {
                    const base = totals[i] || 0;
                    totals[i] = base + v;
                    if (type === 'lines') return [i, base + v];
                    return [i, base, base + v];
                  });
                });
              }
              return data.map((_, index) => [
                index,
                ...property.map((p) =>
                  seriesValues[p] ? seriesValues[p][index] : data[index][p],
                ),
              ]);
            }
            if (typeof property === 'object') {
              // When 'property' is an array, the keys of this array indicate
              // which property drives which part of the rendered Chart.
              const { color, thickness, x, y, y2 } = property;
              return seriesValues[x].map((value, index) => {
                const aValue = { value: [value] };
                aValue.value.push(seriesValues[y][index]);
                if (y2) aValue.value.push(seriesValues[y2][index]);
                if (thickness) {
                  const t =
                    seriesValues[thickness.property || thickness][index];
                  aValue.thickness = thickness.transform
                    ? thickness.transform(t)
                    : t;
                }
                if (color) {
                  const c = seriesValues[color.property || color][index];
                  aValue.color = color.transform ? color.transform(c) : c;
                }
                if (opacity) aValue.opacity = opacity;
                return aValue;
              });
            }
            return seriesValues[property];
          }
          return undefined;
        }),
      [charts, data, seriesValues],
    );

    // map granularities to work well with the number of data points we have
    const granularities = useMemo(() => {
      let medium;
      // determine a good medium granularity that will align well with the
      // length of the data
      const steps = data.length - 1;
      // special case property driven point charts
      if (charts[0] && typeof charts[0].property === 'object') medium = 3;
      else if (steps < 4) medium = data.length;
      else if (steps === 4) medium = 3;
      else if (steps % 4 === 0) medium = 5;
      else if (steps % 3 === 0) medium = 4;
      else if (steps % 2 === 0) medium = 3;
      else medium = 2;
      const granularity0 = {
        coarse: Math.min(data.length, 2),
        fine: data.length,
        medium,
      };
      const granularity1 = {
        ...(heightYGranularity[(size && size.height) || 'small'] || {
          fine: 5,
          medium: 3,
        }),
        coarse: 2,
      };
      return horizontal
        ? { x: granularity1, y: granularity0 }
        : { x: granularity0, y: granularity1 };
    }, [charts, data.length, horizontal, size]);

    // normalize axis to objects, convert granularity to a number
    const axis = useMemo(() => {
      if (!axisProp) return undefined;
      const result = {};
      if (axisProp === true || axisProp.x === true) result.x = {};
      if (axisProp === true || axisProp.y === true) result.y = {};

      if (!result.x && axisProp.x)
        result.x =
          typeof axisProp.x === 'string'
            ? { property: axisProp.x }
            : { ...axisProp.x };
      if (!result.y && axisProp.y)
        result.y =
          typeof axisProp.y === 'string'
            ? { property: axisProp.y }
            : { ...axisProp.y };

      if (result.x) {
        if (!result.x.property) {
          // see if we have a point chart that has an x property
          if (horizontal) {
            if (charts[0])
              result.x.property = charts[0].property.x || charts[0].property;
          } else if (data && data[0]) {
            if (data[0].date) result.x.property = 'date';
            else if (data[0].time) result.x.property = 'time';
          }
        }
        if (!result.x.granularity) result.x.granularity = 'coarse';
      }

      if (result.y) {
        if (!result.y.property) {
          // see if we have a point chart that has an y property
          if (horizontal) {
            if (data && data[0]) {
              if (data[0].date) result.y.property = 'date';
              else if (data[0].time) result.y.property = 'time';
            }
          } else if (charts[0])
            result.y.property = charts[0].property.y || charts[0].property;
        }
        if (!result.y.granularity) result.y.granularity = 'coarse';
      }

      // calculate number of entries based on granularity
      if (result.x) {
        const { granularity = 'coarse' } = result.x;
        result.x.count = granularities.x[granularity];
      }
      if (result.y) {
        const { granularity = 'coarse' } = result.y;
        result.y.count = granularities.y[granularity];
      }

      return result;
    }, [axisProp, data, charts, granularities, horizontal]);

    // calculate axis, bounds, and thickness for each chart
    const chartProps = useMemo(() => {
      const steps = {};
      const coarseness = horizontal ? [5, undefined] : [undefined, 5];
      if (axis && axis.x) {
        const { granularity = 'coarse' } = axis.x;
        steps.x = granularities.x[granularity] - 1;
      } else steps.x = horizontal ? 1 : data.length - 1;

      if (axis && axis.y) {
        const { granularity = 'coarse' } = axis.y;
        steps.y = granularities.y[granularity] - 1;
      } else steps.y = horizontal ? data.length - 1 : 1;

      let chartBounds = chartValues.map((_, index) => {
        const { type } = charts[index];
        if (stackedChartType[type]) {
          // merge values for bars, areas, and lines cases
          let mergedValues = chartValues[index]?.[0]?.slice(0) || [];
          chartValues[index]
            .slice(1) // skip first index as that is the x value
            .filter((values) => values) // property name isn't valid
            .forEach((values) => {
              mergedValues = mergedValues.map((__, i) =>
                type === 'lines'
                  ? [
                      i,
                      Math.min(mergedValues[i][1], values[i][1]),
                      Math.max(mergedValues[i][1], values[i][1]),
                    ]
                  : [
                      i,
                      Math.min(mergedValues[i][1], values[i][1]),
                      Math.max(mergedValues[i][2], values[i][2]),
                    ],
              );
            });
          return calcBounds(mergedValues, { coarseness, direction, steps });
        }
        // if this is a data driven x chart, set coarseness for x
        return calcBounds(chartValues[index], {
          coarseness: charts[index].property.x ? { x: 5, y: 5 } : coarseness,
          direction,
          steps,
        });
      });

      if (boundsProp === 'align' && chartBounds.length) {
        const alignedBounds = { x: {}, y: {} };
        chartBounds.forEach((bounds) => {
          alignedBounds.x.min = minimum(alignedBounds.x.min, bounds.x.min);
          alignedBounds.x.max = maximum(alignedBounds.x.max, bounds.x.max);
          alignedBounds.y.min = minimum(alignedBounds.y.min, bounds.y.min);
          alignedBounds.y.max = maximum(alignedBounds.y.max, bounds.y.max);
        });
        chartBounds = chartBounds.map(() => alignedBounds);
      }

      return chartValues.map((values, index) => {
        const { thickness, type } = charts[index];
        const calcValues = stackedChartType[type] ? values[0] : values;
        return calcs(calcValues, {
          bounds: chartBounds[index],
          direction,
          steps,
          thickness,
        });
      });
    }, [
      axis,
      boundsProp,
      charts,
      chartValues,
      data,
      direction,
      granularities,
      horizontal,
    ]);

    // normalize how we style data properties for use by Legend and Detail
    const seriesStyles = useMemo(() => {
      const result = {};
      // start from what we were explicitly given
      charts.forEach((chart, index) => {
        const { thickness: calcThickness } = chartProps[index];

        if (
          typeof chart.property === 'object' &&
          !Array.isArray(chart.property)
        ) {
          // data driven point chart
          Object.keys(chart.property).forEach((aspect) => {
            const prop = chart.property[aspect];
            if (!result[prop.property || prop])
              result[prop.property || prop] = { aspect };
          });
        } else {
          const setPropertyStyle = ({ property, ...styles }) => {
            // keep what we've got, use what is new
            result[property] = {
              ...styles,
              ...(result[property] || {}),
            };
            // unless the new style is has no opacity
            if (!styles.opacity) delete result[property].opacity;
            if (styles.type === 'point') result[property].point = false;
            if (calcThickness && !result[property].thickness)
              result[property].thickness = calcThickness;
          };

          if (Array.isArray(chart.property))
            chart.property.forEach((prop) => {
              if (typeof prop === 'string')
                setPropertyStyle({ ...chart, property: prop });
              else if (typeof prop === 'object')
                setPropertyStyle({ ...chart, ...prop });
            });
          else if (typeof chart === 'object') setPropertyStyle(chart);
          else if (typeof chart === 'string')
            setPropertyStyle({ property: chart });
        }
      });

      // set color for any non-aspect properties we don't have one for yet
      let colorIndex = 0;
      let pointIndex = 0;
      Object.keys(result).forEach((key) => {
        const seriesStyle = result[key];
        if (!seriesStyle.aspect && !seriesStyle.color) {
          seriesStyle.color = `graph-${colorIndex}`;
          colorIndex += 1;
        }
        // set opacity if it isn't set and this isn't the active property
        if (activeProperty !== undefined && activeProperty !== key) {
          seriesStyle.opacity = 'medium';
        }
        if (seriesStyle.point === false) {
          seriesStyle.point = points[pointIndex];
          pointIndex += 1;
        }
      });

      return result;
    }, [activeProperty, charts, chartProps]);

    // normalize guide
    const guide = useMemo(() => {
      if (!guideProp) return undefined;
      let result;
      if (guideProp === true) {
        result = { x: {}, y: {} };
      } else {
        result = {};
        if (guideProp.x) result.x = { ...guideProp.x };
        if (guideProp.y) result.y = { ...guideProp.y };
      }
      // set counts
      if (result.x) {
        // if no granularity and axis, align count with axis
        if (!result.x.granularity && axis && axis.x)
          result.x.count = axis.x.count;
        if (!result.x.count)
          result.x.count = granularities.x[result.x.granularity || 'coarse'];
      }
      if (result.y) {
        // if no granularity and axis, align count with axis
        if (!result.y.granularity && axis && axis.y)
          result.y.count = axis.y.count;
        if (!result.y.count)
          result.y.count = granularities.y[result.y.granularity || 'coarse'];
      }
      return result;
    }, [axis, granularities, guideProp]);

    // set the pad to half the thickness, based on the chart types
    // except when using offset, then add even more horizontal pad
    const pad = useMemo(() => {
      if (padProp !== undefined) return padProp;
      let pad0;
      let pad1;

      charts.forEach(({ type }, index) => {
        const { thickness } = chartProps[index];
        pad0 = largestSize(pad0, halfPad[thickness]);
        if (type && type !== 'bar')
          pad1 = largestSize(pad1, halfPad[thickness]);
      });
      return horizontal
        ? { horizontal: pad1, vertical: pad0 }
        : { horizontal: pad0, vertical: pad1 };
    }, [chartProps, charts, horizontal, padProp]);

    // calculate the thickness in pixels of each chart
    const thicknesses = useMemo(
      () =>
        offset
          ? charts.map((_, index) => {
              const { thickness } = chartProps[index];
              return parseMetricToNum(
                theme.global.edgeSize[thickness] || thickness,
              );
            })
          : undefined,
      [charts, chartProps, offset, theme],
    );

    // normalize any offset gap
    const offsetGap = useMemo(
      () =>
        (offset?.gap &&
          parseMetricToNum(theme.global.edgeSize[offset.gap] || offset.gap)) ||
        0,
      [offset, theme],
    );

    // calculate the offset for each chart, which is a sum of the thicknesses
    // any offset gaps that preceded it
    const offsets = useMemo(() => {
      if (offset) {
        return thicknesses.map((t, i) =>
          thicknesses.slice(0, i).reduce((a, b) => a + b + offsetGap, 0),
        );
      }
      return undefined;
    }, [offset, offsetGap, thicknesses]);

    // Calculate the total pad we should add to the end of each chart.
    // We do this to shrink the width of each chart so we can shift them
    // via `translate` and have them take up the right amount of width.
    const offsetPad = useMemo(
      () =>
        offsets
          ? `${
              offsets[offsets.length - 1] + thicknesses[thicknesses.length - 1]
            }px`
          : undefined,
      [offsets, thicknesses],
    );

    // The thickness of the segments. We need to convert to numbers
    // to be able to compare across charts where some might be using T-shirt
    // labels and others might be pixel values.
    const segmentThickness = useMemo(() => {
      let result = 0;
      charts.forEach((_, index) => {
        const { thickness } = chartProps[index];
        result = Math.max(
          result,
          parseMetricToNum(theme.global.edgeSize[thickness] || thickness),
        );
      });
      return `${result}px`;
    }, [charts, chartProps, theme]);

    const dateFormats = useMemo(() => {
      const result = {};
      const full = axis && axis.x && axis.x.granularity === 'coarse';
      series.forEach(({ property, render }) => {
        if (
          !render &&
          data.length > 1 &&
          typeof data[0][property] === 'string'
        ) {
          result[property] = createDateFormat(
            data[0][property],
            data[data.length - 1][property],
            full,
          );
        }
      });
      return result;
    }, [axis, data, series]);

    const renderValue = (serie = {}, axisValue, y) => {
      const { prefix, property, render, suffix } = serie;
      let value = axisValue;
      if (value !== undefined) {
        if (!property || (!horizontal && y) || (horizontal && !y)) {
          if (render) return render(value);
        } else {
          const datum = data[axisValue];
          value = datum[property];
          if (render) return render(value, datum, property);
        }
        if (property) {
          const dateFormat = dateFormats[property];
          if (dateFormat) return dateFormat(new Date(value));
          if (prefix) value = `${prefix}${value}`;
          if (suffix) value = `${value}${suffix}`;
        }
      }
      return value;
    };

    // TODO: revisit how x/y axis are hooked up to charts and series

    const xAxisElement =
      axis && axis.x && (chartProps.length || boundsProp?.x) ? (
        <XAxis
          axis={axis}
          values={
            boundsProp?.x?.slice(0) ||
            (Array.isArray(chartProps[0]) ? chartProps[0][0] : chartProps[0])
              .axis.x
          }
          pad={!horizontal && offsetPad ? { ...pad, end: offsetPad } : pad}
          renderValue={renderValue}
          serie={axis.x.property && getPropertySeries(axis.x.property)}
          style={
            offsetPad
              ? {
                  transform: `translate(${
                    offsets[Math.floor(offsets.length / 2)]
                  }px, 0px)`,
                }
              : {}
          }
          thickness={horizontal ? undefined : segmentThickness}
          theme={theme}
        />
      ) : null;

    const yAxisElement =
      axis && axis.y && (chartProps.length || boundsProp?.y) ? (
        <YAxis
          axis={axis}
          values={
            boundsProp?.y?.slice(0).reverse() ||
            (Array.isArray(chartProps[0]) ? chartProps[0][0] : chartProps[0])
              .axis.y
          }
          pad={horizontal && offsetPad ? { ...pad, bottom: offsetPad } : pad}
          renderValue={renderValue}
          serie={axis.y.property && getPropertySeries(axis.y.property)}
          thickness={horizontal ? segmentThickness : undefined}
          theme={theme}
        />
      ) : null;

    const stackFill = useMemo(() => {
      if (
        size === 'fill' ||
        (size && size.width === 'fill' && size.height === 'fill')
      )
        return true;
      if (size && size.width === 'fill') return 'horizontal';
      if (size && size.height === 'fill') return 'vertical';
      return undefined;
    }, [size]);

    const guidingChild = useMemo(() => {
      let result = 0;
      if (guide && guide.x) result += 1;
      if (guide && guide.y) result += 1;
      return result;
    }, [guide]);

    const stackElement = (
      <Stack gridArea="charts" guidingChild={guidingChild} fill={stackFill}>
        {guide && guide.x && (
          <XGuide guide={guide} pad={pad} thickness={horizontal} />
        )}
        {guide && guide.y && (
          <YGuide guide={guide} pad={pad} thickness={!horizontal} />
        )}
        {charts.map(({ property: prop, type, x, y, ...chartRest }, i) => {
          // When we offset, we increase the padding on the end for all charts
          // by the same amount and we shift each successive chart to the
          // right by an offset for that chart. The last chart's right side
          // will end up aligning with where the charts would have been
          // had we not padded their ends.
          const chartPad = offsetPad ? { ...pad, end: offsetPad } : pad;
          const offsetProps = offsetPad
            ? { style: { transform: `translate(${offsets[i]}px, 0px)` } }
            : {};

          if (stackedChartType[type]) {
            // reverse to ensure area Charts are stacked in the right order
            return prop
              .map((cProp, j) => {
                const pProp = cProp.property || cProp;
                const { property, ...propRest } =
                  typeof cProp === 'object' ? cProp : {};
                return (
                  <Chart
                    // eslint-disable-next-line react/no-array-index-key
                    key={j}
                    // when property name isn't valid, send empty array
                    values={chartValues[i][j] || []}
                    overflow
                    direction={direction}
                    {...seriesStyles[pProp]}
                    {...chartProps[i]}
                    {...chartRest}
                    {...propRest}
                    {...offsetProps}
                    type={stackedChartType[type] || type}
                    size={size}
                    pad={chartPad}
                  />
                );
              })
              .reverse();
          }

          return (
            <Chart
              // eslint-disable-next-line react/no-array-index-key
              key={i}
              values={chartValues[i]}
              overflow
              direction={direction}
              {...seriesStyles[prop]}
              {...chartProps[i]}
              {...chartRest}
              {...offsetProps}
              type={type}
              size={size}
              pad={chartPad}
            />
          );
        })}
        {placeholder &&
          ((typeof placeholder === 'string' && (
            <Box
              fill="vertical"
              align="center"
              justify="center"
              background={{ color: 'background-front', opacity: 'strong' }}
              margin={pad}
            >
              <Text color="text-weak">{placeholder}</Text>
            </Box>
          )) ||
            placeholder)}
        {detail && (
          <Detail
            activeProperty={activeProperty}
            axis={axis}
            data={data}
            horizontal={horizontal}
            pad={pad}
            series={series}
            seriesStyles={seriesStyles}
            renderValue={renderValue}
            thickness={segmentThickness}
          />
        )}
      </Stack>
    );

    const legendElement = legend ? (
      <Legend
        series={series}
        seriesStyles={seriesStyles}
        activeProperty={activeProperty}
        setActiveProperty={setActiveProperty}
      />
    ) : null;

    let content = (
      <Grid
        ref={ref}
        aria-label={a11yTitle}
        fill={stackFill}
        columns={[
          'auto',
          stackFill === true || stackFill === 'horizontal' ? 'flex' : 'auto',
        ]}
        rows={[
          stackFill === true || stackFill === 'vertical' ? 'flex' : 'auto',
          'auto',
        ]}
        areas={[
          { name: 'yAxis', start: [0, 0], end: [0, 0] },
          { name: 'xAxis', start: [1, 1], end: [1, 1] },
          { name: 'charts', start: [1, 0], end: [1, 0] },
        ]}
        gap={gap}
        {...rest}
      >
        {xAxisElement}
        {yAxisElement}
        {stackElement}
      </Grid>
    );

    if (legendElement) {
      content = (
        <Box align="start">
          {content}
          {legendElement}
        </Box>
      );
    }

    return content;
  },
);

DataChart.displayName = 'DataChart';
DataChart.propTypes = DataChartPropTypes;

export { DataChart };