react/index.ts

/**
 * Copyright (c) 2017 ~ present NAVER Corp.
 * billboard.js project is licensed under the MIT license
 */
import type {HTMLProps} from "react";
import {createElement, forwardRef, useEffect, useImperativeHandle, useRef} from "react";
import type {bb, Chart as BillboardChart, ChartOptions} from "../../types";
import type {ChartTypes} from "../../types/types";

export type {ChartOptions as IChartOptions};

export type ChartTypeResolver = () => ChartTypes;
export type ChartModuleResolver = () => unknown;

export interface IProp extends Pick<HTMLProps<HTMLDivElement>, "className" | "style"> {
	bb: typeof bb;
	options: ChartOptions;
	type?: ChartTypes | ChartTypeResolver;
	modules?: ChartModuleResolver[];
}

export interface IChart {
	instance: BillboardChart;
}

/**
 * Resolve chart type from a direct string or resolver function.
 * @param {string|function} [type] Chart type value or resolver
 * @returns {string|undefined} Chart type string
 * @private
 */
function resolveType(type?: IProp["type"]): ChartTypes | undefined {
	return typeof type === "function" ? type() : type;
}

/**
 * Clone options and apply the optional chart type helper.
 * @param {object} options Chart options
 * @param {string|function} [type] Chart type value or resolver
 * @returns {object} Chart options with resolved type
 * @private
 */
function getOptions(options: ChartOptions, type?: IProp["type"]): ChartOptions {
	const resolvedType = resolveType(type);

	return {
		...options,
		...(resolvedType ?
			{
				data: {
					...options.data,
					type: resolvedType
				}
			} :
			{})
	};
}

/**
 * React component wrapper for billboard.js.
 * @module billboard.js/react
 * @description
 * Use the `billboard.js/react` subpath to render billboard.js charts in React.
 * The component preserves the existing `ChartOptions` API and receives the
 * billboard namespace through the `bb` prop, so importing this React subpath
 * does not pull the root billboard.js bundle into non-React user bundles.
 * @example
 * import bb, {line} from "billboard.js";
 * import BillboardJS from "billboard.js/react";
 *
 * <BillboardJS
 *   bb={bb}
 *   options={{
 *     data: {
 *       columns: [["data1", 30, 120, 80]],
 *       type: line()
 *     }
 *   }}
 * />;
 * @example
 * import bb, {canvas, line} from "billboard.js/canvas";
 * import BillboardJS from "billboard.js/react";
 *
 * <BillboardJS
 *   bb={bb}
 *   type={line}
 *   modules={[canvas]}
 *   style={{
 *     width: "480px",
 *     height: "320px"
 *   }}
 *   options={{
 *     render: {
 *       mode: "canvas"
 *     },
 *     size: {
 *       width: 480,
 *       height: 320
 *     },
 *     data: {
 *       columns: [["data1", 30, 120, 80]]
 *     }
 *   }}
 * />;
 * @example
 * import bb, {line, zoom} from "billboard.js";
 * import {Chart} from "billboard.js/react";
 *
 * <Chart
 *   bb={bb}
 *   type={line}
 *   modules={[zoom]}
 *   options={{
 *     data: {
 *       columns: [["data1", 30, 120, 80]]
 *     },
 *     zoom: {
 *       enabled: true
 *     }
 *   }}
 * />;
 * @example
 * // UMD: 'dist/billboard.react.js' exposes the 'BillboardReact' global, holding
 * // the same component as both '.Chart' and '.default'. React is expected on the
 * // page as the 'React' global, so this path needs a UMD build of React - React
 * // 18 and below ship one, React 19 does not.
 * <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
 * <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
 *
 * <link rel="stylesheet" href="$YOUR_PATH/billboard.css">
 * <script src="$YOUR_PATH/billboard.pkgd.js"></script>
 * <script src="$YOUR_PATH/billboard.react.js"></script>
 *
 * <div id="root"></div>
 * <script>
 *   const {Chart} = BillboardReact;
 *
 *   ReactDOM.createRoot(document.getElementById("root")).render(
 *     React.createElement(Chart, {
 *       bb,
 *       options: {
 *         data: {
 *           columns: [["data1", 30, 120, 80]],
 *           type: "line"
 *         }
 *       }
 *     })
 *   );
 * </script>
 */
const Chart = forwardRef<IChart, IProp>((props, ref) => {
	const container = useRef<HTMLDivElement>(null);
	const instance = useRef<BillboardChart | null>(null);
	const {bb: billboard, options, type, modules, ...htmlDivProps} = props;

	useEffect(() => {
		if (!billboard || !options) {
			// eslint-disable-next-line no-console
			console.warn("Required props('bb' or 'options') are not defined.");
			return;
		}

		modules?.forEach(register => register());

		const chartOptions = getOptions(options, type);

		chartOptions.bindto = container.current;
		instance.current = billboard.generate(chartOptions);

		return () => {
			instance.current?.destroy();
			instance.current = null;
		};
	}, []);

	useImperativeHandle(
		ref,
		() => ({
			get instance() {
				return instance.current as BillboardChart;
			}
		}),
		[]
	);

	return createElement("div", {
		...htmlDivProps,
		ref: container
	});
});

export {Chart};
export default Chart;