diff --git a/packages/data-viz/src/core/BarChart/__storybook__/constants.ts b/packages/data-viz/src/core/BarChart/__storybook__/constants.ts new file mode 100644 index 000000000..1f3121fe2 --- /dev/null +++ b/packages/data-viz/src/core/BarChart/__storybook__/constants.ts @@ -0,0 +1,22 @@ +export const BARCHART_DATA: number[][] = []; + +for (let i = 1; i < 20; i++) { + BARCHART_DATA.push([i, Math.round(Math.random() * 100)]); +} + +export const BARCHART_TOOLTIP_OPTIONS = [ + { show: false }, + { + enterable: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formatter: function (param: any) { + return param.data + ? [ + `X-Axis: ${param.data[0]}
`, + `Y-Axis: ${param.data[1]} ${param.marker}`, + ].join("") + : []; + }, + show: true, + }, +]; diff --git a/packages/data-viz/src/core/BarChart/__storybook__/index.stories.tsx b/packages/data-viz/src/core/BarChart/__storybook__/index.stories.tsx new file mode 100644 index 000000000..c8db83051 --- /dev/null +++ b/packages/data-viz/src/core/BarChart/__storybook__/index.stories.tsx @@ -0,0 +1,45 @@ +import { Meta } from "@storybook/react"; +import { BARCHART_TOOLTIP_OPTIONS } from "./constants"; +import { BADGE } from "@geometricpanda/storybook-addon-badges"; +import { BarChart } from "./stories/default"; + +export default { + argTypes: { + echartsRendererMode: { + control: { + labels: ["Canvas", "SVG"], + type: "select", + }, + options: ["canvas", "svg"], + }, + tooltip: { + control: { + labels: ["No tooltip", "Show Tooltip"], + type: "select", + }, + mapping: BARCHART_TOOLTIP_OPTIONS, + options: Object.keys(BARCHART_TOOLTIP_OPTIONS), + }, + }, + component: BarChart, + parameters: { + badges: [BADGE.BETA], + chromatic: { + disableSnapshot: true, + }, + snapshot: { + skip: true, + }, + }, + title: "Data Viz/BarChart [beta]", +} as Meta; + +// Default + +export const Default = { + args: { + echartsRendererMode: "svg", + tooltip: BARCHART_TOOLTIP_OPTIONS[1], + }, + parameters: {}, +}; diff --git a/packages/data-viz/src/core/BarChart/__storybook__/stories/default.tsx b/packages/data-viz/src/core/BarChart/__storybook__/stories/default.tsx new file mode 100644 index 000000000..59e214a51 --- /dev/null +++ b/packages/data-viz/src/core/BarChart/__storybook__/stories/default.tsx @@ -0,0 +1,44 @@ +import { Args } from "@storybook/react"; +import RawBarChart from "src/core/BarChart"; +import { BARCHART_DATA } from "../constants"; + +export const BarChart = (props: Args): JSX.Element => { + const { tooltip, ...rest } = props; + + return ( + <> + + + ); +}; diff --git a/packages/data-viz/src/core/BarChart/hooks/useUpdateChart.ts b/packages/data-viz/src/core/BarChart/hooks/useUpdateChart.ts new file mode 100644 index 000000000..8003704a9 --- /dev/null +++ b/packages/data-viz/src/core/BarChart/hooks/useUpdateChart.ts @@ -0,0 +1,76 @@ +import { throttle } from "lodash"; +import { useEffect, useMemo } from "react"; +import { CreateChartOptionsProps, createChartOptions } from "./utils"; + +const UPDATE_THROTTLE_MS = 1 * 100; + +export interface UpdateChartProps extends CreateChartOptionsProps { + chart: echarts.ECharts | null; +} + +export function useUpdateChart({ + chart, + width, + height, + options, + onEvents, +}: UpdateChartProps): void { + const throttledUpdateChart = useMemo(() => { + return throttle( + () => { + if (!chart) { + return; + } + + // (thuang): resize() needs to be called before setOption() to prevent + // TypeError: Cannot read properties of undefined (reading 'shouldBePainted') + chart.resize(); + + const chartOptions = createChartOptions({ + height, + options, + width, + }); + + chart.setOption(chartOptions, { + replaceMerge: ["tooltip"], + }); + + /** + * We need to remove old event listeners and bind new ones to + * make sure that the event listeners are updated when the props change. + */ + if (onEvents) { + for (const eventName in onEvents) { + if ( + Object.prototype.hasOwnProperty.call(onEvents, eventName) && + typeof eventName === "string" && + typeof onEvents[eventName] === "function" + ) { + // Remove old event listener + chart.off(eventName); + + // Add new event listener + chart.on(eventName, (event) => { + onEvents[eventName](event, chart); + }); + } + } + } + }, + UPDATE_THROTTLE_MS, + // (thuang): Trailing guarantees that the last call to the function will + // be executed + { trailing: true } + ); + }, [chart, width, height, options, onEvents]); + + useEffect(() => { + return () => throttledUpdateChart.cancel(); + }, [throttledUpdateChart]); + + // Update the charts + useEffect(() => { + throttledUpdateChart(); + }, [chart, throttledUpdateChart, width, height, options, onEvents]); +} diff --git a/packages/data-viz/src/core/BarChart/hooks/utils.ts b/packages/data-viz/src/core/BarChart/hooks/utils.ts new file mode 100644 index 000000000..ec18e6164 --- /dev/null +++ b/packages/data-viz/src/core/BarChart/hooks/utils.ts @@ -0,0 +1,75 @@ +import { ECharts, EChartsOption } from "echarts"; + +export interface CreateChartOptionsProps { + /** + * The width of the chart in pixels + */ + width: number; + /** + * The height of the chart in pixels + */ + height: number; + /** + * https://echarts.apache.org/en/option.html#grid + */ + grid?: + | EChartsOption["grid"] + | ((defaultOption: EChartsOption["grid"]) => EChartsOption["grid"]); + /** + * The options object to be passed to echarts.setOption() + * https://echarts.apache.org/en/option.html + */ + options?: EChartsOption; + /** + * Event listeners for the chart + * https://echarts.apache.org/en/api.html#events + */ + onEvents?: Record void>; +} + +export function createChartOptions( + props: CreateChartOptionsProps +): EChartsOption { + const { grid: gridProp, options } = props; + + const { defaultGrid } = generateDefaultValues(props); + + const customGrid = + typeof gridProp === "function" ? gridProp(defaultGrid) : gridProp; + + const { series: optionsSeries, ...optionsRest } = options || {}; + + return { + animation: false, + grid: customGrid || defaultGrid, + series: [ + Object.assign( + optionsSeries + ? Array.isArray(optionsSeries) + ? optionsSeries[0] + : optionsSeries + : [], + { type: "bar" } + ), + ] as EChartsOption["series"], + ...optionsRest, + }; +} + +function generateDefaultValues(props: CreateChartOptionsProps) { + const { height, width } = props; + + const defaultGrid = { + containLabel: true, + height: `${height}px`, + left: 0, + top: 0, + // (atarashansky): this is the key change to align x and y axis + // labels to fixed spaces + width: `${width}px`, + }; + + return { + defaultGrid, + }; +} diff --git a/packages/data-viz/src/core/BarChart/index.tsx b/packages/data-viz/src/core/BarChart/index.tsx new file mode 100644 index 000000000..4aa17f395 --- /dev/null +++ b/packages/data-viz/src/core/BarChart/index.tsx @@ -0,0 +1,187 @@ +import { ECharts, init } from "echarts"; +import { + ForwardedRef, + HTMLAttributes, + forwardRef, + memo, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { EMPTY_OBJECT } from "src/common/utils"; +import { useUpdateChart } from "./hooks/useUpdateChart"; +import { CreateChartOptionsProps } from "./hooks/utils"; +import { ChartContainer } from "./style"; + +export interface BarChartProps + extends HTMLAttributes, + CreateChartOptionsProps { + echartsRendererMode?: "svg" | "canvas"; +} + +const HeatmapChart = forwardRef( + ( + props: BarChartProps, + ref: ForwardedRef + // eslint-disable-next-line sonarjs/cognitive-complexity + ): JSX.Element => { + const { + width, + height, + echartsRendererMode = "svg", + onEvents, + grid, + options, + ...rest + } = props; + + // Validate width and height + if (!width || !height) { + throw Error("BarChart must have width and height bigger than Zero!"); + } + + // Ref for the chart container + const innerRef = useRef(null); + + /** + * (thuang): We need both a state and a ref to store the chart instance, so + * some hooks can opt out of re-rendering when the chart instance changes. + */ + const [chart, setChart] = useState(null); + const chartRef = useRef(chart); + + /** + * (thuang): Use this ref to store the onEvents prop to prevent + * unnecessary re-renders when the onEvents prop changes. + * NOTE: This implies that `onEvents` prop changes alone from the parent + * won't re-render the chart + */ + const onEventsRef = useRef(onEvents); + + /** + * (thuang): Use this function to dispose the chart instance for both + * the state and the ref. This is to prevent memory leaks. + */ + const disposeChart = useCallback(() => { + chartRef.current?.dispose(); + chartRef.current = null; + setChart(null); + }, []); + + // Function to initialize the chart + const initChart = useCallback(() => { + const onEventsCurrent = onEventsRef.current; + const { current } = innerRef; + + if ( + !current || + chartRef.current || + // (thuang): echart's `init()` will throw error if the container has 0 width or height + current?.getAttribute("height") === "0" || + current?.getAttribute("width") === "0" + ) { + return; + } + + // Initialize ECharts instance + const rawChart = init(current, EMPTY_OBJECT, { + renderer: echartsRendererMode, + useDirtyRect: true, + }); + + // Bind events if provided + if (onEventsCurrent) { + bindEvents(rawChart, onEventsCurrent); + } + + setChart(rawChart); + chartRef.current = rawChart; + + // Cleanup function + return () => { + disposeChart(); + + // Unbind events if provided + if (onEventsCurrent) { + for (const eventName in onEventsCurrent) { + if ( + Object.prototype.hasOwnProperty.call( + onEventsCurrent, + eventName + ) && + typeof eventName === "string" && + typeof onEventsCurrent[eventName] === "function" + ) { + rawChart.off(eventName, onEventsCurrent[eventName]); + } + } + } + }; + }, [echartsRendererMode, disposeChart]); + + // Initialize charts on component mount + useEffect(() => { + disposeChart(); + initChart(); + }, [initChart, disposeChart]); + + // Hook to update chart data and options + useUpdateChart({ + chart, + grid, + height, + onEvents, + options, + width, + }); + + // Render the chart container + return ( + + ); + + // Function to bind events to the ECharts instance + function bindEvents( + instance: ECharts, + events: CreateChartOptionsProps["onEvents"] + ) { + function innerBindEvent( + eventName: string, + func: (event: unknown, chart: ECharts) => void + ) { + // Ignore invalid event configurations + if (typeof eventName === "string" && typeof func === "function") { + // Bind event + instance.on(eventName, (param) => { + func(param, instance); + }); + } + } + + // Loop through events and bind them + for (const eventName in events) { + if (Object.prototype.hasOwnProperty.call(events, eventName)) { + innerBindEvent(eventName, events[eventName]); + } + } + } + + // Function to handle the ref of the chart container + function handleRef(element: HTMLDivElement | null) { + innerRef.current = element; + + if (!ref) return; + + // (thuang): `ref` from `forwardRef` can be a function or a ref object + if (typeof ref === "function") { + ref(element); + } else { + ref.current = element; + } + } + } +); + +export default memo(HeatmapChart); diff --git a/packages/data-viz/src/core/BarChart/style.ts b/packages/data-viz/src/core/BarChart/style.ts new file mode 100644 index 000000000..b7a368852 --- /dev/null +++ b/packages/data-viz/src/core/BarChart/style.ts @@ -0,0 +1,18 @@ +import styled from "@emotion/styled"; + +export const ChartContainer = styled("div")` + ${getWidthAndHeight} +`; + +function getWidthAndHeight({ + width, + height, +}: { + width: number; + height: number; +}) { + return ` + width: ${width}px; + height: ${height}px; + `; +} diff --git a/packages/data-viz/src/core/HistogramChart/__storybook__/constants.ts b/packages/data-viz/src/core/HistogramChart/__storybook__/constants.ts new file mode 100644 index 000000000..3d8eeca04 --- /dev/null +++ b/packages/data-viz/src/core/HistogramChart/__storybook__/constants.ts @@ -0,0 +1,22 @@ +export const HISTOGRAM_DATA: number[][] = []; + +for (let i = 1; i < 20; i++) { + HISTOGRAM_DATA.push([i, Math.round(Math.random() * 100)]); +} + +export const HISTOGRAM_TOOLTIP_OPTIONS = [ + { show: false }, + { + enterable: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formatter: function (param: any) { + return param.data + ? [ + `X-Axis: ${param.data[0]}
`, + `Y-Axis: ${param.data[1]} ${param.marker}`, + ].join("") + : []; + }, + show: true, + }, +]; diff --git a/packages/data-viz/src/core/HistogramChart/__storybook__/index.stories.tsx b/packages/data-viz/src/core/HistogramChart/__storybook__/index.stories.tsx new file mode 100644 index 000000000..8286b99f5 --- /dev/null +++ b/packages/data-viz/src/core/HistogramChart/__storybook__/index.stories.tsx @@ -0,0 +1,45 @@ +import { Meta } from "@storybook/react"; +import { HISTOGRAM_TOOLTIP_OPTIONS } from "./constants"; +import { BADGE } from "@geometricpanda/storybook-addon-badges"; +import { HistogramChart } from "./stories/default"; + +export default { + argTypes: { + echartsRendererMode: { + control: { + labels: ["Canvas", "SVG"], + type: "select", + }, + options: ["canvas", "svg"], + }, + tooltip: { + control: { + labels: ["No tooltip", "Show Tooltip"], + type: "select", + }, + mapping: HISTOGRAM_TOOLTIP_OPTIONS, + options: Object.keys(HISTOGRAM_TOOLTIP_OPTIONS), + }, + }, + component: HistogramChart, + parameters: { + badges: [BADGE.BETA], + chromatic: { + disableSnapshot: true, + }, + snapshot: { + skip: true, + }, + }, + title: "Data Viz/HistogramChart [beta]", +} as Meta; + +// Default + +export const Default = { + args: { + echartsRendererMode: "svg", + tooltip: HISTOGRAM_TOOLTIP_OPTIONS[1], + }, + parameters: {}, +}; diff --git a/packages/data-viz/src/core/HistogramChart/__storybook__/stories/default.tsx b/packages/data-viz/src/core/HistogramChart/__storybook__/stories/default.tsx new file mode 100644 index 000000000..ce9933dfc --- /dev/null +++ b/packages/data-viz/src/core/HistogramChart/__storybook__/stories/default.tsx @@ -0,0 +1,45 @@ +import { Args } from "@storybook/react"; +import RawHistogramChart from "src/core/HistogramChart"; +import { HISTOGRAM_DATA } from "../constants"; + +export const HistogramChart = (props: Args): JSX.Element => { + const { tooltip, ...rest } = props; + + return ( + <> + + + ); +}; diff --git a/packages/data-viz/src/core/HistogramChart/hooks/useUpdateChart.ts b/packages/data-viz/src/core/HistogramChart/hooks/useUpdateChart.ts new file mode 100644 index 000000000..8003704a9 --- /dev/null +++ b/packages/data-viz/src/core/HistogramChart/hooks/useUpdateChart.ts @@ -0,0 +1,76 @@ +import { throttle } from "lodash"; +import { useEffect, useMemo } from "react"; +import { CreateChartOptionsProps, createChartOptions } from "./utils"; + +const UPDATE_THROTTLE_MS = 1 * 100; + +export interface UpdateChartProps extends CreateChartOptionsProps { + chart: echarts.ECharts | null; +} + +export function useUpdateChart({ + chart, + width, + height, + options, + onEvents, +}: UpdateChartProps): void { + const throttledUpdateChart = useMemo(() => { + return throttle( + () => { + if (!chart) { + return; + } + + // (thuang): resize() needs to be called before setOption() to prevent + // TypeError: Cannot read properties of undefined (reading 'shouldBePainted') + chart.resize(); + + const chartOptions = createChartOptions({ + height, + options, + width, + }); + + chart.setOption(chartOptions, { + replaceMerge: ["tooltip"], + }); + + /** + * We need to remove old event listeners and bind new ones to + * make sure that the event listeners are updated when the props change. + */ + if (onEvents) { + for (const eventName in onEvents) { + if ( + Object.prototype.hasOwnProperty.call(onEvents, eventName) && + typeof eventName === "string" && + typeof onEvents[eventName] === "function" + ) { + // Remove old event listener + chart.off(eventName); + + // Add new event listener + chart.on(eventName, (event) => { + onEvents[eventName](event, chart); + }); + } + } + } + }, + UPDATE_THROTTLE_MS, + // (thuang): Trailing guarantees that the last call to the function will + // be executed + { trailing: true } + ); + }, [chart, width, height, options, onEvents]); + + useEffect(() => { + return () => throttledUpdateChart.cancel(); + }, [throttledUpdateChart]); + + // Update the charts + useEffect(() => { + throttledUpdateChart(); + }, [chart, throttledUpdateChart, width, height, options, onEvents]); +} diff --git a/packages/data-viz/src/core/HistogramChart/hooks/utils.ts b/packages/data-viz/src/core/HistogramChart/hooks/utils.ts new file mode 100644 index 000000000..ec18e6164 --- /dev/null +++ b/packages/data-viz/src/core/HistogramChart/hooks/utils.ts @@ -0,0 +1,75 @@ +import { ECharts, EChartsOption } from "echarts"; + +export interface CreateChartOptionsProps { + /** + * The width of the chart in pixels + */ + width: number; + /** + * The height of the chart in pixels + */ + height: number; + /** + * https://echarts.apache.org/en/option.html#grid + */ + grid?: + | EChartsOption["grid"] + | ((defaultOption: EChartsOption["grid"]) => EChartsOption["grid"]); + /** + * The options object to be passed to echarts.setOption() + * https://echarts.apache.org/en/option.html + */ + options?: EChartsOption; + /** + * Event listeners for the chart + * https://echarts.apache.org/en/api.html#events + */ + onEvents?: Record void>; +} + +export function createChartOptions( + props: CreateChartOptionsProps +): EChartsOption { + const { grid: gridProp, options } = props; + + const { defaultGrid } = generateDefaultValues(props); + + const customGrid = + typeof gridProp === "function" ? gridProp(defaultGrid) : gridProp; + + const { series: optionsSeries, ...optionsRest } = options || {}; + + return { + animation: false, + grid: customGrid || defaultGrid, + series: [ + Object.assign( + optionsSeries + ? Array.isArray(optionsSeries) + ? optionsSeries[0] + : optionsSeries + : [], + { type: "bar" } + ), + ] as EChartsOption["series"], + ...optionsRest, + }; +} + +function generateDefaultValues(props: CreateChartOptionsProps) { + const { height, width } = props; + + const defaultGrid = { + containLabel: true, + height: `${height}px`, + left: 0, + top: 0, + // (atarashansky): this is the key change to align x and y axis + // labels to fixed spaces + width: `${width}px`, + }; + + return { + defaultGrid, + }; +} diff --git a/packages/data-viz/src/core/HistogramChart/index.tsx b/packages/data-viz/src/core/HistogramChart/index.tsx new file mode 100644 index 000000000..22954412f --- /dev/null +++ b/packages/data-viz/src/core/HistogramChart/index.tsx @@ -0,0 +1,187 @@ +import { ECharts, init } from "echarts"; +import { + ForwardedRef, + HTMLAttributes, + forwardRef, + memo, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { EMPTY_OBJECT } from "src/common/utils"; +import { useUpdateChart } from "./hooks/useUpdateChart"; +import { CreateChartOptionsProps } from "./hooks/utils"; +import { ChartContainer } from "./style"; + +export interface HistogramChartProps + extends HTMLAttributes, + CreateChartOptionsProps { + echartsRendererMode?: "svg" | "canvas"; +} + +const HeatmapChart = forwardRef( + ( + props: HistogramChartProps, + ref: ForwardedRef + // eslint-disable-next-line sonarjs/cognitive-complexity + ): JSX.Element => { + const { + width, + height, + echartsRendererMode = "svg", + onEvents, + grid, + options, + ...rest + } = props; + + // Validate width and height + if (!width || !height) { + throw Error("Histogram must have width and height bigger than Zero!"); + } + + // Ref for the chart container + const innerRef = useRef(null); + + /** + * (thuang): We need both a state and a ref to store the chart instance, so + * some hooks can opt out of re-rendering when the chart instance changes. + */ + const [chart, setChart] = useState(null); + const chartRef = useRef(chart); + + /** + * (thuang): Use this ref to store the onEvents prop to prevent + * unnecessary re-renders when the onEvents prop changes. + * NOTE: This implies that `onEvents` prop changes alone from the parent + * won't re-render the chart + */ + const onEventsRef = useRef(onEvents); + + /** + * (thuang): Use this function to dispose the chart instance for both + * the state and the ref. This is to prevent memory leaks. + */ + const disposeChart = useCallback(() => { + chartRef.current?.dispose(); + chartRef.current = null; + setChart(null); + }, []); + + // Function to initialize the chart + const initChart = useCallback(() => { + const onEventsCurrent = onEventsRef.current; + const { current } = innerRef; + + if ( + !current || + chartRef.current || + // (thuang): echart's `init()` will throw error if the container has 0 width or height + current?.getAttribute("height") === "0" || + current?.getAttribute("width") === "0" + ) { + return; + } + + // Initialize ECharts instance + const rawChart = init(current, EMPTY_OBJECT, { + renderer: echartsRendererMode, + useDirtyRect: true, + }); + + // Bind events if provided + if (onEventsCurrent) { + bindEvents(rawChart, onEventsCurrent); + } + + setChart(rawChart); + chartRef.current = rawChart; + + // Cleanup function + return () => { + disposeChart(); + + // Unbind events if provided + if (onEventsCurrent) { + for (const eventName in onEventsCurrent) { + if ( + Object.prototype.hasOwnProperty.call( + onEventsCurrent, + eventName + ) && + typeof eventName === "string" && + typeof onEventsCurrent[eventName] === "function" + ) { + rawChart.off(eventName, onEventsCurrent[eventName]); + } + } + } + }; + }, [echartsRendererMode, disposeChart]); + + // Initialize charts on component mount + useEffect(() => { + disposeChart(); + initChart(); + }, [initChart, disposeChart]); + + // Hook to update chart data and options + useUpdateChart({ + chart, + grid, + height, + onEvents, + options, + width, + }); + + // Render the chart container + return ( + + ); + + // Function to bind events to the ECharts instance + function bindEvents( + instance: ECharts, + events: CreateChartOptionsProps["onEvents"] + ) { + function innerBindEvent( + eventName: string, + func: (event: unknown, chart: ECharts) => void + ) { + // Ignore invalid event configurations + if (typeof eventName === "string" && typeof func === "function") { + // Bind event + instance.on(eventName, (param) => { + func(param, instance); + }); + } + } + + // Loop through events and bind them + for (const eventName in events) { + if (Object.prototype.hasOwnProperty.call(events, eventName)) { + innerBindEvent(eventName, events[eventName]); + } + } + } + + // Function to handle the ref of the chart container + function handleRef(element: HTMLDivElement | null) { + innerRef.current = element; + + if (!ref) return; + + // (thuang): `ref` from `forwardRef` can be a function or a ref object + if (typeof ref === "function") { + ref(element); + } else { + ref.current = element; + } + } + } +); + +export default memo(HeatmapChart); diff --git a/packages/data-viz/src/core/HistogramChart/style.ts b/packages/data-viz/src/core/HistogramChart/style.ts new file mode 100644 index 000000000..b7a368852 --- /dev/null +++ b/packages/data-viz/src/core/HistogramChart/style.ts @@ -0,0 +1,18 @@ +import styled from "@emotion/styled"; + +export const ChartContainer = styled("div")` + ${getWidthAndHeight} +`; + +function getWidthAndHeight({ + width, + height, +}: { + width: number; + height: number; +}) { + return ` + width: ${width}px; + height: ${height}px; + `; +} diff --git a/packages/data-viz/src/core/ScatterPlot/__storybook__/constants.ts b/packages/data-viz/src/core/ScatterPlot/__storybook__/constants.ts new file mode 100644 index 000000000..9ab9261ed --- /dev/null +++ b/packages/data-viz/src/core/ScatterPlot/__storybook__/constants.ts @@ -0,0 +1,41 @@ +export const SCATTERPLOT_SIZE = 100; + +export const SCATTERPLOT_ITEM_SIZE = 20; + +export const SCATTERPLOT_NUMBERS = Array.from(Array(SCATTERPLOT_SIZE).keys()); + +export const SCATTERPLOT_DATA: { x: number; y: number }[] = []; + +for (let i = 0; i < SCATTERPLOT_SIZE; i++) { + SCATTERPLOT_DATA.push({ + x: Math.round(Math.random() * 100), + y: Math.round(Math.random() * 100), + }); +} + +export const SCATTERPLOT_ENCODE = { x: "x", y: "y" }; + +export const SCATTERPLOT_ITEM_STYLE = { + borderColor: "white", + borderType: "solid", + borderWidth: 1, + color: "gray", + opacity: 1, +}; + +export const SCATTERPLOT_TOOLTIP_OPTIONS = [ + { show: false }, + { + enterable: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + formatter: function (param: any) { + return param.data + ? [ + `X-Axis: ${param.data.x}
`, + `Y-Axis: ${param.data.y}`, + ].join("") + : []; + }, + show: true, + }, +]; diff --git a/packages/data-viz/src/core/ScatterPlot/__storybook__/index.stories.tsx b/packages/data-viz/src/core/ScatterPlot/__storybook__/index.stories.tsx new file mode 100644 index 000000000..4e36fbdc9 --- /dev/null +++ b/packages/data-viz/src/core/ScatterPlot/__storybook__/index.stories.tsx @@ -0,0 +1,53 @@ +import { Meta } from "@storybook/react"; +import { BADGE } from "@geometricpanda/storybook-addon-badges"; +import { ScatterPlot } from "./stories/default"; +import { SCATTERPLOT_TOOLTIP_OPTIONS } from "./constants"; + +export default { + argTypes: { + echartsRendererMode: { + control: { + labels: ["Canvas", "SVG"], + type: "select", + }, + options: ["canvas", "svg"], + }, + symbol: { + control: { + labels: ["Circle", "Rectangle", "Round Rectangle"], + type: "select", + }, + options: ["circle", "rect", "roundRect"], + }, + tooltip: { + control: { + labels: ["No tooltip", "Show Tooltip"], + type: "select", + }, + mapping: SCATTERPLOT_TOOLTIP_OPTIONS, + options: Object.keys(SCATTERPLOT_TOOLTIP_OPTIONS), + }, + }, + component: ScatterPlot, + parameters: { + badges: [BADGE.BETA], + chromatic: { + disableSnapshot: true, + }, + snapshot: { + skip: true, + }, + }, + title: "Data Viz/ScatterPlot [beta]", +} as Meta; + +// Default + +export const Default = { + args: { + echartsRendererMode: "svg", + symbol: "circle", + tooltip: SCATTERPLOT_TOOLTIP_OPTIONS[1], + }, + parameters: {}, +}; diff --git a/packages/data-viz/src/core/ScatterPlot/__storybook__/stories/default.tsx b/packages/data-viz/src/core/ScatterPlot/__storybook__/stories/default.tsx new file mode 100644 index 000000000..f33d61ad1 --- /dev/null +++ b/packages/data-viz/src/core/ScatterPlot/__storybook__/stories/default.tsx @@ -0,0 +1,56 @@ +import { Args } from "@storybook/react"; +import { + SCATTERPLOT_DATA, + SCATTERPLOT_ENCODE, + SCATTERPLOT_ITEM_STYLE, + SCATTERPLOT_NUMBERS, +} from "../constants"; +import RawScatterPlot from "src/core/ScatterPlot"; +import { ScatterSeriesOption } from "echarts"; + +export const ScatterPlot = (props: Args): JSX.Element => { + const { symbol, tooltip, ...rest } = props; + + return ( + <> + + + ); +}; diff --git a/packages/data-viz/src/core/ScatterPlot/hooks/useUpdateChart.ts b/packages/data-viz/src/core/ScatterPlot/hooks/useUpdateChart.ts new file mode 100644 index 000000000..f77b1981f --- /dev/null +++ b/packages/data-viz/src/core/ScatterPlot/hooks/useUpdateChart.ts @@ -0,0 +1,125 @@ +import { throttle } from "lodash"; +import { useEffect, useMemo } from "react"; +import { CreateChartOptionsProps, createChartOptions } from "./utils"; + +const UPDATE_THROTTLE_MS = 1 * 100; + +export interface UpdateChartProps extends CreateChartOptionsProps { + chart: echarts.ECharts | null; +} + +export function useUpdateChart({ + chart, + data, + emphasis, + xAxisData, + yAxisData, + width, + height, + encode, + itemStyle, + symbol, + symbolSize, + grid, + options, + onEvents, +}: UpdateChartProps): void { + const throttledUpdateChart = useMemo(() => { + return throttle( + () => { + if (!chart || !data || !xAxisData || !yAxisData) { + return; + } + + // (thuang): resize() needs to be called before setOption() to prevent + // TypeError: Cannot read properties of undefined (reading 'shouldBePainted') + chart.resize(); + + const chartOptions = createChartOptions({ + data, + emphasis, + encode, + grid, + height, + itemStyle, + options, + symbol, + symbolSize, + width, + xAxisData, + yAxisData, + }); + + chart.setOption(chartOptions, { + replaceMerge: ["dataZoom", "tooltip"], + }); + + /** + * We need to remove old event listeners and bind new ones to + * make sure that the event listeners are updated when the props change. + */ + if (onEvents) { + for (const eventName in onEvents) { + if ( + Object.prototype.hasOwnProperty.call(onEvents, eventName) && + typeof eventName === "string" && + typeof onEvents[eventName] === "function" + ) { + // Remove old event listener + chart.off(eventName); + + // Add new event listener + chart.on(eventName, (event) => { + onEvents[eventName](event, chart); + }); + } + } + } + }, + UPDATE_THROTTLE_MS, + // (thuang): Trailing guarantees that the last call to the function will + // be executed + { trailing: true } + ); + }, [ + chart, + data, + emphasis, + xAxisData, + yAxisData, + width, + height, + encode, + itemStyle, + symbol, + symbolSize, + grid, + options, + onEvents, + ]); + + useEffect(() => { + return () => throttledUpdateChart.cancel(); + }, [throttledUpdateChart]); + + // Update the charts + useEffect(() => { + throttledUpdateChart(); + }, [ + chart, + data, + emphasis, + xAxisData, + yAxisData, + throttledUpdateChart, + width, + height, + encode, + itemStyle, + symbol, + symbolSize, + grid, + options, + onEvents, + ]); +} diff --git a/packages/data-viz/src/core/ScatterPlot/hooks/utils.ts b/packages/data-viz/src/core/ScatterPlot/hooks/utils.ts new file mode 100644 index 000000000..5f1fdd8b9 --- /dev/null +++ b/packages/data-viz/src/core/ScatterPlot/hooks/utils.ts @@ -0,0 +1,232 @@ +import { + AxisPointerComponentOption, + DatasetComponentOption, + ECharts, + EChartsOption, + ScatterSeriesOption, +} from "echarts"; + +const DEFAULT_ITEM_STYLE = { + color() { + return "rgb(0, 0, 0)"; + }, +}; + +export interface CreateChartOptionsProps { + /** + * The data array to be visualized + * The data point object shape can be whatever you like, but it must be consistent with the `encode` option + * For example, if the data point shape is: + * { + * geneIndex: 0, + * cellTypeIndex: 0, + * percentage: 0.5 + * } + * and you want geneIndex to be encoded to x axis and cellTypeIndex to be encoded to y axis, then make sure your encode option is: + * encode: { + * x: 'geneIndex', + * y: 'cellTypeIndex' + * } + */ + data: DatasetComponentOption["source"]; + /** + * Customize the style of each cell item when mouse hovers on it, such as color, border, opacity, etc. + * https://echarts.apache.org/en/option.html#series-scatter.emphasis + */ + emphasis?: ScatterSeriesOption["emphasis"]; + /** + * The data for the x axis + * For example: + * [{ value: "gene1", textStyle: { color: "red" } }, "gene2", "gene3"] + */ + xAxisData: CategoryAxisData; + /** + * The data for the y axis + * For example: + * [{ value: "cellType1", textStyle: { color: "red" } }, "cellType2", "cellType3"] + */ + yAxisData: CategoryAxisData; + /** + * The width of the chart in pixels + */ + width: number; + /** + * The height of the chart in pixels + */ + height: number; + /** + * Provide a mapping of data key to x/y axis encoding + * For example, if the data is: + * { + * geneIndex: 0, + * cellTypeIndex: 0, + * percentage: 0.5 + * } + * and we want to encode `geneIndex` to x axis and `cellTypeIndex` to y axis, then + * encode: { + * x: 'geneIndex', + * y: 'cellTypeIndex' + * } + * https://echarts.apache.org/en/option.html#series-scatter.encode + */ + encode?: { + x: string; + y: string; + }; + /** + * Customize the style of each cell item, such as color, border, opacity, etc. + * https://echarts.apache.org/en/option.html#series-scatter.itemStyle + */ + itemStyle?: ScatterSeriesOption["itemStyle"]; + /** + * The shape of the symbol. + */ + symbol?: "circle" | "rect" | "roundRect"; + /** + * `symbolSize` can be set to single numbers like 10, or use an array to represent width and height. For example, [20, 10] means symbol width is 20, and height is 10. + * + * If size of symbols needs to be different, you can set with callback function in the following format: + * + * (value: Array|number, params: Object) => number|Array + * + * The first parameter value is the value in data, and the second parameter params is the rest parameters of data item. + * https://echarts.apache.org/en/option.html#series-scatter.symbolSize + */ + symbolSize?: ScatterSeriesOption["symbolSize"]; + /** + * https://echarts.apache.org/en/option.html#grid + */ + grid?: + | EChartsOption["grid"] + | ((defaultOption: EChartsOption["grid"]) => EChartsOption["grid"]); + /** + * The options object to be passed to echarts.setOption() + * https://echarts.apache.org/en/option.html + */ + options?: EChartsOption; + /** + * Event listeners for the chart + * https://echarts.apache.org/en/api.html#events + */ + onEvents?: Record void>; +} + +export function createChartOptions( + props: CreateChartOptionsProps +): EChartsOption { + const { + data, + emphasis, + encode, + grid: gridProp, + itemStyle = DEFAULT_ITEM_STYLE, + options, + symbolSize, + symbol = "rect", + } = props; + + const { defaultEmphasis, defaultGrid, defaultXAxis, defaultYAxis } = + generateDefaultValues(props); + + const customGrid = + typeof gridProp === "function" ? gridProp(defaultGrid) : gridProp; + + const { + series: optionsSeries, + xAxis: optionsXAxis, + yAxis: optionsYAxis, + ...optionsRest + } = options || {}; + + return { + animation: false, + dataset: { + source: data as DatasetComponentOption["source"], + }, + grid: customGrid || defaultGrid, + series: [ + Object.assign( + { + emphasis: Object.assign(defaultEmphasis, emphasis), + encode, + itemStyle, + legendHoverLink: false, + symbol, + symbolSize, + }, + optionsSeries + ? Array.isArray(optionsSeries) + ? optionsSeries[0] + : optionsSeries + : [], + { symbol: symbol, type: "scatter" } + ), + ] as EChartsOption["series"], + xAxis: [ + Object.assign( + defaultXAxis, + optionsXAxis + ? Array.isArray(optionsXAxis) + ? optionsXAxis[0] + : optionsXAxis + : {} + ), + ], + yAxis: [ + Object.assign( + defaultYAxis, + optionsYAxis + ? Array.isArray(optionsYAxis) + ? optionsYAxis[0] + : optionsYAxis + : {} + ), + ], + ...optionsRest, + }; +} + +function generateDefaultValues(props: CreateChartOptionsProps) { + const { height, width } = props; + + const defaultGrid = { + height: `${height}px`, + left: 0, + top: 0, + // (atarashansky): this is the key change to align x and y axis + // labels to fixed spaces + width: `${width}px`, + }; + + const defaultAxisPointer = {} as AxisPointerComponentOption; + + const defaultXAxis = {}; + + const defaultYAxis = {}; + + const defaultEmphasis = {}; + + return { + defaultAxisPointer, + defaultEmphasis, + defaultGrid, + defaultXAxis, + defaultYAxis, + }; +} + +type OrdinalRawValue = string | number; + +/** + * (thuang): This copies echarts' CategoryAxisBaseOption["data"] type, since it's not exported + */ +type CategoryAxisData = ( + | OrdinalRawValue + | { + value: OrdinalRawValue; + /** + * (thuang): This should be echarts `TextCommonOption` type, but it's not exported + */ + textStyle?: never; + } +)[]; diff --git a/packages/data-viz/src/core/ScatterPlot/index.tsx b/packages/data-viz/src/core/ScatterPlot/index.tsx new file mode 100644 index 000000000..8c124dfab --- /dev/null +++ b/packages/data-viz/src/core/ScatterPlot/index.tsx @@ -0,0 +1,203 @@ +import { ECharts, init } from "echarts"; +import { + ForwardedRef, + HTMLAttributes, + forwardRef, + memo, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { EMPTY_OBJECT } from "src/common/utils"; +import { useUpdateChart } from "./hooks/useUpdateChart"; +import { CreateChartOptionsProps } from "./hooks/utils"; +import { ChartContainer } from "./style"; + +export interface ScatterPlotProps + extends HTMLAttributes, + CreateChartOptionsProps { + echartsRendererMode?: "svg" | "canvas"; +} + +const ScatterPlot = forwardRef( + ( + props: ScatterPlotProps, + ref: ForwardedRef + // eslint-disable-next-line sonarjs/cognitive-complexity + ): JSX.Element => { + const { + width, + height, + echartsRendererMode = "svg", + onEvents, + xAxisData, + yAxisData, + data, + encode, + emphasis, + itemStyle, + symbol, + symbolSize = 5, + grid, + options, + ...rest + } = props; + + // Validate width and height + if (!width || !height) { + throw Error("ScatterPlot must have width and height bigger than Zero!"); + } + + // Ref for the chart container + const innerRef = useRef(null); + + /** + * (thuang): We need both a state and a ref to store the chart instance, so + * some hooks can opt out of re-rendering when the chart instance changes. + */ + const [chart, setChart] = useState(null); + const chartRef = useRef(chart); + + /** + * (thuang): Use this ref to store the onEvents prop to prevent + * unnecessary re-renders when the onEvents prop changes. + * NOTE: This implies that `onEvents` prop changes alone from the parent + * won't re-render the chart + */ + const onEventsRef = useRef(onEvents); + + /** + * (thuang): Use this function to dispose the chart instance for both + * the state and the ref. This is to prevent memory leaks. + */ + const disposeChart = useCallback(() => { + chartRef.current?.dispose(); + chartRef.current = null; + setChart(null); + }, []); + + // Function to initialize the chart + const initChart = useCallback(() => { + const onEventsCurrent = onEventsRef.current; + const { current } = innerRef; + + if ( + !current || + chartRef.current || + // (thuang): echart's `init()` will throw error if the container has 0 width or height + current?.getAttribute("height") === "0" || + current?.getAttribute("width") === "0" + ) { + return; + } + + // Initialize ECharts instance + const rawChart = init(current, EMPTY_OBJECT, { + renderer: echartsRendererMode, + useDirtyRect: true, + }); + + // Bind events if provided + if (onEventsCurrent) { + bindEvents(rawChart, onEventsCurrent); + } + + setChart(rawChart); + chartRef.current = rawChart; + + // Cleanup function + return () => { + disposeChart(); + + // Unbind events if provided + if (onEventsCurrent) { + for (const eventName in onEventsCurrent) { + if ( + Object.prototype.hasOwnProperty.call( + onEventsCurrent, + eventName + ) && + typeof eventName === "string" && + typeof onEventsCurrent[eventName] === "function" + ) { + rawChart.off(eventName, onEventsCurrent[eventName]); + } + } + } + }; + }, [echartsRendererMode, disposeChart]); + + // Initialize charts on component mount + useEffect(() => { + disposeChart(); + initChart(); + }, [initChart, disposeChart]); + + // Hook to update chart data and options + useUpdateChart({ + chart, + data, + emphasis, + encode, + grid, + height, + itemStyle, + onEvents, + options, + symbol, + symbolSize, + width, + xAxisData, + yAxisData, + }); + + // Render the chart container + return ( + + ); + + // Function to bind events to the ECharts instance + function bindEvents( + instance: ECharts, + events: CreateChartOptionsProps["onEvents"] + ) { + function innerBindEvent( + eventName: string, + func: (event: unknown, chart: ECharts) => void + ) { + // Ignore invalid event configurations + if (typeof eventName === "string" && typeof func === "function") { + // Bind event + instance.on(eventName, (param) => { + func(param, instance); + }); + } + } + + // Loop through events and bind them + for (const eventName in events) { + if (Object.prototype.hasOwnProperty.call(events, eventName)) { + innerBindEvent(eventName, events[eventName]); + } + } + } + + // Function to handle the ref of the chart container + function handleRef(element: HTMLDivElement | null) { + innerRef.current = element; + + if (!ref) return; + + // (thuang): `ref` from `forwardRef` can be a function or a ref object + if (typeof ref === "function") { + ref(element); + } else { + ref.current = element; + } + } + } +); + +export default memo(ScatterPlot); diff --git a/packages/data-viz/src/core/ScatterPlot/style.ts b/packages/data-viz/src/core/ScatterPlot/style.ts new file mode 100644 index 000000000..b7a368852 --- /dev/null +++ b/packages/data-viz/src/core/ScatterPlot/style.ts @@ -0,0 +1,18 @@ +import styled from "@emotion/styled"; + +export const ChartContainer = styled("div")` + ${getWidthAndHeight} +`; + +function getWidthAndHeight({ + width, + height, +}: { + width: number; + height: number; +}) { + return ` + width: ${width}px; + height: ${height}px; + `; +} diff --git a/packages/data-viz/src/index.ts b/packages/data-viz/src/index.ts index 4d5e9685c..925bf9bbe 100644 --- a/packages/data-viz/src/index.ts +++ b/packages/data-viz/src/index.ts @@ -1,2 +1,8 @@ export * from "./core/HeatmapChart"; export { default as HeatmapChart } from "./core/HeatmapChart"; +export * from "./core/HistogramChart"; +export { default as HistogramChart } from "./core/HistogramChart"; +export * from "./core/BarChart"; +export { default as BarChart } from "./core/BarChart"; +export * from "./core/ScatterPlot"; +export { default as ScatterPlot } from "./core/ScatterPlot";