import { useMemo } from 'react'; import { makePlotlyPlotComponent, PlotProps } from './PlotlyPlot'; import { Layout, Shape } from 'plotly.js'; import { NumberOrDateRange } from '../types/general'; import { isArrayOfNumbersOrNulls } from '../types/guards'; import { zip, uniq } from 'lodash'; // add axis range control truncation import { LinePlotData, OrientationAddon, OrientationDefault, AxisTruncationAddon, independentAxisLogScaleAddon, independentAxisLogScaleDefault, DependentAxisLogScaleAddon, DependentAxisLogScaleDefault, } from '../types/plots'; // import truncation util functions import { extendAxisRangeForTruncations } from '../utils/extended-axis-range-truncations'; import { truncationLayoutShapes } from '../utils/truncation-layout-shapes'; import { tickSettings } from '../utils/tick-settings'; import { TimeDelta } from '../../src/types/general'; // is it possible to have this interface extend ScatterPlotProps? // or would we need some abstract layer, w scatter and line both as equal children below it? // add axis range control export interface LinePlotProps extends PlotProps, // truncation OrientationAddon, independentAxisLogScaleAddon, DependentAxisLogScaleAddon, AxisTruncationAddon { /** x-axis range: required for confidence interval - not really */ independentAxisRange?: NumberOrDateRange; /** y-axis range: required for confidence interval */ dependentAxisRange?: NumberOrDateRange; /** x-axis label */ independentAxisLabel?: string; /** y-axis label */ dependentAxisLabel?: string; /** independentValueType */ independentValueType?: | 'string' | 'number' | 'date' | 'longitude' | 'category'; /** dependentValueType */ dependentValueType?: 'string' | 'number' | 'date' | 'longitude' | 'category'; // TO DO // opacity? } const EmptyLinePlotData: LinePlotData = { series: [], }; /** * This component is much like ScatterPlot, but where the X-axis may be binned and * the mode 'markers' is unavailable. */ const LinePlot = makePlotlyPlotComponent('LinePlot', (props: LinePlotProps) => { const { data = EmptyLinePlotData, independentAxisRange, dependentAxisRange, independentAxisLabel, dependentAxisLabel, independentValueType, dependentValueType, // add axis range control truncation orientation = OrientationDefault, axisTruncationConfig, independentAxisLogScale = independentAxisLogScaleDefault, dependentAxisLogScale = DependentAxisLogScaleDefault, ...restProps } = props; // add axis range control truncation axis range const standardIndependentAxisRange = independentAxisRange; const extendedIndependentAxisRange = extendAxisRangeForTruncations( standardIndependentAxisRange, axisTruncationConfig?.independentAxis, independentValueType === 'date' ? 'date' : 'number', true, // addPadding independentAxisLogScale ); // truncation const standardDependentAxisRange = dependentAxisRange; const extendedDependentAxisRange = extendAxisRangeForTruncations( standardDependentAxisRange, axisTruncationConfig?.dependentAxis, dependentValueType === 'date' ? 'date' : 'number', true, // addPadding dependentAxisLogScale ); // make rectangular layout shapes for truncated axis/missing data const truncatedAxisHighlighting: | Partial[] | undefined = useMemo(() => { if (data.series.length > 0) { const filteredTruncationLayoutShapes = truncationLayoutShapes( orientation, standardIndependentAxisRange, // send undefined for independentAxisRange standardDependentAxisRange, extendedIndependentAxisRange, // send undefined for independentAxisRange extendedDependentAxisRange, axisTruncationConfig ); return filteredTruncationLayoutShapes; } else { return []; } }, [ standardDependentAxisRange, extendedDependentAxisRange, orientation, data, axisTruncationConfig, ]); const layout: Partial = { hovermode: 'x', xaxis: { title: independentAxisLabel, // add axis range control truncation range: data.series.length ? [ extendedIndependentAxisRange?.min, extendedIndependentAxisRange?.max, ].map((val) => independentAxisLogScale && val != null ? Math.log10(val as number) : val ) : undefined, zeroline: false, // disable line at 0 value // make plot border mirror: true, // date or number type (from variable.type) type: independentValueType === 'date' ? 'date' : independentAxisLogScale ? 'log' : undefined, tickfont: data.series.length ? {} : { color: 'transparent' }, ...tickSettings( independentAxisLogScale, extendedIndependentAxisRange, independentValueType, data.series.length ), tickformat: independentValueType === 'date' && data.binWidthSlider != null && (data.binWidthSlider.binWidth as TimeDelta).unit === 'year' ? '%Y' : undefined, tickvals: data != null && data.series.length > 0 && independentValueType === 'date' && data.binWidthSlider != null && (data.binWidthSlider.binWidth as TimeDelta).unit === 'year' ? uniq( data.series.flatMap((series) => series.x.map((x) => (x as string).substring(0, 4)) ) ) : undefined, }, yaxis: { title: dependentAxisLabel, // add axis range control range: data.series.length ? [ extendedDependentAxisRange?.min, extendedDependentAxisRange?.max, ].map((val) => dependentAxisLogScale && val != null ? Math.log10(val as number) : val ) : undefined, zeroline: false, // disable line at 0 value // make plot border mirror: true, // date or number type (from variable.type) type: dependentValueType === 'date' ? 'date' : dependentAxisLogScale ? 'log' : undefined, tickfont: data.series.length ? {} : { color: 'transparent' }, ...tickSettings( dependentAxisLogScale, extendedDependentAxisRange, dependentValueType, data.series.length ), }, // axis range control: add truncatedAxisHighlighting for layout.shapes shapes: truncatedAxisHighlighting, }; // Convert upper and lower error bar data from absolute to relative // requires BOTH yErrorBarUpper and yErrorBarLower to be non-empty // and the same length as each other and the series.y array // Only number data is handled presently. // Default Plotly hover info is to show the relative +/- amounts. // Can change this if QA/Outreach don't like it. const plotlyData = useMemo( () => data.series .map((series) => { if (series.yErrorBarLower?.length && series.yErrorBarUpper?.length) { if ( series.yErrorBarLower.length === series.y.length && series.yErrorBarLower.length === series.y.length ) { if ( isArrayOfNumbersOrNulls(series.yErrorBarUpper) && isArrayOfNumbersOrNulls(series.yErrorBarLower) && isArrayOfNumbersOrNulls(series.y) ) { const yvals = series.y; // this is only to help TS return { ...series, error_y: { type: 'data', visible: 'true', array: series.yErrorBarUpper.map((upperValue, index) => { const yval = yvals[index]; return upperValue != null && yval != null ? upperValue - yval : null; }), arrayminus: series.yErrorBarLower.map( (lowerValue, index) => { const yval = yvals[index]; return lowerValue != null && yval != null ? yval - lowerValue : null; } ), }, }; } else { console.log( 'WARNING: date-based error bars not yet implemented for LinePlot' ); return series; } } else { throw new Error( "yErrorBarUpper and/or yErrorBarLower don't have the same number of values as the main data" ); } } else { return series; } }) // now do another map to sort out the mouseover/tooltip text // NOTE: unfortunately the newlines do not render. Newlines can be added to the // 'hovertemplate' but from there I don't think we can access arbitrary values, // such as 'upper', 'lower' and 'n' .map((series) => ({ ...series, hovertemplate: '%{text}', text: zip( series.binLabel ?? [], (series.x ?? []).map(String), (series.y ?? []).map(String), (series.yErrorBarLower ?? []).map(String), (series.yErrorBarUpper ?? []).map(String), series.extraTooltipText ?? [] ).map(([binLabel, x, y, lower, upper, xtra]) => { const CI = lower != null && upper != null ? ` (95% CI: ${lower} - ${upper})` : ''; // use
instead of \n for line break return `x: ${binLabel ?? x}
y: ${y}${CI}
${xtra}`; }), })), [data.series] ); return { data: plotlyData, layout, ...restProps, }; }); export default LinePlot;