1
0

Chart.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  1. /*
  2. * Copyright 2021-2025 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, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
  14. import { useTheme } from "@mui/material";
  15. import Box from "@mui/material/Box";
  16. import Skeleton from "@mui/material/Skeleton";
  17. import Tooltip from "@mui/material/Tooltip";
  18. import isEqual from "lodash/isEqual";
  19. import merge from "lodash/merge";
  20. import { nanoid } from "nanoid";
  21. import {
  22. AnimationOpts,
  23. Config,
  24. Data,
  25. Frame,
  26. Layout,
  27. ModeBarButtonAny,
  28. PlotDatum,
  29. PlotData,
  30. PlotlyHTMLElement,
  31. PlotMarker,
  32. PlotRelayoutEvent,
  33. PlotSelectionEvent,
  34. Root,
  35. ScatterLine,
  36. } from "plotly.js";
  37. import { Figure } from "react-plotly.js";
  38. import {
  39. createRequestChartUpdateAction,
  40. createSendActionNameAction,
  41. createSendUpdateAction,
  42. } from "../../context/taipyReducers";
  43. import { lightenPayload } from "../../context/wsUtils";
  44. import { darkThemeTemplate } from "../../themes/darkThemeTemplate";
  45. import {
  46. useClassNames,
  47. useDispatch,
  48. useDispatchRequestUpdateOnFirstRender,
  49. useDynamicJsonProperty,
  50. useDynamicProperty,
  51. useModule,
  52. } from "../../utils/hooks";
  53. import { ColumnDesc } from "./tableUtils";
  54. import { getComponentClassName } from "./TaipyStyle";
  55. import { getArrayValue, getUpdateVar, TaipyActiveProps, TaipyChangeProps } from "./utils";
  56. const Plot = lazy(() => import("react-plotly.js"));
  57. interface PlotlyObject {
  58. animate: (
  59. root: Root,
  60. frameOrGroupNameOrFrameList?: string | string[] | Partial<Frame> | Array<Partial<Frame>>,
  61. opts?: Partial<AnimationOpts>
  62. ) => Promise<void>;
  63. }
  64. interface ChartProp extends TaipyActiveProps, TaipyChangeProps {
  65. title?: string;
  66. defaultTitle?: string;
  67. width?: string | number;
  68. height?: string | number;
  69. defaultConfig: string;
  70. config?: string;
  71. data?: Record<string, TraceValueType>;
  72. animationData?: Record<string, TraceValueType>;
  73. //data${number}?: Record<string, TraceValueType>;
  74. defaultLayout?: string;
  75. layout?: string;
  76. plotConfig?: string;
  77. onRangeChange?: string;
  78. render?: boolean;
  79. defaultRender?: boolean;
  80. template?: string;
  81. template_Dark_?: string;
  82. template_Light_?: string;
  83. //[key: `selected${number}`]: number[];
  84. figure?: Array<Record<string, unknown>>;
  85. onClick?: string;
  86. dataVarNames?: string;
  87. }
  88. interface ChartConfig {
  89. columns: Array<Record<string, ColumnDesc>>;
  90. labels: string[];
  91. modes: string[];
  92. types: string[];
  93. traces: string[][];
  94. xaxis: string[];
  95. yaxis: string[];
  96. markers: Partial<PlotMarker>[];
  97. selectedMarkers: Partial<PlotMarker>[];
  98. orientations: string[];
  99. names: string[];
  100. lines: Partial<ScatterLine>[];
  101. texts: string[];
  102. textAnchors: string[];
  103. options: Record<string, unknown>[];
  104. axisNames: Array<string[]>;
  105. addIndex: Array<boolean>;
  106. decimators?: string[];
  107. }
  108. export type TraceValueType = Record<string, (string | number)[]>;
  109. const defaultStyle = { position: "relative", display: "inline-block" };
  110. const indexedData = /^(\d+)\/(.*)/;
  111. export const getColNameFromIndexed = (colName: string): string => {
  112. if (colName) {
  113. const reRes = indexedData.exec(colName);
  114. if (reRes && reRes.length > 2) {
  115. return reRes[2] || colName;
  116. }
  117. }
  118. return colName;
  119. };
  120. export const getValue = <T,>(
  121. values: TraceValueType | undefined,
  122. arr: T[],
  123. idx: number,
  124. returnUndefined = false
  125. ): (string | number)[] | undefined => {
  126. const value = getValueFromCol(values, getArrayValue(arr, idx) as unknown as string);
  127. if (!returnUndefined || value.length) {
  128. return value;
  129. }
  130. return undefined;
  131. };
  132. export const getValueFromCol = (values: TraceValueType | undefined, col: string): (string | number)[] => {
  133. if (values) {
  134. if (col) {
  135. // TODO: Re-review the logic here
  136. if (Array.isArray(values)) {
  137. const reRes = indexedData.exec(col);
  138. if (reRes && reRes.length > 2) {
  139. return values[parseInt(reRes[1], 10) || 0][reRes[2] || col] || [];
  140. }
  141. } else {
  142. return values[col] || [];
  143. }
  144. }
  145. }
  146. return [];
  147. };
  148. export const getAxis = (traces: string[][], idx: number, columns: Record<string, ColumnDesc>, axis: number) => {
  149. if (traces.length > idx && traces[idx].length > axis && traces[idx][axis] && columns[traces[idx][axis]])
  150. return columns[traces[idx][axis]].dfid;
  151. return undefined;
  152. };
  153. const getDecimatorsPayload = (
  154. decimators: string[] | undefined,
  155. plotDiv: HTMLDivElement | null,
  156. modes: string[],
  157. columns: Record<string, ColumnDesc>,
  158. traces: string[][],
  159. relayoutData?: PlotRelayoutEvent
  160. ) => {
  161. return decimators
  162. ? {
  163. width: plotDiv?.clientWidth,
  164. height: plotDiv?.clientHeight,
  165. decimators: decimators.map((d, i) =>
  166. d
  167. ? {
  168. decimator: d,
  169. xAxis: getAxis(traces, i, columns, 0),
  170. yAxis: getAxis(traces, i, columns, 1),
  171. zAxis: getAxis(traces, i, columns, 2),
  172. chartMode: modes[i],
  173. }
  174. : {
  175. xAxis: getAxis(traces, i, columns, 0),
  176. yAxis: getAxis(traces, i, columns, 1),
  177. zAxis: getAxis(traces, i, columns, 2),
  178. chartMode: modes[i],
  179. }
  180. ),
  181. relayoutData: relayoutData,
  182. }
  183. : undefined;
  184. };
  185. const selectedPropRe = /selected(\d+)/;
  186. const MARKER_TO_COL = ["color", "size", "symbol", "opacity", "colors"];
  187. const DEFAULT_ANIMATION_SETTINGS: Partial<AnimationOpts> = {
  188. transition: {
  189. duration: 500,
  190. easing: "cubic-in-out",
  191. },
  192. frame: {
  193. duration: 500,
  194. },
  195. mode: "immediate",
  196. };
  197. const isOnClick = (types: string[]) => (types?.length ? types.every((t) => t === "pie") : false);
  198. interface Axis {
  199. p2c: () => number;
  200. p2d: (a: number) => number;
  201. }
  202. interface PlotlyMap {
  203. _subplot?: { xaxis: Axis; yaxis: Axis };
  204. }
  205. interface PlotlyDiv extends HTMLDivElement {
  206. _fullLayout?: {
  207. map?: PlotlyMap;
  208. geo?: PlotlyMap;
  209. mapbox?: PlotlyMap;
  210. xaxis?: Axis;
  211. yaxis?: Axis;
  212. };
  213. }
  214. interface ExtendedPlotData extends PlotData {
  215. meta?: {
  216. xAxisName?: string;
  217. yAxisName?: string;
  218. };
  219. }
  220. interface WithPointNumbers {
  221. pointNumbers: number[];
  222. }
  223. export const getPlotIndex = (pt: PlotDatum) =>
  224. pt.pointIndex === undefined
  225. ? pt.pointNumber === undefined
  226. ? (pt as unknown as WithPointNumbers).pointNumbers?.length
  227. ? (pt as unknown as WithPointNumbers).pointNumbers[0]
  228. : 0
  229. : pt.pointNumber
  230. : pt.pointIndex;
  231. const defaultConfig = {
  232. columns: [] as Array<Record<string, ColumnDesc>>,
  233. labels: [],
  234. modes: [],
  235. types: [],
  236. traces: [],
  237. xaxis: [],
  238. yaxis: [],
  239. markers: [],
  240. selectedMarkers: [],
  241. orientations: [],
  242. names: [],
  243. lines: [],
  244. texts: [],
  245. textAnchors: [],
  246. options: [],
  247. axisNames: [],
  248. addIndex: [],
  249. } as ChartConfig;
  250. const emptyLayout = {} as Partial<Layout>;
  251. const emptyData = {} as Record<string, TraceValueType>;
  252. export const TaipyPlotlyButtons: ModeBarButtonAny[] = [
  253. {
  254. name: "Full screen",
  255. title: "Full screen",
  256. icon: {
  257. height: 24,
  258. width: 24,
  259. path: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z",
  260. },
  261. click: function (gd: HTMLElement, evt: Event) {
  262. const div = gd.querySelector("div.svg-container") as HTMLDivElement;
  263. if (!div) {
  264. return;
  265. }
  266. const { height, width } = gd.dataset;
  267. if (!height) {
  268. const st = getComputedStyle(div);
  269. gd.setAttribute("data-height", st.height);
  270. gd.setAttribute("data-width", st.width);
  271. }
  272. const fs = gd.classList.toggle("full-screen");
  273. (evt.currentTarget as HTMLElement).setAttribute("data-title", fs ? "Exit Full screen" : "Full screen");
  274. if (!fs) {
  275. // height && div.attributeStyleMap.set("height", height);
  276. height && (div.style.height = height);
  277. // width && div.attributeStyleMap.set("width", width);
  278. width && (div.style.width = width);
  279. }
  280. window.dispatchEvent(new Event("resize"));
  281. },
  282. },
  283. ];
  284. const updateArrays = (sel: number[][], val: number[], idx: number) => {
  285. if (idx >= sel.length || val.length !== sel[idx].length || val.some((v, i) => sel[idx][i] != v)) {
  286. sel = sel.concat(); // shallow copy
  287. sel[idx] = val;
  288. }
  289. return sel;
  290. };
  291. const getDataKey = (columns?: Record<string, ColumnDesc>, decimators?: string[]): [string[], string] => {
  292. const backCols = columns ? Object.values(columns).map((col) => col.dfid) : [];
  293. return [backCols, backCols.join("-") + (decimators ? `--${decimators.join("")}` : "")];
  294. };
  295. const isDataRefresh = (data?: Record<string, TraceValueType>) => data?.__taipy_refresh !== undefined;
  296. const getDataVarName = (updateVarName: string | undefined, dataVarNames: string[], idx: number) =>
  297. idx === 0 ? updateVarName : dataVarNames[idx - 1];
  298. const getData = (
  299. data: Record<string, TraceValueType>,
  300. additionalDatas: Array<Record<string, TraceValueType>>,
  301. idx: number
  302. ) => (idx === 0 ? data : idx <= additionalDatas.length ? additionalDatas[idx - 1] : undefined);
  303. const Chart = (props: ChartProp) => {
  304. const {
  305. width = "100%",
  306. height,
  307. updateVarName,
  308. updateVars,
  309. id,
  310. data = emptyData,
  311. onRangeChange,
  312. propagate = true,
  313. onClick,
  314. animationData,
  315. } = props;
  316. const dispatch = useDispatch();
  317. const [selected, setSelected] = useState<number[][]>([]);
  318. const plotRef = useRef<HTMLDivElement | null>(null);
  319. const plotlyRef = useRef<PlotlyObject | null>(null);
  320. const [dataKeys, setDataKeys] = useState<string[]>([]);
  321. // animation
  322. const [toFrame, setToFrame] = useState<Partial<Frame>>({
  323. data: [],
  324. traces: [],
  325. });
  326. const lastDataPl = useRef<ExtendedPlotData[]>([]);
  327. const theme = useTheme();
  328. const module = useModule();
  329. const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
  330. const active = useDynamicProperty(props.active, props.defaultActive, true);
  331. const render = useDynamicProperty(props.render, props.defaultRender, true);
  332. const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
  333. const baseLayout = useDynamicJsonProperty(props.layout, props.defaultLayout || "", emptyLayout);
  334. const title = useDynamicProperty(props.title, props.defaultTitle, "");
  335. const dataVarNames = useMemo(() => (props.dataVarNames ? props.dataVarNames.split(";") : []), [props.dataVarNames]);
  336. const oldAdditionalDatas = useRef<Array<Record<string, TraceValueType>>>([]);
  337. const additionalDatas = useMemo(() => {
  338. const newAdditionalDatas = dataVarNames.map(
  339. (_, idx) => (props as unknown as Record<string, Record<string, TraceValueType>>)[`data${idx + 1}`]
  340. );
  341. if (newAdditionalDatas.length !== oldAdditionalDatas.current.length) {
  342. oldAdditionalDatas.current = newAdditionalDatas;
  343. } else if (!newAdditionalDatas.every((d, idx) => d === oldAdditionalDatas.current[idx])) {
  344. oldAdditionalDatas.current = newAdditionalDatas;
  345. }
  346. return oldAdditionalDatas.current;
  347. }, [dataVarNames, props]);
  348. const refresh = useMemo(
  349. () => (isDataRefresh(data) || additionalDatas.some((d) => isDataRefresh(d)) ? nanoid() : false),
  350. [data, additionalDatas]
  351. );
  352. // get props.selected[i] values
  353. useEffect(() => {
  354. if (props.figure) {
  355. return;
  356. }
  357. setSelected((sel) => {
  358. Object.keys(props).forEach((key) => {
  359. const res = selectedPropRe.exec(key);
  360. if (res && res.length == 2) {
  361. const idx = parseInt(res[1], 10);
  362. let val = (props as unknown as Record<string, number[]>)[key];
  363. if (val !== undefined) {
  364. if (typeof val === "string") {
  365. try {
  366. val = JSON.parse(val) as number[];
  367. } catch {
  368. // too bad
  369. val = [];
  370. }
  371. }
  372. if (!Array.isArray(val)) {
  373. val = [];
  374. }
  375. if (idx === 0 && val.length && Array.isArray(val[0])) {
  376. for (let i = 0; i < val.length; i++) {
  377. sel = updateArrays(sel, val[i] as unknown as number[], i);
  378. }
  379. } else {
  380. sel = updateArrays(sel, val, idx);
  381. }
  382. }
  383. }
  384. });
  385. return sel;
  386. });
  387. }, [props]);
  388. const config = useDynamicJsonProperty(props.config, props.defaultConfig, defaultConfig);
  389. useEffect(() => {
  390. setDataKeys((oldDtKeys) => {
  391. let changed = false;
  392. const newDtKeys = (config.columns || []).map((columns, idx) => {
  393. const varName = getDataVarName(updateVarName, dataVarNames, idx);
  394. if (varName) {
  395. const [backCols, dtKey] = getDataKey(columns, config.decimators);
  396. changed = changed || idx > oldDtKeys.length || oldDtKeys[idx] !== dtKey;
  397. const lData = getData(data, additionalDatas, idx);
  398. if (lData === undefined || isDataRefresh(lData) || !lData[dtKey]) {
  399. Promise.resolve().then(() =>
  400. dispatch(
  401. createRequestChartUpdateAction(
  402. varName,
  403. id,
  404. module,
  405. backCols,
  406. dtKey,
  407. getDecimatorsPayload(
  408. config.decimators,
  409. plotRef.current,
  410. config.modes,
  411. columns,
  412. config.traces
  413. )
  414. )
  415. )
  416. );
  417. }
  418. return dtKey;
  419. }
  420. return "";
  421. });
  422. return changed ? newDtKeys : oldDtKeys;
  423. });
  424. // eslint-disable-next-line react-hooks/exhaustive-deps
  425. }, [
  426. refresh,
  427. dispatch,
  428. config.columns,
  429. config.traces,
  430. config.modes,
  431. config.decimators,
  432. updateVarName,
  433. dataVarNames,
  434. id,
  435. module,
  436. ]);
  437. useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars);
  438. const layout = useMemo(() => {
  439. const layout = { ...baseLayout };
  440. let template = undefined;
  441. try {
  442. const tpl = props.template && JSON.parse(props.template);
  443. const tplTheme =
  444. theme.palette.mode === "dark"
  445. ? props.template_Dark_
  446. ? JSON.parse(props.template_Dark_)
  447. : darkThemeTemplate
  448. : props.template_Light_ && JSON.parse(props.template_Light_);
  449. template = tpl ? (tplTheme ? { ...tpl, ...tplTheme } : tpl) : tplTheme ? tplTheme : undefined;
  450. } catch (e) {
  451. console.info(`Error while parsing Chart.template\n${(e as Error).message || e}`);
  452. }
  453. if (template) {
  454. layout.template = template;
  455. }
  456. if (props.figure) {
  457. return merge({}, props.figure[0].layout as Partial<Layout>, layout, {
  458. title: title || layout.title || (props.figure[0].layout as Partial<Layout>).title,
  459. clickmode: "event+select",
  460. });
  461. }
  462. return {
  463. ...layout,
  464. autosize: true,
  465. title: title || layout.title,
  466. xaxis: {
  467. title:
  468. config.traces.length && config.traces[0].length && config.traces[0][0]
  469. ? getColNameFromIndexed(config.columns[0][config.traces[0][0]]?.dfid)
  470. : undefined,
  471. ...layout.xaxis,
  472. },
  473. yaxis: {
  474. title:
  475. config.traces.length == 1 && config.traces[0].length > 1 && config.columns[0][config.traces[0][1]]
  476. ? getColNameFromIndexed(config.columns[0][config.traces[0][1]]?.dfid)
  477. : undefined,
  478. ...layout.yaxis,
  479. },
  480. clickmode: "event+select",
  481. } as Layout;
  482. }, [
  483. theme.palette.mode,
  484. title,
  485. config.columns,
  486. config.traces,
  487. baseLayout,
  488. props.template,
  489. props.template_Dark_,
  490. props.template_Light_,
  491. props.figure,
  492. ]);
  493. useEffect(() => {
  494. if (animationData?.__taipy_refresh) {
  495. const animationDataVar = getUpdateVar(updateVars || "", "animationData");
  496. animationDataVar &&
  497. dispatch(createRequestChartUpdateAction(animationDataVar, id, module, [], "", undefined));
  498. }
  499. }, [animationData?.__taipy_refresh, dispatch, id, module, updateVars]);
  500. const runAnimation = useCallback(() => {
  501. return (
  502. plotRef.current &&
  503. plotlyRef.current &&
  504. toFrame.data &&
  505. (toFrame.traces && toFrame.traces.length > 0 ? true : null) &&
  506. plotlyRef.current.animate(
  507. plotRef.current as unknown as PlotlyHTMLElement,
  508. {
  509. ...toFrame,
  510. layout: layout,
  511. },
  512. DEFAULT_ANIMATION_SETTINGS
  513. )
  514. );
  515. }, [layout, toFrame]);
  516. const style = useMemo(
  517. () =>
  518. height === undefined
  519. ? ({ ...defaultStyle, width: width } as CSSProperties)
  520. : ({ ...defaultStyle, width: width, height: height } as CSSProperties),
  521. [width, height]
  522. );
  523. const skelStyle = useMemo(() => ({ ...style, minHeight: "7em" }), [style]);
  524. const dataPl = useMemo(() => {
  525. if (props.figure) {
  526. return lastDataPl.current || [];
  527. }
  528. const dataList = dataKeys.map((_, idx) => getData(data, additionalDatas, idx));
  529. if (!dataList.length || dataList.every((d) => !d || isDataRefresh(d) || !Object.keys(d).length)) {
  530. return lastDataPl.current || [];
  531. }
  532. let changed = false;
  533. let baseDataPl = (lastDataPl.current.length && lastDataPl.current[0]) || {};
  534. const newDataPl = config.traces.map((trace, idx) => {
  535. const currentData = (idx < lastDataPl.current.length && lastDataPl.current[idx]) || baseDataPl;
  536. const dataKey = idx < dataKeys.length ? dataKeys[idx] : dataKeys[0];
  537. const lData = (idx < dataList.length && dataList[idx]) || dataList[0];
  538. if (!lData || isDataRefresh(lData) || !Object.keys(lData).length) {
  539. return currentData;
  540. }
  541. const dtKey = getDataKey(
  542. idx < config.columns?.length ? config.columns[idx] : undefined,
  543. config.decimators
  544. )[1];
  545. if (!dataKey.startsWith(dtKey)) {
  546. return currentData;
  547. }
  548. changed = true;
  549. const datum = lData[dataKey];
  550. const columns = config.columns[idx] || config.columns[0];
  551. const ret = {
  552. ...getArrayValue(config.options, idx, {}),
  553. type: config.types[idx],
  554. mode: config.modes[idx],
  555. name:
  556. getArrayValue(config.names, idx) ||
  557. (columns[trace[1]] ? getColNameFromIndexed(columns[trace[1]].dfid) : undefined),
  558. } as Record<string, unknown>;
  559. ret.marker = { ...getArrayValue(config.markers, idx, ret.marker || {}) };
  560. if (Object.keys(ret.marker as object).length) {
  561. MARKER_TO_COL.forEach((prop) => {
  562. const val = (ret.marker as Record<string, unknown>)[prop];
  563. if (typeof val === "string") {
  564. const arr = getValueFromCol(datum, val as string);
  565. if (arr.length) {
  566. (ret.marker as Record<string, unknown>)[prop] = arr;
  567. }
  568. }
  569. });
  570. } else {
  571. delete ret.marker;
  572. }
  573. const xs = getValue(datum, trace, 0) || [];
  574. const ys = getValue(datum, trace, 1) || [];
  575. const addIndex = getArrayValue(config.addIndex, idx, true) && !ys.length;
  576. const baseX = addIndex ? Array.from(Array(xs.length).keys()) : xs;
  577. const baseY = addIndex ? xs : ys;
  578. const axisNames = config.axisNames.length > idx ? config.axisNames[idx] : ([] as string[]);
  579. if (baseX.length) {
  580. if (axisNames.length > 0) {
  581. ret[axisNames[0]] = baseX;
  582. } else {
  583. ret.x = baseX;
  584. }
  585. }
  586. if (baseY.length) {
  587. if (axisNames.length > 1) {
  588. ret[axisNames[1]] = baseY;
  589. } else {
  590. ret.y = baseY;
  591. }
  592. }
  593. const baseZ = getValue(datum, trace, 2, true);
  594. if (baseZ) {
  595. if (axisNames.length > 2) {
  596. ret[axisNames[2]] = baseZ;
  597. } else {
  598. ret.z = baseZ;
  599. }
  600. }
  601. // Hack for treemap charts: create a fallback 'parents' column if needed
  602. // This works ONLY because 'parents' is the third named axis
  603. // (see __CHART_AXIS in gui/utils/chart_config_builder.py)
  604. else if (config.types[idx] === "treemap" && Array.isArray(ret.labels)) {
  605. ret.parents = Array(ret.labels.length).fill("");
  606. }
  607. // Other axis
  608. for (let i = 3; i < axisNames.length; i++) {
  609. ret[axisNames[i]] = getValue(datum, trace, i, true);
  610. }
  611. ret.text = getValue(datum, config.texts, idx, true);
  612. ret.xaxis = config.xaxis[idx];
  613. ret.yaxis = config.yaxis[idx];
  614. ret.hovertext = getValue(datum, config.labels, idx, true);
  615. ret.meta = {
  616. xAxisName: config.traces[idx][0],
  617. yAxisName: config.traces[idx][1],
  618. };
  619. const selPoints = getArrayValue(selected, idx, []);
  620. if (selPoints?.length) {
  621. ret.selectedpoints = selPoints;
  622. }
  623. ret.orientation = getArrayValue(config.orientations, idx);
  624. ret.line = getArrayValue(config.lines, idx);
  625. ret.textposition = getArrayValue(config.textAnchors, idx);
  626. const selectedMarker = getArrayValue(config.selectedMarkers, idx);
  627. if (selectedMarker) {
  628. ret.selected = { marker: selectedMarker };
  629. }
  630. if (idx == 0) {
  631. baseDataPl = ret;
  632. }
  633. return ret;
  634. });
  635. if (changed) {
  636. lastDataPl.current = newDataPl as ExtendedPlotData[];
  637. }
  638. return lastDataPl.current;
  639. }, [props.figure, selected, data, additionalDatas, config, dataKeys]);
  640. const plotConfig = useMemo(() => {
  641. let plConf: Partial<Config> = {};
  642. if (props.plotConfig) {
  643. try {
  644. plConf = JSON.parse(props.plotConfig);
  645. } catch (e) {
  646. console.info(`Error while parsing Chart.plot_config\n${(e as Error).message || e}`);
  647. }
  648. if (typeof plConf !== "object" || plConf === null || Array.isArray(plConf)) {
  649. console.info("Error Chart.plot_config is not a dictionary");
  650. plConf = {};
  651. }
  652. }
  653. plConf.displaylogo = !!plConf.displaylogo;
  654. plConf.modeBarButtonsToAdd = TaipyPlotlyButtons;
  655. // plConf.responsive = true; // this is the source of the on/off height ...
  656. plConf.autosizable = true;
  657. if (!active) {
  658. plConf.staticPlot = true;
  659. }
  660. return plConf;
  661. }, [active, props.plotConfig]);
  662. const onRelayout = useCallback(
  663. (eventData: PlotRelayoutEvent) => {
  664. onRangeChange && dispatch(createSendActionNameAction(id, module, { action: onRangeChange, ...eventData }));
  665. if (config.decimators && !config.types.includes("scatter3d")) {
  666. const [backCols, dtKeyBase] = getDataKey(
  667. config.columns?.length ? config.columns[0] : undefined,
  668. config.decimators
  669. );
  670. const dtKey = `${dtKeyBase}--${Object.entries(eventData)
  671. .map(([k, v]) => `${k}=${v}`)
  672. .join("-")}`;
  673. setDataKeys((oldDataKeys) => {
  674. if (oldDataKeys.length === 0) {
  675. return [dtKey];
  676. }
  677. if (oldDataKeys[0] !== dtKey) {
  678. Promise.resolve().then(() =>
  679. dispatch(
  680. createRequestChartUpdateAction(
  681. updateVarName,
  682. id,
  683. module,
  684. backCols,
  685. dtKey,
  686. getDecimatorsPayload(
  687. config.decimators,
  688. plotRef.current,
  689. config.modes,
  690. config.columns?.length ? config.columns[0] : {},
  691. config.traces,
  692. eventData
  693. )
  694. )
  695. )
  696. );
  697. return [dtKey, ...oldDataKeys.slice(1)];
  698. }
  699. return oldDataKeys;
  700. });
  701. }
  702. },
  703. [
  704. dispatch,
  705. onRangeChange,
  706. id,
  707. config.modes,
  708. config.columns,
  709. config.traces,
  710. config.types,
  711. config.decimators,
  712. updateVarName,
  713. module,
  714. ]
  715. );
  716. const clickHandler = useCallback(
  717. (evt?: MouseEvent) => {
  718. const map =
  719. (evt?.currentTarget as PlotlyDiv)?._fullLayout?.map ||
  720. (evt?.currentTarget as PlotlyDiv)?._fullLayout?.geo ||
  721. (evt?.currentTarget as PlotlyDiv)?._fullLayout?.mapbox;
  722. const xaxis = map ? map._subplot?.xaxis : (evt?.currentTarget as PlotlyDiv)?._fullLayout?.xaxis;
  723. const yaxis = map ? map._subplot?.yaxis : (evt?.currentTarget as PlotlyDiv)?._fullLayout?.yaxis;
  724. if (!xaxis || !yaxis) {
  725. console.info("clickHandler: Plotly div does not have an xaxis object", evt);
  726. return;
  727. }
  728. const transform = (axis: Axis, delta: keyof DOMRect) => {
  729. const bb = (evt?.target as HTMLDivElement).getBoundingClientRect();
  730. return (pos?: number) => axis.p2d((pos || 0) - (bb[delta] as number));
  731. };
  732. dispatch(
  733. createSendActionNameAction(
  734. id,
  735. module,
  736. lightenPayload({
  737. action: onClick,
  738. lat: map ? yaxis.p2c() : undefined,
  739. y: map ? undefined : transform(yaxis, "top")(evt?.clientY),
  740. lon: map ? xaxis.p2c() : undefined,
  741. x: map ? undefined : transform(xaxis, "left")(evt?.clientX),
  742. })
  743. )
  744. );
  745. },
  746. [dispatch, module, id, onClick]
  747. );
  748. const onInitialized = useCallback(
  749. (figure: Readonly<Figure>, graphDiv: Readonly<HTMLElement>) => {
  750. onClick && graphDiv.addEventListener("click", clickHandler);
  751. plotRef.current = graphDiv as HTMLDivElement;
  752. plotlyRef.current = window.Plotly as unknown as PlotlyObject;
  753. if (animationData) {
  754. runAnimation()?.catch(console.error);
  755. }
  756. },
  757. [onClick, clickHandler, animationData, runAnimation]
  758. );
  759. const getRealIndex = useCallback(
  760. (dataIdx: number, index?: number) => {
  761. const lData = getData(data, additionalDatas, dataIdx);
  762. if (!lData) {
  763. return index || 0;
  764. }
  765. const dtKey = dataKeys[dataIdx];
  766. return typeof index === "number"
  767. ? props.figure
  768. ? index
  769. : lData[dtKey].tp_index
  770. ? (lData[dtKey].tp_index[index] as number)
  771. : index
  772. : 0;
  773. },
  774. [data, additionalDatas, dataKeys, props.figure]
  775. );
  776. const onSelect = useCallback(
  777. (evt?: PlotSelectionEvent) => {
  778. if (updateVars) {
  779. const traces = (evt?.points || []).reduce((tr, pt) => {
  780. tr[pt.curveNumber] = tr[pt.curveNumber] || [];
  781. tr[pt.curveNumber].push(getRealIndex(pt.curveNumber, getPlotIndex(pt)));
  782. return tr;
  783. }, [] as number[][]);
  784. if (config.traces.length === 0) {
  785. // figure
  786. const theVar = getUpdateVar(updateVars, "selected");
  787. theVar && dispatch(createSendUpdateAction(theVar, traces, module, props.onChange, propagate));
  788. return;
  789. }
  790. if (traces.length) {
  791. const upvars = traces.map((_, idx) => getUpdateVar(updateVars, `selected${idx}`));
  792. const setVars = new Set(upvars.filter((v) => v));
  793. if (traces.length > 1 && setVars.size === 1) {
  794. dispatch(
  795. createSendUpdateAction(
  796. setVars.values().next().value,
  797. traces,
  798. module,
  799. props.onChange,
  800. propagate
  801. )
  802. );
  803. return;
  804. }
  805. traces.forEach((tr, idx) => {
  806. if (upvars[idx] && tr && tr.length) {
  807. dispatch(createSendUpdateAction(upvars[idx], tr, module, props.onChange, propagate));
  808. }
  809. });
  810. } else if (config.traces.length === 1) {
  811. const upVar = getUpdateVar(updateVars, "selected0");
  812. if (upVar) {
  813. dispatch(createSendUpdateAction(upVar, [], module, props.onChange, propagate));
  814. }
  815. }
  816. }
  817. },
  818. [getRealIndex, dispatch, updateVars, propagate, props.onChange, config.traces.length, module]
  819. );
  820. useEffect(() => {
  821. if (!dataPl.length || !animationData || isDataRefresh(animationData)) {
  822. return;
  823. }
  824. const animationKeys = Object.keys(animationData) as Array<keyof ExtendedPlotData>;
  825. let found = false;
  826. const toFramesData = dataPl
  827. .map((trace) => {
  828. const traceAnimationKeys = animationKeys.filter(
  829. (key) => trace.hasOwnProperty(key) && Array.isArray(trace[key]) && Array.isArray(animationData[key])
  830. );
  831. if (!traceAnimationKeys.length) {
  832. return undefined;
  833. }
  834. return traceAnimationKeys.reduce(
  835. (tr, key) => {
  836. if (!isEqual(trace[key], animationData[key])) {
  837. found = true;
  838. tr[key] = animationData[key];
  839. }
  840. return tr;
  841. },
  842. { ...trace } as Record<string, unknown>
  843. ) as unknown as ExtendedPlotData;
  844. })
  845. .filter((t) => t);
  846. if (!found) {
  847. return;
  848. }
  849. if (toFramesData.length) {
  850. setToFrame({
  851. data: toFramesData as Data[],
  852. traces: dataPl.map((_, idx) => idx),
  853. });
  854. }
  855. }, [dataPl, animationData]);
  856. useEffect(() => {
  857. if (!plotRef.current || !toFrame.data?.length) {
  858. return;
  859. }
  860. const plotElement = plotRef.current as unknown as PlotlyHTMLElement;
  861. if (!plotElement?.data) {
  862. return;
  863. }
  864. const timer = setTimeout(() => {
  865. if (plotRef.current) {
  866. runAnimation()?.catch(console.error);
  867. }
  868. }, 100);
  869. return () => {
  870. if (timer) {
  871. clearTimeout(timer);
  872. }
  873. };
  874. }, [toFrame.data?.length, runAnimation]);
  875. return render ? (
  876. <Tooltip title={hover || ""}>
  877. <Box id={props.id} className={`${className} ${getComponentClassName(props.children)}`} ref={plotRef}>
  878. <Suspense fallback={<Skeleton key="skeleton" sx={skelStyle} />}>
  879. {Array.isArray(props.figure) && props.figure.length && props.figure[0].data !== undefined ? (
  880. <Plot
  881. data={props.figure[0].data as Data[]}
  882. layout={layout}
  883. style={style}
  884. onRelayout={onRelayout}
  885. onSelected={onSelect}
  886. onDeselect={onSelect}
  887. config={plotConfig}
  888. useResizeHandler
  889. onInitialized={onInitialized}
  890. />
  891. ) : (
  892. <Plot
  893. data={dataPl}
  894. layout={layout}
  895. style={style}
  896. onRelayout={onRelayout}
  897. onSelected={isOnClick(config.types) ? undefined : onSelect}
  898. onDeselect={isOnClick(config.types) ? undefined : onSelect}
  899. onClick={isOnClick(config.types) ? onSelect : undefined}
  900. config={plotConfig}
  901. useResizeHandler
  902. onInitialized={onInitialized}
  903. />
  904. )}
  905. </Suspense>
  906. {props.children}
  907. </Box>
  908. </Tooltip>
  909. ) : null;
  910. };
  911. export default Chart;