packages/charts/src/components/chart.tsx (186 lines of code) (raw):
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import classNames from 'classnames';
import type { CSSProperties, ReactNode } from 'react';
import React, { createRef } from 'react';
import { Provider } from 'react-redux';
import type { Unsubscribe, Store } from 'redux';
import type { OptionalKeys } from 'utility-types';
import { v4 as uuidv4 } from 'uuid';
import { ChartBackground } from './chart_background';
import { ChartContainer } from './chart_container';
import { ChartResizer } from './chart_resizer';
import { ChartStatus } from './chart_status';
import { Legend } from './legend/legend';
import { getElementZIndex } from './portal/utils';
import { chartTypeSelectors } from '../chart_types/chart_type_selectors';
import { Colors } from '../common/colors';
import type { LegendPositionConfig, PointerEvent } from '../specs';
import { SpecsParser } from '../specs/specs_parser';
import { updateChartTitles, updateParentDimensions } from '../state/actions/chart_settings';
import { onExternalPointerEvent } from '../state/actions/events';
import { onComputedZIndex } from '../state/actions/z_index';
import { createChartStore, type GlobalChartState } from '../state/chart_state';
import { getChartContainerUpdateStateSelector } from '../state/selectors/chart_container_updates';
import { getInternalChartStateSelector, chartSelectorsRegistry } from '../state/selectors/get_internal_chart_state';
import { getInternalIsInitializedSelector, InitStatus } from '../state/selectors/get_internal_is_intialized';
import type { ChartSize } from '../utils/chart_size';
import { getChartSize, getFixedChartSize } from '../utils/chart_size';
import { LayoutDirection } from '../utils/common';
import { deepEqual } from '../utils/fast_deep_equal';
import { LIGHT_THEME } from '../utils/themes/light_theme';
/** @public */
export interface ChartProps {
/**
* The type of rendered
* @defaultValue `canvas`
*/
renderer?: 'svg' | 'canvas';
size?: ChartSize;
className?: string;
id?: string;
title?: string;
description?: string;
children?: ReactNode;
}
interface ChartState {
legendDirection: LegendPositionConfig['direction'];
paddingLeft: number;
paddingRight: number;
displayTitles: boolean;
}
/** @public */
export class Chart extends React.Component<ChartProps, ChartState> {
static defaultProps: Pick<ChartProps, OptionalKeys<ChartProps>> = {
renderer: 'canvas',
};
private unsubscribeToStore: Unsubscribe;
private chartStore: Store<GlobalChartState>;
private chartContainerRef: React.RefObject<HTMLDivElement>;
private chartStageRef: React.RefObject<HTMLCanvasElement>;
constructor(props: ChartProps) {
super(props);
this.chartContainerRef = createRef();
this.chartStageRef = createRef();
// set up the chart specific selector overrides
chartSelectorsRegistry.setChartSelectors(chartTypeSelectors);
// set up the redux store
const id = props.id ?? uuidv4();
this.chartStore = createChartStore(id, this.props.title, this.props.description);
this.state = {
legendDirection: LayoutDirection.Vertical,
paddingLeft: LIGHT_THEME.chartMargins.left,
paddingRight: LIGHT_THEME.chartMargins.right,
displayTitles: true,
};
this.unsubscribeToStore = this.chartStore.subscribe(() => {
const state = this.chartStore.getState();
const internalChartState = getInternalChartStateSelector(state);
if (getInternalIsInitializedSelector(state) !== InitStatus.Initialized) {
return;
}
const newState = getChartContainerUpdateStateSelector(state);
if (!deepEqual(this.state, newState)) this.setState(newState);
if (internalChartState) {
internalChartState.eventCallbacks(state);
}
});
}
componentDidMount() {
if (this.chartContainerRef.current) {
const zIndex = getElementZIndex(this.chartContainerRef.current, document.body);
this.chartStore.dispatch(onComputedZIndex(zIndex));
}
}
componentWillUnmount() {
this.unsubscribeToStore();
}
componentDidUpdate({ title, description, size }: Readonly<ChartProps>) {
if (title !== this.props.title || description !== this.props.description) {
this.chartStore.dispatch(updateChartTitles({ title: this.props.title, description: this.props.description }));
}
const prevChartSize = getChartSize(size);
const newChartSize = getFixedChartSize(this.props.size);
// if the size is specified in pixels then update directly the store
if (newChartSize && (newChartSize.width !== prevChartSize.width || newChartSize.height !== prevChartSize.height)) {
this.chartStore.dispatch(updateParentDimensions({ ...newChartSize, top: 0, left: 0 }));
}
}
getPNGSnapshot(
// eslint-disable-next-line unicorn/no-object-as-default-parameter
options = {
backgroundColor: Colors.Transparent.keyword,
},
): {
blobOrDataUrl: any;
browser: 'IE11' | 'other';
} | null {
if (!this.chartStageRef.current) {
return null;
}
const canvas = this.chartStageRef.current;
const backgroundCanvas = document.createElement('canvas');
backgroundCanvas.width = canvas.width;
backgroundCanvas.height = canvas.height;
const bgCtx = backgroundCanvas.getContext('2d');
if (!bgCtx) {
return null;
}
bgCtx.fillStyle = options.backgroundColor;
bgCtx.fillRect(0, 0, canvas.width, canvas.height);
bgCtx.drawImage(canvas, 0, 0);
return {
blobOrDataUrl: backgroundCanvas.toDataURL(),
browser: 'other',
};
}
getChartContainerRef = () => this.chartContainerRef;
dispatchExternalPointerEvent(event: PointerEvent) {
this.chartStore.dispatch(onExternalPointerEvent(event));
}
render() {
const { size, className } = this.props;
const containerSizeStyle = getChartSize(size);
const chartContentClassNames = classNames('echChartContent', className, {
'echChartContent--column': this.state.legendDirection === LayoutDirection.Horizontal,
});
return (
<Provider store={this.chartStore}>
<div className="echChart" style={containerSizeStyle}>
<Titles
displayTitles={this.state.displayTitles}
title={this.props.title}
description={this.props.description}
paddingLeft={this.state.paddingLeft}
paddingRight={this.state.paddingRight}
/>
<div className={chartContentClassNames}>
<ChartBackground />
<ChartStatus />
<ChartResizer />
<Legend />
<SpecsParser>{this.props.children}</SpecsParser>
<div className="echContainer" ref={this.chartContainerRef}>
<ChartContainer getChartContainerRef={this.getChartContainerRef} forwardStageRef={this.chartStageRef} />
</div>
</div>
</div>
</Provider>
);
}
}
function Titles({
displayTitles,
title,
description,
paddingLeft,
paddingRight,
}: Pick<ChartProps, 'title' | 'description'> & Pick<ChartState, 'displayTitles' | 'paddingLeft' | 'paddingRight'>) {
if (!displayTitles || (!title && !description)) return null;
const titleDescStyle: CSSProperties = {
paddingLeft,
paddingRight,
};
return (
<div className="echChart__titles">
{title && (
<h3 className="echChartTitle" style={titleDescStyle}>
{title}
</h3>
)}
{description && (
<h4 className="echChartDescription" style={titleDescStyle}>
{description}
</h4>
)}
</div>
);
}