Chart.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652
  1. /*
  2. * Copyright 2021-2024 Avaiga Private Limited
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  5. * the License. You may obtain a copy of the License at
  6. *
  7. * http://www.apache.org/licenses/LICENSE-2.0
  8. *
  9. * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  10. * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
  11. * specific language governing permissions and limitations under the License.
  12. */
  13. import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState, lazy, Suspense } from "react";
  14. import {
  15. Config,
  16. Data,
  17. Layout,
  18. ModeBarButtonAny,
  19. PlotDatum,
  20. PlotMarker,
  21. PlotRelayoutEvent,
  22. PlotSelectionEvent,
  23. ScatterLine,
  24. } from "plotly.js";
  25. import Skeleton from "@mui/material/Skeleton";
  26. import Box from "@mui/material/Box";
  27. import Tooltip from "@mui/material/Tooltip";
  28. import { useTheme } from "@mui/material";
  29. import { getArrayValue, getUpdateVar, TaipyActiveProps, TaipyChangeProps } from "./utils";
  30. import {
  31. createRequestChartUpdateAction,
  32. createSendActionNameAction,
  33. createSendUpdateAction,
  34. } from "../../context/taipyReducers";
  35. import { ColumnDesc } from "./tableUtils";
  36. import {
  37. useClassNames,
  38. useDispatch,
  39. useDispatchRequestUpdateOnFirstRender,
  40. useDynamicJsonProperty,
  41. useDynamicProperty,
  42. useModule,
  43. } from "../../utils/hooks";
  44. import { darkThemeTemplate } from "../../themes/darkThemeTemplate";
  45. const Plot = lazy(() => import("react-plotly.js"));
  46. interface ChartProp extends TaipyActiveProps, TaipyChangeProps {
  47. title?: string;
  48. width?: string | number;
  49. height?: string | number;
  50. defaultConfig: string;
  51. config?: string;
  52. data?: Record<string, TraceValueType>;
  53. defaultLayout?: string;
  54. layout?: string;
  55. plotConfig?: string;
  56. onRangeChange?: string;
  57. testId?: string;
  58. render?: boolean;
  59. defaultRender?: boolean;
  60. template?: string;
  61. template_Dark_?: string;
  62. template_Light_?: string;
  63. //[key: `selected_${number}`]: number[];
  64. figure?: Array<Record<string, unknown>>;
  65. }
  66. interface ChartConfig {
  67. columns: Record<string, ColumnDesc>;
  68. labels: string[];
  69. modes: string[];
  70. types: string[];
  71. traces: string[][];
  72. xaxis: string[];
  73. yaxis: string[];
  74. markers: Partial<PlotMarker>[];
  75. selectedMarkers: Partial<PlotMarker>[];
  76. orientations: string[];
  77. names: string[];
  78. lines: Partial<ScatterLine>[];
  79. texts: string[];
  80. textAnchors: string[];
  81. options: Record<string, unknown>[];
  82. axisNames: Array<string[]>;
  83. addIndex: Array<boolean>;
  84. decimators?: string[];
  85. }
  86. export type TraceValueType = Record<string, (string | number)[]>;
  87. const defaultStyle = { position: "relative", display: "inline-block" };
  88. const indexedData = /^(\d+)\/(.*)/;
  89. const getColNameFromIndexed = (colName: string): string => {
  90. if (colName) {
  91. const reRes = indexedData.exec(colName);
  92. if (reRes && reRes.length > 2) {
  93. return reRes[2] || colName;
  94. }
  95. }
  96. return colName;
  97. };
  98. const getValue = <T,>(
  99. values: TraceValueType | undefined,
  100. arr: T[],
  101. idx: number,
  102. returnUndefined = false
  103. ): (string | number)[] | undefined => {
  104. const value = getValueFromCol(values, getArrayValue(arr, idx) as unknown as string);
  105. if (!returnUndefined || value.length) {
  106. return value;
  107. }
  108. return undefined;
  109. };
  110. const getValueFromCol = (values: TraceValueType | undefined, col: string): (string | number)[] => {
  111. if (values) {
  112. if (col) {
  113. if (Array.isArray(values)) {
  114. const reRes = indexedData.exec(col);
  115. if (reRes && reRes.length > 2) {
  116. return values[parseInt(reRes[1], 10) || 0][reRes[2] || col] || [];
  117. }
  118. } else {
  119. return values[col] || [];
  120. }
  121. }
  122. }
  123. return [];
  124. };
  125. const getAxis = (traces: string[][], idx: number, columns: Record<string, ColumnDesc>, axis: number) => {
  126. if (traces.length > idx && traces[idx].length > axis && traces[idx][axis] && columns[traces[idx][axis]])
  127. return columns[traces[idx][axis]].dfid;
  128. return undefined;
  129. };
  130. const getDecimatorsPayload = (
  131. decimators: string[] | undefined,
  132. plotDiv: HTMLDivElement | null,
  133. modes: string[],
  134. columns: Record<string, ColumnDesc>,
  135. traces: string[][],
  136. relayoutData?: PlotRelayoutEvent
  137. ) => {
  138. return decimators
  139. ? {
  140. width: plotDiv?.clientWidth,
  141. height: plotDiv?.clientHeight,
  142. decimators: decimators.map((d, i) =>
  143. d
  144. ? {
  145. decimator: d,
  146. xAxis: getAxis(traces, i, columns, 0),
  147. yAxis: getAxis(traces, i, columns, 1),
  148. zAxis: getAxis(traces, i, columns, 2),
  149. chartMode: modes[i],
  150. }
  151. : undefined
  152. ),
  153. relayoutData: relayoutData,
  154. }
  155. : undefined;
  156. };
  157. const selectedPropRe = /selected(\d+)/;
  158. const MARKER_TO_COL = ["color", "size", "symbol", "opacity", "colors"];
  159. const isOnClick = (types: string[]) => (types?.length ? types.every((t) => t === "pie") : false);
  160. interface WithpointNumbers {
  161. pointNumbers: number[];
  162. }
  163. const getPlotIndex = (pt: PlotDatum) =>
  164. pt.pointIndex === undefined
  165. ? pt.pointNumber === undefined
  166. ? (pt as unknown as WithpointNumbers).pointNumbers?.length
  167. ? (pt as unknown as WithpointNumbers).pointNumbers[0]
  168. : 0
  169. : pt.pointNumber
  170. : pt.pointIndex;
  171. const defaultConfig = {
  172. columns: {} as Record<string, ColumnDesc>,
  173. labels: [],
  174. modes: [],
  175. types: [],
  176. traces: [],
  177. xaxis: [],
  178. yaxis: [],
  179. markers: [],
  180. selectedMarkers: [],
  181. orientations: [],
  182. names: [],
  183. lines: [],
  184. texts: [],
  185. textAnchors: [],
  186. options: [],
  187. axisNames: [],
  188. addIndex: [],
  189. } as ChartConfig;
  190. const emptyLayout = {} as Partial<Layout>;
  191. const emptyData = {} as Record<string, TraceValueType>;
  192. const TaipyPlotlyButtons: ModeBarButtonAny[] = [
  193. {
  194. name: "Full screen",
  195. title: "Full screen",
  196. icon: {
  197. height: 24,
  198. width: 24,
  199. path: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z",
  200. },
  201. click: function (gd: HTMLElement, evt: Event) {
  202. const div = gd.querySelector("div.svg-container") as HTMLDivElement;
  203. if (!div) {
  204. return;
  205. }
  206. const { height } = gd.dataset;
  207. if (!height) {
  208. gd.setAttribute("data-height", getComputedStyle(div).height);
  209. }
  210. const fs = gd.classList.toggle("full-screen");
  211. (evt.currentTarget as HTMLElement).setAttribute("data-title", fs ? "Exit Full screen" : "Full screen");
  212. if (height && !fs) {
  213. div.attributeStyleMap.set("height", height);
  214. }
  215. window.dispatchEvent(new Event("resize"));
  216. },
  217. },
  218. ];
  219. const updateArrays = (sel: number[][], val: number[], idx: number) => {
  220. if (idx >= sel.length || val.length !== sel[idx].length || val.some((v, i) => sel[idx][i] != v)) {
  221. sel = sel.concat(); // shallow copy
  222. sel[idx] = val;
  223. }
  224. return sel;
  225. };
  226. const Chart = (props: ChartProp) => {
  227. const {
  228. title = "",
  229. width = "100%",
  230. height,
  231. updateVarName,
  232. updateVars,
  233. id,
  234. data = emptyData,
  235. onRangeChange,
  236. propagate = true,
  237. } = props;
  238. const dispatch = useDispatch();
  239. const [selected, setSelected] = useState<number[][]>([]);
  240. const plotRef = useRef<HTMLDivElement>(null);
  241. const [dataKey, setDataKey] = useState("__default__");
  242. const lastDataPl = useRef<Data[]>([]);
  243. const theme = useTheme();
  244. const module = useModule();
  245. const refresh = typeof data === "number" ? data : 0;
  246. const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
  247. const active = useDynamicProperty(props.active, props.defaultActive, true);
  248. const render = useDynamicProperty(props.render, props.defaultRender, true);
  249. const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
  250. const baseLayout = useDynamicJsonProperty(props.layout, props.defaultLayout || "", emptyLayout);
  251. // get props.selected[i] values
  252. useEffect(() => {
  253. if (props.figure) {
  254. return;
  255. }
  256. setSelected((sel) => {
  257. Object.keys(props).forEach((key) => {
  258. const res = selectedPropRe.exec(key);
  259. if (res && res.length == 2) {
  260. const idx = parseInt(res[1], 10);
  261. let val = (props as unknown as Record<string, number[]>)[key];
  262. if (val !== undefined) {
  263. if (typeof val === "string") {
  264. try {
  265. val = JSON.parse(val) as number[];
  266. } catch (e) {
  267. // too bad
  268. val = [];
  269. }
  270. }
  271. if (!Array.isArray(val)) {
  272. val = [];
  273. }
  274. if (idx === 0 && val.length && Array.isArray(val[0])) {
  275. for (let i = 0; i < val.length; i++) {
  276. sel = updateArrays(sel, val[i] as unknown as number[], i);
  277. }
  278. } else {
  279. sel = updateArrays(sel, val, idx);
  280. }
  281. }
  282. }
  283. });
  284. return sel;
  285. });
  286. }, [props]);
  287. const config = useDynamicJsonProperty(props.config, props.defaultConfig, defaultConfig);
  288. useEffect(() => {
  289. if (updateVarName && (refresh || !data[dataKey])) {
  290. const backCols = Object.values(config.columns).map((col) => col.dfid);
  291. const dtKey = backCols.join("-") + (config.decimators ? `--${config.decimators.join("")}` : "");
  292. setDataKey(dtKey);
  293. if (refresh || !data[dtKey]) {
  294. dispatch(
  295. createRequestChartUpdateAction(
  296. updateVarName,
  297. id,
  298. module,
  299. backCols,
  300. dtKey,
  301. getDecimatorsPayload(
  302. config.decimators,
  303. plotRef.current,
  304. config.modes,
  305. config.columns,
  306. config.traces
  307. )
  308. )
  309. );
  310. }
  311. }
  312. // eslint-disable-next-line react-hooks/exhaustive-deps
  313. }, [refresh, dispatch, config.columns, config.traces, config.modes, config.decimators, updateVarName, id, module]);
  314. useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars);
  315. const layout = useMemo(() => {
  316. const layout = { ...baseLayout };
  317. let template = undefined;
  318. try {
  319. const tpl = props.template && JSON.parse(props.template);
  320. const tplTheme =
  321. theme.palette.mode === "dark"
  322. ? props.template_Dark_
  323. ? JSON.parse(props.template_Dark_)
  324. : darkThemeTemplate
  325. : props.template_Light_ && JSON.parse(props.template_Light_);
  326. template = tpl ? (tplTheme ? { ...tpl, ...tplTheme } : tpl) : tplTheme ? tplTheme : undefined;
  327. } catch (e) {
  328. console.info(`Error while parsing Chart.template\n${(e as Error).message || e}`);
  329. }
  330. if (template) {
  331. layout.template = template;
  332. }
  333. if (props.figure) {
  334. return {
  335. ...(props.figure[0].layout as Partial<Layout>),
  336. ...layout,
  337. title: title || layout.title || (props.figure[0].layout as Partial<Layout>).title,
  338. clickmode: "event+select",
  339. } as Layout;
  340. }
  341. return {
  342. ...layout,
  343. autosize: true,
  344. title: title || layout.title,
  345. xaxis: {
  346. title:
  347. config.traces.length && config.traces[0].length && config.traces[0][0]
  348. ? getColNameFromIndexed(config.columns[config.traces[0][0]]?.dfid)
  349. : undefined,
  350. ...layout.xaxis,
  351. },
  352. yaxis: {
  353. title:
  354. config.traces.length == 1 && config.traces[0].length > 1 && config.columns[config.traces[0][1]]
  355. ? getColNameFromIndexed(config.columns[config.traces[0][1]]?.dfid)
  356. : undefined,
  357. ...layout.yaxis,
  358. },
  359. clickmode: "event+select",
  360. } as Layout;
  361. }, [
  362. theme.palette.mode,
  363. title,
  364. config.columns,
  365. config.traces,
  366. baseLayout,
  367. props.template,
  368. props.template_Dark_,
  369. props.template_Light_,
  370. props.figure,
  371. ]);
  372. const style = useMemo(
  373. () =>
  374. height === undefined
  375. ? ({ ...defaultStyle, width: width } as CSSProperties)
  376. : ({ ...defaultStyle, width: width, height: height } as CSSProperties),
  377. [width, height]
  378. );
  379. const skelStyle = useMemo(() => ({ ...style, minHeight: "7em" }), [style]);
  380. const dataPl = useMemo(() => {
  381. if (props.figure) {
  382. return lastDataPl.current;
  383. }
  384. if (typeof data === "number" && lastDataPl.current) {
  385. return lastDataPl.current;
  386. }
  387. const datum = data[dataKey];
  388. lastDataPl.current = datum
  389. ? config.traces.map((trace, idx) => {
  390. const ret = {
  391. ...getArrayValue(config.options, idx, {}),
  392. type: config.types[idx],
  393. mode: config.modes[idx],
  394. name:
  395. getArrayValue(config.names, idx) ||
  396. (config.columns[trace[1]] ? getColNameFromIndexed(config.columns[trace[1]].dfid) : undefined),
  397. } as Record<string, unknown>;
  398. ret.marker = { ...getArrayValue(config.markers, idx, ret.marker || {}) };
  399. if (Object.keys(ret.marker as object).length) {
  400. MARKER_TO_COL.forEach((prop) => {
  401. const val = (ret.marker as Record<string, unknown>)[prop];
  402. if (typeof val === "string") {
  403. const arr = getValueFromCol(datum, val as string);
  404. if (arr.length) {
  405. (ret.marker as Record<string, unknown>)[prop] = arr;
  406. }
  407. }
  408. });
  409. } else {
  410. delete ret.marker;
  411. }
  412. const xs = getValue(datum, trace, 0) || [];
  413. const ys = getValue(datum, trace, 1) || [];
  414. const addIndex = getArrayValue(config.addIndex, idx, true) && !ys.length;
  415. const baseX = addIndex ? Array.from(Array(xs.length).keys()) : xs;
  416. const baseY = addIndex ? xs : ys;
  417. const axisNames = config.axisNames.length > idx ? config.axisNames[idx] : ([] as string[]);
  418. if (baseX.length) {
  419. if (axisNames.length > 0) {
  420. ret[axisNames[0]] = baseX;
  421. } else {
  422. ret.x = baseX;
  423. }
  424. }
  425. if (baseY.length) {
  426. if (axisNames.length > 1) {
  427. ret[axisNames[1]] = baseY;
  428. } else {
  429. ret.y = baseY;
  430. }
  431. }
  432. const baseZ = getValue(datum, trace, 2, true);
  433. if (baseZ) {
  434. if (axisNames.length > 2) {
  435. ret[axisNames[2]] = baseZ;
  436. } else {
  437. ret.z = baseZ;
  438. }
  439. }
  440. // Hack for treemap charts: create a fallback 'parents' column if needed
  441. // This works ONLY because 'parents' is the third named axis
  442. // (see __CHART_AXIS in gui/utils/chart_config_builder.py)
  443. else if (config.types[idx] === "treemap" && Array.isArray(ret.labels)) {
  444. ret.parents = Array(ret.labels.length).fill("");
  445. }
  446. // Other axis
  447. for (let i = 3; i < axisNames.length; i++) {
  448. ret[axisNames[i]] = getValue(datum, trace, i, true);
  449. }
  450. ret.text = getValue(datum, config.texts, idx, true);
  451. ret.xaxis = config.xaxis[idx];
  452. ret.yaxis = config.yaxis[idx];
  453. ret.hovertext = getValue(datum, config.labels, idx, true);
  454. const selPoints = getArrayValue(selected, idx, []);
  455. if (selPoints?.length) {
  456. ret.selectedpoints = selPoints;
  457. }
  458. ret.orientation = getArrayValue(config.orientations, idx);
  459. ret.line = getArrayValue(config.lines, idx);
  460. ret.textposition = getArrayValue(config.textAnchors, idx);
  461. const selectedMarker = getArrayValue(config.selectedMarkers, idx);
  462. if (selectedMarker) {
  463. ret.selected = { marker: selectedMarker };
  464. }
  465. return ret as Data;
  466. })
  467. : [];
  468. return lastDataPl.current;
  469. }, [props.figure, selected, data, config, dataKey]);
  470. const plotConfig = useMemo(() => {
  471. let plconf: Partial<Config> = {};
  472. if (props.plotConfig) {
  473. try {
  474. plconf = JSON.parse(props.plotConfig);
  475. } catch (e) {
  476. console.info(`Error while parsing Chart.plot_config\n${(e as Error).message || e}`);
  477. }
  478. if (typeof plconf !== "object" || plconf === null || Array.isArray(plconf)) {
  479. console.info("Error Chart.plot_config is not a dictionary");
  480. plconf = {};
  481. }
  482. }
  483. plconf.modeBarButtonsToAdd = TaipyPlotlyButtons;
  484. // plconf.responsive = true; // this is the source of the on/off height ...
  485. plconf.autosizable = true;
  486. if (!active) {
  487. plconf.staticPlot = true;
  488. }
  489. return plconf;
  490. }, [active, props.plotConfig]);
  491. const onRelayout = useCallback(
  492. (eventData: PlotRelayoutEvent) => {
  493. onRangeChange && dispatch(createSendActionNameAction(id, module, { action: onRangeChange, ...eventData }));
  494. if (config.decimators && !config.types.includes("scatter3d")) {
  495. const backCols = Object.values(config.columns).map((col) => col.dfid);
  496. const eventDataKey = Object.entries(eventData)
  497. .map(([k, v]) => `${k}=${v}`)
  498. .join("-");
  499. const dtKey =
  500. backCols.join("-") +
  501. (config.decimators ? `--${config.decimators.join("")}` : "") +
  502. "--" +
  503. eventDataKey;
  504. setDataKey(dtKey);
  505. dispatch(
  506. createRequestChartUpdateAction(
  507. updateVarName,
  508. id,
  509. module,
  510. backCols,
  511. dtKey,
  512. getDecimatorsPayload(
  513. config.decimators,
  514. plotRef.current,
  515. config.modes,
  516. config.columns,
  517. config.traces,
  518. eventData
  519. )
  520. )
  521. );
  522. }
  523. },
  524. [
  525. dispatch,
  526. onRangeChange,
  527. id,
  528. config.modes,
  529. config.columns,
  530. config.traces,
  531. config.types,
  532. config.decimators,
  533. updateVarName,
  534. module,
  535. ]
  536. );
  537. const onAfterPlot = useCallback(() => {
  538. // Manage loading Animation ... One day
  539. }, []);
  540. const getRealIndex = useCallback(
  541. (index?: number) =>
  542. typeof index === "number"
  543. ? props.figure
  544. ? index
  545. : data[dataKey].tp_index
  546. ? (data[dataKey].tp_index[index] as number)
  547. : index
  548. : 0,
  549. [data, dataKey, props.figure]
  550. );
  551. const onSelect = useCallback(
  552. (evt?: PlotSelectionEvent) => {
  553. if (updateVars) {
  554. const traces = (evt?.points || []).reduce((tr, pt) => {
  555. tr[pt.curveNumber] = tr[pt.curveNumber] || [];
  556. tr[pt.curveNumber].push(getRealIndex(getPlotIndex(pt)));
  557. return tr;
  558. }, [] as number[][]);
  559. if (traces.length) {
  560. const upvars = traces.map((_, idx) => getUpdateVar(updateVars, `selected${idx}`));
  561. if (traces.length > 1 && new Set(upvars).size === 1) {
  562. dispatch(createSendUpdateAction(upvars[0], traces, module, props.onChange, propagate));
  563. return;
  564. }
  565. traces.forEach((tr, idx) => {
  566. if (upvars[idx] && tr && tr.length) {
  567. dispatch(createSendUpdateAction(upvars[idx], tr, module, props.onChange, propagate));
  568. }
  569. });
  570. } else if (config.traces.length === 1) {
  571. const upvar = getUpdateVar(updateVars, "selected0");
  572. if (upvar) {
  573. dispatch(createSendUpdateAction(upvar, [], module, props.onChange, propagate));
  574. }
  575. }
  576. }
  577. },
  578. [getRealIndex, dispatch, updateVars, propagate, props.onChange, config.traces.length, module]
  579. );
  580. return render ? (
  581. <Box id={id} data-testid={props.testId} className={className} ref={plotRef}>
  582. <Tooltip title={hover || ""}>
  583. <Suspense fallback={<Skeleton key="skeleton" sx={skelStyle} />}>
  584. {Array.isArray(props.figure) && props.figure.length && props.figure[0].data !== undefined ? (
  585. <Plot
  586. data={props.figure[0].data as Data[]}
  587. layout={layout}
  588. style={style}
  589. onRelayout={onRelayout}
  590. onAfterPlot={onAfterPlot}
  591. onSelected={onSelect}
  592. onDeselect={onSelect}
  593. config={plotConfig}
  594. useResizeHandler
  595. />
  596. ) : (
  597. <Plot
  598. data={dataPl}
  599. layout={layout}
  600. style={style}
  601. onRelayout={onRelayout}
  602. onAfterPlot={onAfterPlot}
  603. onSelected={isOnClick(config.types) ? undefined : onSelect}
  604. onDeselect={isOnClick(config.types) ? undefined : onSelect}
  605. onClick={isOnClick(config.types) ? onSelect : undefined}
  606. config={plotConfig}
  607. useResizeHandler
  608. />
  609. )}
  610. </Suspense>
  611. </Tooltip>
  612. </Box>
  613. ) : null;
  614. };
  615. export default Chart;