import React, { CSSProperties, Ref, Suspense, forwardRef, lazy, useCallback, useImperativeHandle, useMemo, } from 'react'; import { PlotParams } from 'react-plotly.js'; import { legendSpecification } from '../utils/plotly'; import Spinner from '../components/Spinner'; import { PlotRef } from '../types/plots'; import { PlotLegendAddon, PlotSpacingAddon, PlotSpacingDefault, ColorPaletteAddon, ColorPaletteDefault, } from '../types/plots/addOns'; import { LayoutLegendTitle } from '../types/plotly-omissions'; // add d3.select import { select } from 'd3'; // 3rd party toImage function from plotly import { ToImgopts, toImage, DataTitle } from 'plotly.js'; import { uniqueId } from 'lodash'; import { makeSharedPromise } from '../utils/promise-utils'; import NoDataOverlay from '../components/NoDataOverlay'; import { removeHtmlTags } from '../utils/removeHtmlTags'; export interface PlotProps extends ColorPaletteAddon { /** plot data - following web-components' API, not Plotly's */ data?: T; /** Title of plot. */ title?: string; /** Should plot legend be displayed? Default is yes */ displayLegend?: boolean; /** CSS styles for enclosing div * Default is { width: '100%', height: '400px' } */ containerStyles?: CSSProperties; /** class name for enclosing div. Default is web-components-plot */ containerClass?: string; /** Enables mouse-overs and interaction if true. Default false. */ interactive?: boolean; /** show Plotly's mode bar (only shows if interactive == true) */ displayLibraryControls?: boolean; /** show a loading... spinner in the middle of the container div */ showSpinner?: boolean; /** Show an overlay with the words 'No Data' */ showNoDataOverlay?: boolean; /** Options for customizing plot legend layout and appearance. */ legendOptions?: PlotLegendAddon; /** legend title */ legendTitle?: string; /** Options for customizing plot placement. */ spacingOptions?: PlotSpacingAddon; /** maximum number of characters for legend ellipsis */ maxLegendTextLength?: number; /** original independent axis tick labels as data is changed at each component (barplot and boxplot)*/ storedIndependentAxisTickLabel?: string[]; /** list of checked legend items via checkbox input */ checkedLegendItems?: string[]; /** A function to call each time after plotly renders the plot */ onPlotlyRender?: PlotParams['onUpdate']; } const Plot = lazy(() => import('react-plotly.js')); export const DEFAULT_CONTAINER_HEIGHT = '400px'; export const DEFAULT_MAX_LEGEND_TEXT_LENGTH = 20; /** * Wrapper over the `react-plotly.js` `Plot` component * * @param props : PlotProps & PlotParams * * Takes all Plotly props (PlotParams) and our own PlotProps for * controlling global things like spinner, library controls etc * */ function PlotlyPlot( props: Omit, 'data'> & PlotParams, ref: Ref ) { const { title, displayLegend = true, containerStyles = { width: '100%', height: DEFAULT_CONTAINER_HEIGHT }, containerClass = 'web-components-plot', interactive = false, displayLibraryControls, legendOptions, legendTitle, spacingOptions, showSpinner, showNoDataOverlay, // set default max number of characters (20) for legend ellipsis maxLegendTextLength = DEFAULT_MAX_LEGEND_TEXT_LENGTH, // expose data for applying legend ellipsis data, // original independent axis tick labels for tooltip text storedIndependentAxisTickLabel, checkedLegendItems, colorPalette = ColorPaletteDefault, onPlotlyRender, ...plotlyProps } = props; // set max legend title length for ellipsis const maxLegendTitleTextLength = maxLegendTextLength + 5; // set max independent axis title length for ellipsis const maxIndependentAxisTitleTextLength = 60; // set max dependent axis title length for ellipsis const maxDependentAxisTitleTextLength = 50; /** This is used to ensure toImage is called after the plot has been created */ const sharedPlotCreation = useMemo( () => makeSharedPromise(async () => {}), [] ); // config is derived purely from PlotProps props const finalConfig = useMemo( (): PlotParams['config'] => ({ responsive: true, displaylogo: false, displayModeBar: displayLibraryControls ? 'hover' : false, staticPlot: !interactive, }), [displayLibraryControls, interactive] ); const xAxisTitle = plotlyProps?.layout?.xaxis?.title; const yAxisTitle = plotlyProps?.layout?.yaxis?.title; const finalLayout = useMemo( (): PlotParams['layout'] & LayoutLegendTitle => ({ ...plotlyProps.layout, xaxis: { linecolor: 'black', ...plotlyProps.layout.xaxis, fixedrange: true, linewidth: 1, title: axisTitleEllipsis(xAxisTitle, maxIndependentAxisTitleTextLength), }, yaxis: { linecolor: 'black', ...plotlyProps.layout.yaxis, fixedrange: true, linewidth: 1, title: axisTitleEllipsis(yAxisTitle, maxDependentAxisTitleTextLength), }, showlegend: displayLegend ?? true, margin: { t: spacingOptions?.marginTop, r: spacingOptions?.marginRight, b: spacingOptions?.marginBottom, l: spacingOptions?.marginLeft, pad: spacingOptions?.padding || 0, // axes don't join up if >0 }, legend: { ...plotlyProps.layout.legend, title: { // add ellipsis for legendTitle text: (legendTitle || '').length > maxLegendTitleTextLength ? (legendTitle || '').substring(0, maxLegendTitleTextLength) + '...' : legendTitle, }, ...(legendOptions ? legendSpecification(legendOptions) : {}), }, autosize: true, // responds properly to enclosing div resizing (not to be confused with config.responsive) colorway: colorPalette, }), [ plotlyProps.layout, spacingOptions, legendOptions, displayLegend, legendTitle, title, colorPalette, ] ); /** * legend ellipsis with tooltip */ const storedLegendList = useMemo(() => { if (data != null) { return data.map((data) => { return data.name ?? ''; }); } else { return []; } }, [data]); // keep independent axis title for tooltip text const originalIndependentAxisTitle = useMemo(() => { if (typeof xAxisTitle === 'object' && xAxisTitle != null) return xAxisTitle.text; else return xAxisTitle; }, [xAxisTitle]); const originalDependentAxisTitle = useMemo(() => { if (typeof yAxisTitle === 'object' && yAxisTitle != null) return yAxisTitle.text; else return yAxisTitle; }, [yAxisTitle]); // ellipsis with tooltip for legend, legend title, and independent axis tick labels const onRender = useCallback( (figure, graphDiv: Readonly) => { onPlotlyRender && onPlotlyRender(figure, graphDiv); // legend tooltip // remove pre-existing title to avoid duplicates select(graphDiv) .select('g.legend') .selectAll('g.traces') .selectAll('title') .remove(); // add tooltip using svg title select(graphDiv) .select('g.legend') .selectAll('g.traces') .append('svg:title') .text((d: any) => { return removeHtmlTags(storedLegendList[d[0].trace.index]); }); // legend title tooltip // remove pre-existing svg:title under legendtitletext to avoid duplicates select(graphDiv) .select('g.legend g.scrollbox text.legendtitletext') .selectAll('title') .remove(); // add tooltip: no need to show tooltip if legent title is not that long if ( legendTitle != null && legendTitle.length > maxLegendTitleTextLength ) { select(graphDiv) .select('g.legend g.scrollbox text.legendtitletext') .append('svg:title') .text(removeHtmlTags(legendTitle)); } // independent axis tick label for barplot and boxplot if ( storedIndependentAxisTickLabel != null && storedIndependentAxisTickLabel.length > 0 ) { // remove pre-existing xtick.title select(graphDiv) .select( '.plot-container svg.main-svg g.cartesianlayer g.xaxislayer-above' ) .selectAll('g.xtick') .selectAll('title') .remove(); // add xtick.title (tooltip) select(graphDiv) .select( '.plot-container svg.main-svg g.cartesianlayer g.xaxislayer-above' ) .selectAll('g.xtick') // need this attribute for tooltip of axis tick label! .attr('pointer-events', 'all') .append('svg:title') .text((d, i) => { return storedIndependentAxisTickLabel != null ? removeHtmlTags(storedIndependentAxisTickLabel[i] as string) : ''; }); } // handling independent axis title with ellipsis & tooltip if ( originalIndependentAxisTitle != null && (originalIndependentAxisTitle as string).length > maxIndependentAxisTitleTextLength ) { // remove duplicate svg:title select(graphDiv) .select('.plot-container svg.main-svg g.infolayer g.g-xtitle') .selectAll('title') .remove(); // add tooltip select(graphDiv) .select( '.plot-container svg.main-svg g.infolayer g.g-xtitle text.xtitle' ) // need this attribute for tooltip of dependent axis title! .attr('pointer-events', 'all') .append('svg:title') .text( removeHtmlTags(originalIndependentAxisTitle as string) as string ); } // handling dependent axis title with ellipsis & tooltip if ( originalDependentAxisTitle != null && (originalDependentAxisTitle as string).length > maxDependentAxisTitleTextLength ) { // remove duplicate svg:title select(graphDiv) .select('.plot-container svg.main-svg g.infolayer g.g-ytitle') .selectAll('title') .remove(); // add tooltip select(graphDiv) .select( '.plot-container svg.main-svg g.infolayer g.g-ytitle text.ytitle' ) // need this attribute for tooltip of dependent axis title! .attr('pointer-events', 'all') .append('svg:title') .text(removeHtmlTags(originalDependentAxisTitle as string) as string); } }, [ onPlotlyRender, storedLegendList, legendTitle, maxLegendTitleTextLength, storedIndependentAxisTickLabel, originalDependentAxisTitle, ] ); const onInitialized = useCallback( (figure, graphDiv: Readonly) => { onRender(figure, graphDiv); sharedPlotCreation.run(); }, [onRender, sharedPlotCreation.run] ); const finalData = useMemo(() => { return data.map((d: any) => ({ ...d, // set to 'legendonly' as 'true' changes plot colors and condition for no custom legend case visible: checkedLegendItems != null && d.name != null ? checkedLegendItems?.includes(d.name) ? true : 'legendonly' : undefined, // ellipsis for legend item name: (d.name || '').length > maxLegendTextLength ? (d.name || '').substring(0, maxLegendTextLength) + '...' : d.name, })); }, [data, checkedLegendItems]); const plotId = useMemo(() => uniqueId('plotly_plot_div_'), []); useImperativeHandle( ref, () => ({ // Set the ref's toImage function that will be called in web-eda toImage: async (imageOpts: ToImgopts) => { try { await sharedPlotCreation.promise; // Call the 3rd party function that actually creates the image return await toImage(plotId, imageOpts); } catch (error) { console.error('Could not create image for plot:', error); } return ''; }, }), [plotId] ); const marginTop = spacingOptions?.marginTop ?? PlotSpacingDefault.marginTop; return (
{showNoDataOverlay && ( )} {title && (
{title}
)} {showSpinner && }
); } const PlotlyPlotWithRef = forwardRef(PlotlyPlot); /** * Factory function to create a component that delegates to `PlotlyPlotWithRef`. * This encapsulates ref forwarding. See {@link PlotRef}. */ export function makePlotlyPlotComponent( displayName: string, transformProps: (props: S) => Omit, 'data'> & PlotParams ) { function PlotlyPlotComponent(props: S, ref: Ref) { const xformProps = transformProps(props); return ; } PlotlyPlotComponent.displayName = displayName; return forwardRef(PlotlyPlotComponent); } // A function for implementing the ellipsis of the axis title function axisTitleEllipsis( axisTitle: string | Partial | undefined, axisTitleTextLength: number ) { return typeof axisTitle === 'object' && axisTitle != null ? // Mosaic case ((axisTitle.text as string) || '').length > axisTitleTextLength ? { text: ((axisTitle.text as string) || '').substring( 0, axisTitleTextLength ) + '...', standoff: axisTitle.standoff, } : axisTitle : // general case ((axisTitle as string) || '').length > axisTitleTextLength ? ((axisTitle as string) || '').substring(0, axisTitleTextLength) + '...' : axisTitle; } export default PlotlyPlotWithRef;