/* * Copyright 2021-2024 Avaiga Private Limited * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState, lazy, Suspense } from "react"; import { Config, Data, Layout, ModeBarButtonAny, PlotDatum, PlotMarker, PlotRelayoutEvent, PlotSelectionEvent, ScatterLine, } from "plotly.js"; import Skeleton from "@mui/material/Skeleton"; import Box from "@mui/material/Box"; import Tooltip from "@mui/material/Tooltip"; import { useTheme } from "@mui/material"; import { getArrayValue, getUpdateVar, TaipyActiveProps, TaipyChangeProps } from "./utils"; import { createRequestChartUpdateAction, createSendActionNameAction, createSendUpdateAction, } from "../../context/taipyReducers"; import { ColumnDesc } from "./tableUtils"; import { useClassNames, useDispatch, useDispatchRequestUpdateOnFirstRender, useDynamicJsonProperty, useDynamicProperty, useModule, } from "../../utils/hooks"; import { darkThemeTemplate } from "../../themes/darkThemeTemplate"; const Plot = lazy(() => import("react-plotly.js")); interface ChartProp extends TaipyActiveProps, TaipyChangeProps { title?: string; width?: string | number; height?: string | number; defaultConfig: string; config?: string; data?: Record; defaultLayout?: string; layout?: string; plotConfig?: string; onRangeChange?: string; testId?: string; render?: boolean; defaultRender?: boolean; template?: string; template_Dark_?: string; template_Light_?: string; //[key: `selected_${number}`]: number[]; figure?: Array>; } interface ChartConfig { columns: Record; labels: string[]; modes: string[]; types: string[]; traces: string[][]; xaxis: string[]; yaxis: string[]; markers: Partial[]; selectedMarkers: Partial[]; orientations: string[]; names: string[]; lines: Partial[]; texts: string[]; textAnchors: string[]; options: Record[]; axisNames: Array; addIndex: Array; decimators?: string[]; } export type TraceValueType = Record; const defaultStyle = { position: "relative", display: "inline-block" }; const indexedData = /^(\d+)\/(.*)/; const getColNameFromIndexed = (colName: string): string => { if (colName) { const reRes = indexedData.exec(colName); if (reRes && reRes.length > 2) { return reRes[2] || colName; } } return colName; }; const getValue = ( values: TraceValueType | undefined, arr: T[], idx: number, returnUndefined = false ): (string | number)[] | undefined => { const value = getValueFromCol(values, getArrayValue(arr, idx) as unknown as string); if (!returnUndefined || value.length) { return value; } return undefined; }; const getValueFromCol = (values: TraceValueType | undefined, col: string): (string | number)[] => { if (values) { if (col) { if (Array.isArray(values)) { const reRes = indexedData.exec(col); if (reRes && reRes.length > 2) { return values[parseInt(reRes[1], 10) || 0][reRes[2] || col] || []; } } else { return values[col] || []; } } } return []; }; const getAxis = (traces: string[][], idx: number, columns: Record, axis: number) => { if (traces.length > idx && traces[idx].length > axis && traces[idx][axis] && columns[traces[idx][axis]]) return columns[traces[idx][axis]].dfid; return undefined; }; const getDecimatorsPayload = ( decimators: string[] | undefined, plotDiv: HTMLDivElement | null, modes: string[], columns: Record, traces: string[][], relayoutData?: PlotRelayoutEvent ) => { return decimators ? { width: plotDiv?.clientWidth, height: plotDiv?.clientHeight, decimators: decimators.map((d, i) => d ? { decimator: d, xAxis: getAxis(traces, i, columns, 0), yAxis: getAxis(traces, i, columns, 1), zAxis: getAxis(traces, i, columns, 2), chartMode: modes[i], } : undefined ), relayoutData: relayoutData, } : undefined; }; const selectedPropRe = /selected(\d+)/; const MARKER_TO_COL = ["color", "size", "symbol", "opacity", "colors"]; const isOnClick = (types: string[]) => (types?.length ? types.every((t) => t === "pie") : false); interface WithpointNumbers { pointNumbers: number[]; } const getPlotIndex = (pt: PlotDatum) => pt.pointIndex === undefined ? pt.pointNumber === undefined ? (pt as unknown as WithpointNumbers).pointNumbers?.length ? (pt as unknown as WithpointNumbers).pointNumbers[0] : 0 : pt.pointNumber : pt.pointIndex; const defaultConfig = { columns: {} as Record, labels: [], modes: [], types: [], traces: [], xaxis: [], yaxis: [], markers: [], selectedMarkers: [], orientations: [], names: [], lines: [], texts: [], textAnchors: [], options: [], axisNames: [], addIndex: [], } as ChartConfig; const emptyLayout = {} as Partial; const emptyData = {} as Record; const TaipyPlotlyButtons: ModeBarButtonAny[] = [ { name: "Full screen", title: "Full screen", icon: { height: 24, width: 24, path: "M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z", }, click: function (gd: HTMLElement, evt: Event) { const div = gd.querySelector("div.svg-container") as HTMLDivElement; if (!div) { return; } const { height } = gd.dataset; if (!height) { gd.setAttribute("data-height", getComputedStyle(div).height); } const fs = gd.classList.toggle("full-screen"); (evt.currentTarget as HTMLElement).setAttribute("data-title", fs ? "Exit Full screen" : "Full screen"); if (height && !fs) { div.attributeStyleMap.set("height", height); } window.dispatchEvent(new Event("resize")); }, }, ]; const updateArrays = (sel: number[][], val: number[], idx: number) => { if (idx >= sel.length || val.length !== sel[idx].length || val.some((v, i) => sel[idx][i] != v)) { sel = sel.concat(); // shallow copy sel[idx] = val; } return sel; }; const Chart = (props: ChartProp) => { const { title = "", width = "100%", height, updateVarName, updateVars, id, data = emptyData, onRangeChange, propagate = true, } = props; const dispatch = useDispatch(); const [selected, setSelected] = useState([]); const plotRef = useRef(null); const [dataKey, setDataKey] = useState("__default__"); const lastDataPl = useRef([]); const theme = useTheme(); const module = useModule(); const refresh = typeof data === "number" ? data : 0; const className = useClassNames(props.libClassName, props.dynamicClassName, props.className); const active = useDynamicProperty(props.active, props.defaultActive, true); const render = useDynamicProperty(props.render, props.defaultRender, true); const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined); const baseLayout = useDynamicJsonProperty(props.layout, props.defaultLayout || "", emptyLayout); // get props.selected[i] values useEffect(() => { if (props.figure) { return; } setSelected((sel) => { Object.keys(props).forEach((key) => { const res = selectedPropRe.exec(key); if (res && res.length == 2) { const idx = parseInt(res[1], 10); let val = (props as unknown as Record)[key]; if (val !== undefined) { if (typeof val === "string") { try { val = JSON.parse(val) as number[]; } catch (e) { // too bad val = []; } } if (!Array.isArray(val)) { val = []; } if (idx === 0 && val.length && Array.isArray(val[0])) { for (let i = 0; i < val.length; i++) { sel = updateArrays(sel, val[i] as unknown as number[], i); } } else { sel = updateArrays(sel, val, idx); } } } }); return sel; }); }, [props]); const config = useDynamicJsonProperty(props.config, props.defaultConfig, defaultConfig); useEffect(() => { if (updateVarName && (refresh || !data[dataKey])) { const backCols = Object.values(config.columns).map((col) => col.dfid); const dtKey = backCols.join("-") + (config.decimators ? `--${config.decimators.join("")}` : ""); setDataKey(dtKey); if (refresh || !data[dtKey]) { dispatch( createRequestChartUpdateAction( updateVarName, id, module, backCols, dtKey, getDecimatorsPayload( config.decimators, plotRef.current, config.modes, config.columns, config.traces ) ) ); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh, dispatch, config.columns, config.traces, config.modes, config.decimators, updateVarName, id, module]); useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars); const layout = useMemo(() => { const layout = { ...baseLayout }; let template = undefined; try { const tpl = props.template && JSON.parse(props.template); const tplTheme = theme.palette.mode === "dark" ? props.template_Dark_ ? JSON.parse(props.template_Dark_) : darkThemeTemplate : props.template_Light_ && JSON.parse(props.template_Light_); template = tpl ? (tplTheme ? { ...tpl, ...tplTheme } : tpl) : tplTheme ? tplTheme : undefined; } catch (e) { console.info(`Error while parsing Chart.template\n${(e as Error).message || e}`); } if (template) { layout.template = template; } if (props.figure) { return { ...(props.figure[0].layout as Partial), ...layout, title: title || layout.title || (props.figure[0].layout as Partial).title, clickmode: "event+select", } as Layout; } return { ...layout, autosize: true, title: title || layout.title, xaxis: { title: config.traces.length && config.traces[0].length && config.traces[0][0] ? getColNameFromIndexed(config.columns[config.traces[0][0]]?.dfid) : undefined, ...layout.xaxis, }, yaxis: { title: config.traces.length == 1 && config.traces[0].length > 1 && config.columns[config.traces[0][1]] ? getColNameFromIndexed(config.columns[config.traces[0][1]]?.dfid) : undefined, ...layout.yaxis, }, clickmode: "event+select", } as Layout; }, [ theme.palette.mode, title, config.columns, config.traces, baseLayout, props.template, props.template_Dark_, props.template_Light_, props.figure, ]); const style = useMemo( () => height === undefined ? ({ ...defaultStyle, width: width } as CSSProperties) : ({ ...defaultStyle, width: width, height: height } as CSSProperties), [width, height] ); const skelStyle = useMemo(() => ({ ...style, minHeight: "7em" }), [style]); const dataPl = useMemo(() => { if (props.figure) { return lastDataPl.current; } if (typeof data === "number" && lastDataPl.current) { return lastDataPl.current; } const datum = data[dataKey]; lastDataPl.current = datum ? config.traces.map((trace, idx) => { const ret = { ...getArrayValue(config.options, idx, {}), type: config.types[idx], mode: config.modes[idx], name: getArrayValue(config.names, idx) || (config.columns[trace[1]] ? getColNameFromIndexed(config.columns[trace[1]].dfid) : undefined), } as Record; ret.marker = { ...getArrayValue(config.markers, idx, ret.marker || {}) }; if (Object.keys(ret.marker as object).length) { MARKER_TO_COL.forEach((prop) => { const val = (ret.marker as Record)[prop]; if (typeof val === "string") { const arr = getValueFromCol(datum, val as string); if (arr.length) { (ret.marker as Record)[prop] = arr; } } }); } else { delete ret.marker; } const xs = getValue(datum, trace, 0) || []; const ys = getValue(datum, trace, 1) || []; const addIndex = getArrayValue(config.addIndex, idx, true) && !ys.length; const baseX = addIndex ? Array.from(Array(xs.length).keys()) : xs; const baseY = addIndex ? xs : ys; const axisNames = config.axisNames.length > idx ? config.axisNames[idx] : ([] as string[]); if (baseX.length) { if (axisNames.length > 0) { ret[axisNames[0]] = baseX; } else { ret.x = baseX; } } if (baseY.length) { if (axisNames.length > 1) { ret[axisNames[1]] = baseY; } else { ret.y = baseY; } } const baseZ = getValue(datum, trace, 2, true); if (baseZ) { if (axisNames.length > 2) { ret[axisNames[2]] = baseZ; } else { ret.z = baseZ; } } // Hack for treemap charts: create a fallback 'parents' column if needed // This works ONLY because 'parents' is the third named axis // (see __CHART_AXIS in gui/utils/chart_config_builder.py) else if (config.types[idx] === "treemap" && Array.isArray(ret.labels)) { ret.parents = Array(ret.labels.length).fill(""); } // Other axis for (let i = 3; i < axisNames.length; i++) { ret[axisNames[i]] = getValue(datum, trace, i, true); } ret.text = getValue(datum, config.texts, idx, true); ret.xaxis = config.xaxis[idx]; ret.yaxis = config.yaxis[idx]; ret.hovertext = getValue(datum, config.labels, idx, true); const selPoints = getArrayValue(selected, idx, []); if (selPoints?.length) { ret.selectedpoints = selPoints; } ret.orientation = getArrayValue(config.orientations, idx); ret.line = getArrayValue(config.lines, idx); ret.textposition = getArrayValue(config.textAnchors, idx); const selectedMarker = getArrayValue(config.selectedMarkers, idx); if (selectedMarker) { ret.selected = { marker: selectedMarker }; } return ret as Data; }) : []; return lastDataPl.current; }, [props.figure, selected, data, config, dataKey]); const plotConfig = useMemo(() => { let plconf: Partial = {}; if (props.plotConfig) { try { plconf = JSON.parse(props.plotConfig); } catch (e) { console.info(`Error while parsing Chart.plot_config\n${(e as Error).message || e}`); } if (typeof plconf !== "object" || plconf === null || Array.isArray(plconf)) { console.info("Error Chart.plot_config is not a dictionary"); plconf = {}; } } plconf.modeBarButtonsToAdd = TaipyPlotlyButtons; // plconf.responsive = true; // this is the source of the on/off height ... plconf.autosizable = true; if (!active) { plconf.staticPlot = true; } return plconf; }, [active, props.plotConfig]); const onRelayout = useCallback( (eventData: PlotRelayoutEvent) => { onRangeChange && dispatch(createSendActionNameAction(id, module, { action: onRangeChange, ...eventData })); if (config.decimators && !config.types.includes("scatter3d")) { const backCols = Object.values(config.columns).map((col) => col.dfid); const eventDataKey = Object.entries(eventData) .map(([k, v]) => `${k}=${v}`) .join("-"); const dtKey = backCols.join("-") + (config.decimators ? `--${config.decimators.join("")}` : "") + "--" + eventDataKey; setDataKey(dtKey); dispatch( createRequestChartUpdateAction( updateVarName, id, module, backCols, dtKey, getDecimatorsPayload( config.decimators, plotRef.current, config.modes, config.columns, config.traces, eventData ) ) ); } }, [ dispatch, onRangeChange, id, config.modes, config.columns, config.traces, config.types, config.decimators, updateVarName, module, ] ); const onAfterPlot = useCallback(() => { // Manage loading Animation ... One day }, []); const getRealIndex = useCallback( (index?: number) => typeof index === "number" ? props.figure ? index : data[dataKey].tp_index ? (data[dataKey].tp_index[index] as number) : index : 0, [data, dataKey, props.figure] ); const onSelect = useCallback( (evt?: PlotSelectionEvent) => { if (updateVars) { const traces = (evt?.points || []).reduce((tr, pt) => { tr[pt.curveNumber] = tr[pt.curveNumber] || []; tr[pt.curveNumber].push(getRealIndex(getPlotIndex(pt))); return tr; }, [] as number[][]); if (traces.length) { const upvars = traces.map((_, idx) => getUpdateVar(updateVars, `selected${idx}`)); if (traces.length > 1 && new Set(upvars).size === 1) { dispatch(createSendUpdateAction(upvars[0], traces, module, props.onChange, propagate)); return; } traces.forEach((tr, idx) => { if (upvars[idx] && tr && tr.length) { dispatch(createSendUpdateAction(upvars[idx], tr, module, props.onChange, propagate)); } }); } else if (config.traces.length === 1) { const upvar = getUpdateVar(updateVars, "selected0"); if (upvar) { dispatch(createSendUpdateAction(upvar, [], module, props.onChange, propagate)); } } } }, [getRealIndex, dispatch, updateVars, propagate, props.onChange, config.traces.length, module] ); return render ? ( }> {Array.isArray(props.figure) && props.figure.length && props.figure[0].data !== undefined ? ( ) : ( )} ) : null; }; export default Chart;