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";