Browse Source

Chart animation (#2443)

* demo purposes

* only 1 y value

* resolve flickering issue while animating

* remove test attribute

* small issue

* remove toggle

* remove animation

* resolve comments

* move Plotly to window

* update animation data to TaipyData

* fix unit tests

* per Fred

* per Fab

* minor issue

* move test back to DIV

* fix chart

* fix cast

* cleaning

* Update taipy/gui/viselements.json

Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>

* restore id

* format

* mypy

* Simplify and generalize

* Added doc example for chart animation

* lint

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Co-authored-by: Fred Lefévère-Laoide <90181748+FredLL-Avaiga@users.noreply.github.com>
Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>
Co-authored-by: Fabien Lelaquais <fabien.lelaquais@avaiga.com>
Nam Nguyen 1 month ago
parent
commit
707f050c97

+ 54 - 0
doc/gui/examples/charts/advanced_animation.py

@@ -0,0 +1,54 @@
+# Copyright 2021-2025 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.
+# -----------------------------------------------------------------------------------------
+# To execute this script, make sure that the taipy-gui package is installed in your
+# Python environment and run:
+#     python <script>
+# -----------------------------------------------------------------------------------------
+from math import ceil, cos
+
+from taipy.gui import Gui
+
+# Available waveforms to choose from
+waveforms = ["Sine", "Square"]
+# The initially selected waveform
+waveform = waveforms[0]
+
+# Values for the x axis
+x_range = range(100)
+# Data for the 'Sine' waveform
+cos_data = [cos(i / 6) for i in x_range]
+# Data for the 'Square' waveform
+square_data = [1 if ceil(i / 24) % 2 == 0 else -1 for i in x_range]
+
+# Dataset used by the chart
+data = {
+    "x": x_range,
+    "y": cos_data,
+}
+
+animation_data = None
+
+
+# Triggered when the selected waveform changes
+def change_data(state):
+    # Animate by setting the 'y' values to the selected waveform's
+    state.animation_data = {"y": cos_data if state.waveform == waveforms[0] else square_data}
+
+
+page = """
+<|{waveform}|toggle|lov={waveforms}|on_change=change_data|>
+<|{data}|chart|mode=lines+markers|x=x|y=y|animation_data={animation_data}|>
+"""
+
+
+if __name__ == "__main__":
+    Gui(page).run(title="Chart - Advanced - Animation")

+ 148 - 8
frontend/taipy-gui/src/components/Taipy/Chart.tsx

@@ -17,17 +17,23 @@ import { useTheme } from "@mui/material";
 import Box from "@mui/material/Box";
 import Skeleton from "@mui/material/Skeleton";
 import Tooltip from "@mui/material/Tooltip";
+import isEqual from "lodash/isEqual";
 import merge from "lodash/merge";
 import { nanoid } from "nanoid";
 import {
+    AnimationOpts,
     Config,
     Data,
+    Frame,
     Layout,
     ModeBarButtonAny,
     PlotDatum,
+    PlotData,
+    PlotlyHTMLElement,
     PlotMarker,
     PlotRelayoutEvent,
     PlotSelectionEvent,
+    Root,
     ScatterLine,
 } from "plotly.js";
 import { Figure } from "react-plotly.js";
@@ -53,6 +59,14 @@ import { getArrayValue, getUpdateVar, TaipyActiveProps, TaipyChangeProps } from
 
 const Plot = lazy(() => import("react-plotly.js"));
 
+interface PlotlyObject {
+    animate: (
+        root: Root,
+        frameOrGroupNameOrFrameList?: string | string[] | Partial<Frame> | Array<Partial<Frame>>,
+        opts?: Partial<AnimationOpts>
+    ) => Promise<void>;
+}
+
 interface ChartProp extends TaipyActiveProps, TaipyChangeProps {
     title?: string;
     defaultTitle?: string;
@@ -61,6 +75,7 @@ interface ChartProp extends TaipyActiveProps, TaipyChangeProps {
     defaultConfig: string;
     config?: string;
     data?: Record<string, TraceValueType>;
+    animationData?: Record<string, TraceValueType>;
     //data${number}?: Record<string, TraceValueType>;
     defaultLayout?: string;
     layout?: string;
@@ -187,15 +202,28 @@ const selectedPropRe = /selected(\d+)/;
 
 const MARKER_TO_COL = ["color", "size", "symbol", "opacity", "colors"];
 
+const DEFAULT_ANIMATION_SETTINGS: Partial<AnimationOpts> = {
+    transition: {
+        duration: 500,
+        easing: "cubic-in-out",
+    },
+    frame: {
+        duration: 500,
+    },
+    mode: "immediate",
+};
+
 const isOnClick = (types: string[]) => (types?.length ? types.every((t) => t === "pie") : false);
 
 interface Axis {
     p2c: () => number;
     p2d: (a: number) => number;
 }
+
 interface PlotlyMap {
     _subplot?: { xaxis: Axis; yaxis: Axis };
 }
+
 interface PlotlyDiv extends HTMLDivElement {
     _fullLayout?: {
         map?: PlotlyMap;
@@ -206,6 +234,13 @@ interface PlotlyDiv extends HTMLDivElement {
     };
 }
 
+interface ExtendedPlotData extends PlotData {
+    meta?: {
+        xAxisName?: string;
+        yAxisName?: string;
+    };
+}
+
 interface WithPointNumbers {
     pointNumbers: number[];
 }
@@ -308,15 +343,23 @@ const Chart = (props: ChartProp) => {
         onRangeChange,
         propagate = true,
         onClick,
+        animationData,
     } = props;
     const dispatch = useDispatch();
     const [selected, setSelected] = useState<number[][]>([]);
-    const plotRef = useRef<HTMLDivElement>(null);
+    const plotRef = useRef<HTMLDivElement | null>(null);
+    const plotlyRef = useRef<PlotlyObject | null>(null);
     const [dataKeys, setDataKeys] = useState<string[]>([]);
-    const lastDataPl = useRef<Data[]>([]);
+
+    // animation
+    const [toFrame, setToFrame] = useState<Partial<Frame>>({
+        data: [],
+        traces: [],
+    });
+
+    const lastDataPl = useRef<ExtendedPlotData[]>([]);
     const theme = useTheme();
     const module = useModule();
-
     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);
@@ -489,6 +532,31 @@ const Chart = (props: ChartProp) => {
         props.figure,
     ]);
 
+    useEffect(() => {
+        if (animationData?.__taipy_refresh) {
+            const animationDataVar = getUpdateVar(updateVars || "", "animationData");
+            animationDataVar &&
+                dispatch(createRequestChartUpdateAction(animationDataVar, id, module, [], "", undefined));
+        }
+    }, [animationData?.__taipy_refresh, dispatch, id, module, updateVars]);
+
+    const runAnimation = useCallback(() => {
+        return (
+            plotRef.current &&
+            plotlyRef.current &&
+            toFrame.data &&
+            (toFrame.traces && toFrame.traces.length > 0 ? true : null) &&
+            plotlyRef.current.animate(
+                plotRef.current as unknown as PlotlyHTMLElement,
+                {
+                    ...toFrame,
+                    layout: layout,
+                },
+                DEFAULT_ANIMATION_SETTINGS
+            )
+        );
+    }, [layout, toFrame]);
+
     const style = useMemo(
         () =>
             height === undefined
@@ -589,6 +657,10 @@ const Chart = (props: ChartProp) => {
             ret.xaxis = config.xaxis[idx];
             ret.yaxis = config.yaxis[idx];
             ret.hovertext = getValue(datum, config.labels, idx, true);
+            ret.meta = {
+                xAxisName: config.traces[idx][0],
+                yAxisName: config.traces[idx][1],
+            };
             const selPoints = getArrayValue(selected, idx, []);
             if (selPoints?.length) {
                 ret.selectedpoints = selPoints;
@@ -603,10 +675,10 @@ const Chart = (props: ChartProp) => {
             if (idx == 0) {
                 baseDataPl = ret;
             }
-            return ret as Data;
+            return ret;
         });
         if (changed) {
-            lastDataPl.current = newDataPl;
+            lastDataPl.current = newDataPl as ExtendedPlotData[];
         }
         return lastDataPl.current;
     }, [props.figure, selected, data, additionalDatas, config, dataKeys]);
@@ -696,7 +768,7 @@ const Chart = (props: ChartProp) => {
                 (evt?.currentTarget as PlotlyDiv)?._fullLayout?.geo ||
                 (evt?.currentTarget as PlotlyDiv)?._fullLayout?.mapbox;
             const xaxis = map ? map._subplot?.xaxis : (evt?.currentTarget as PlotlyDiv)?._fullLayout?.xaxis;
-            const yaxis = map ? map._subplot?.xaxis : (evt?.currentTarget as PlotlyDiv)?._fullLayout?.yaxis;
+            const yaxis = map ? map._subplot?.yaxis : (evt?.currentTarget as PlotlyDiv)?._fullLayout?.yaxis;
             if (!xaxis || !yaxis) {
                 console.info("clickHandler: Plotly div does not have an xaxis object", evt);
                 return;
@@ -725,8 +797,14 @@ const Chart = (props: ChartProp) => {
     const onInitialized = useCallback(
         (figure: Readonly<Figure>, graphDiv: Readonly<HTMLElement>) => {
             onClick && graphDiv.addEventListener("click", clickHandler);
+            plotRef.current = graphDiv as HTMLDivElement;
+            plotlyRef.current = window.Plotly as unknown as PlotlyObject;
+
+            if (animationData) {
+                runAnimation()?.catch(console.error);
+            }
         },
-        [onClick, clickHandler]
+        [onClick, clickHandler, animationData, runAnimation]
     );
 
     const getRealIndex = useCallback(
@@ -792,9 +870,71 @@ const Chart = (props: ChartProp) => {
         [getRealIndex, dispatch, updateVars, propagate, props.onChange, config.traces.length, module]
     );
 
+    useEffect(() => {
+        if (!dataPl.length || !animationData || isDataRefresh(animationData)) {
+            return;
+        }
+        const animationKeys = Object.keys(animationData) as Array<keyof ExtendedPlotData>;
+
+        let found = false;
+        const toFramesData = dataPl
+            .map((trace) => {
+                const traceAnimationKeys = animationKeys.filter(
+                    (key) => trace.hasOwnProperty(key) && Array.isArray(trace[key]) && Array.isArray(animationData[key])
+                );
+                if (!traceAnimationKeys.length) {
+                    return undefined;
+                }
+                return traceAnimationKeys.reduce(
+                    (tr, key) => {
+                        if (!isEqual(trace[key], animationData[key])) {
+                            found = true;
+                            tr[key] = animationData[key];
+                        }
+                        return tr;
+                    },
+                    { ...trace } as Record<string, unknown>
+                ) as unknown as ExtendedPlotData;
+            })
+            .filter((t) => t);
+        if (!found) {
+            return;
+        }
+
+        if (toFramesData.length) {
+            setToFrame({
+                data: toFramesData as Data[],
+                traces: dataPl.map((_, idx) => idx),
+            });
+        }
+    }, [dataPl, animationData]);
+
+    useEffect(() => {
+        if (!plotRef.current || !toFrame.data?.length) {
+            return;
+        }
+
+        const plotElement = plotRef.current as unknown as PlotlyHTMLElement;
+        if (!plotElement?.data) {
+            return;
+        }
+
+        const timer = setTimeout(() => {
+            if (plotRef.current) {
+                runAnimation()?.catch(console.error);
+            }
+        }, 100);
+
+        return () => {
+            if (timer) {
+                clearTimeout(timer);
+            }
+        };
+    }, [toFrame.data?.length, runAnimation]);
+
     return render ? (
         <Tooltip title={hover || ""}>
-            <Box id={id} className={`${className} ${getComponentClassName(props.children)}`} ref={plotRef}>
+            <Box id={props.id} className={`${className} ${getComponentClassName(props.children)}`} ref={plotRef}>
                 <Suspense fallback={<Skeleton key="skeleton" sx={skelStyle} />}>
                     {Array.isArray(props.figure) && props.figure.length && props.figure[0].data !== undefined ? (
                         <Plot

+ 1 - 1
frontend/taipy-gui/src/components/Taipy/Expandable.spec.tsx

@@ -24,7 +24,7 @@ describe("Expandable Component", () => {
     it("renders", async () => {
         const { getByText } = render(<Expandable title="foo">bar</Expandable>);
         const elt = getByText("foo");
-        expect(elt.tagName).toBe("SPAN");
+        expect(elt.tagName).toBe("DIV");
     });
     it("displays the right info for string", async () => {
         const { getByText } = render(

+ 4 - 1
frontend/taipy-gui/src/components/Taipy/Metric.tsx

@@ -190,8 +190,11 @@ const Metric = (props: MetricProps) => {
         if (template) {
             layout.template = template;
         }
+
         if (props.title) {
-            layout.title = { text: props.title };
+            layout.title = {
+                text: props.title
+            }
         }
         return layout as Partial<Layout>;
     }, [

+ 3 - 2
frontend/taipy-gui/src/context/taipyReducers.spec.ts

@@ -1142,7 +1142,7 @@ describe("initializeWebSocket function", () => {
                 mockSocket,
                 "ID",
                 "TaipyClientId",
-                { "id": "mockId" },
+                { id: "mockId" },
                 "mockId",
                 undefined,
                 false,
@@ -1167,8 +1167,9 @@ describe("initializeWebSocket function", () => {
         initializeWebSocket(mockSocket, mockDispatch);
         const connectErrorCallback = mockSocket.on.mock.calls.find((call) => call[0] === "connect_error")?.[1];
         expect(connectErrorCallback).toBeDefined();
+
         if (connectErrorCallback) {
-            connectErrorCallback();
+            connectErrorCallback(new Error("connect_error"));
             jest.advanceTimersByTime(500);
             expect(mockSocket.connect).toHaveBeenCalled();
         }

+ 96 - 96
frontend/taipy-gui/webpack.config.js

@@ -16,31 +16,31 @@ const path = require("path");
 const webpack = require("webpack");
 const CopyWebpackPlugin = require("copy-webpack-plugin");
 const HtmlWebpackPlugin = require("html-webpack-plugin");
-const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin');
+const AddAssetHtmlPlugin = require("add-asset-html-webpack-plugin");
 const ESLintPlugin = require("eslint-webpack-plugin");
-const GenerateJsonPlugin = require('generate-json-webpack-plugin');
+const GenerateJsonPlugin = require("generate-json-webpack-plugin");
 
 const resolveApp = relativePath => path.resolve(__dirname, relativePath);
 
-const reactBundle = "taipy-gui-deps"
-const taipyBundle = "taipy-gui"
+const reactBundle = "taipy-gui-deps";
+const taipyBundle = "taipy-gui";
 
-const reactBundleName = "TaipyGuiDependencies"
-const taipyBundleName = "TaipyGui"
-const taipyGuiBaseBundleName = "TaipyGuiBase"
+const reactBundleName = "TaipyGuiDependencies";
+const taipyBundleName = "TaipyGui";
+const taipyGuiBaseBundleName = "TaipyGuiBase";
 
 const basePath = "../../taipy/gui/webapp";
 const webAppPath = resolveApp(basePath);
 const reactManifestPath = resolveApp(basePath + "/" + reactBundle + "-manifest.json");
-const reactDllPath = resolveApp(basePath + "/" + reactBundle + ".dll.js")
-const taipyDllPath = resolveApp(basePath + "/" + taipyBundle + ".js")
+const reactDllPath = resolveApp(basePath + "/" + reactBundle + ".dll.js");
+const taipyDllPath = resolveApp(basePath + "/" + taipyBundle + ".js");
 const taipyGuiBaseExportPath = resolveApp(basePath + "/taipy-gui-base-export");
 
 module.exports = (env, options) => {
     const envVariables = {
-        frontend_version: require(resolveApp('package.json')).version,
+        frontend_version: require(resolveApp("package.json")).version,
         frontend_build_date: new Date().toISOString(),
-        frontend_build_mode: options.mode
+        frontend_build_mode: options.mode,
     };
 
     return [{
@@ -71,7 +71,7 @@ module.exports = (env, options) => {
                 path: webAppPath,
                 library: {
                     name: taipyBundleName,
-                    type: "umd"
+                    type: "umd",
                 },
                 publicPath: "",
             },
@@ -96,7 +96,7 @@ module.exports = (env, options) => {
                             fullySpecified: false,
                         },
                     },
-                ]
+                ],
             },
             plugins: [
                 new ESLintPlugin({
@@ -106,9 +106,9 @@ module.exports = (env, options) => {
                 }),
                 new webpack.DllReferencePlugin({
                     name: reactBundleName,
-                    manifest: reactManifestPath
-                })
-            ]
+                    manifest: reactManifestPath,
+                }),
+            ],
         },
         {
             mode: options.mode, //'development', //'production',
@@ -120,7 +120,7 @@ module.exports = (env, options) => {
                 publicPath: "",
             },
             dependencies: [taipyBundleName, reactBundleName],
-            externals: {"taipy-gui": taipyBundleName},
+            externals: { "taipy-gui": taipyBundleName },
 
             // Enable sourcemaps for debugging webpack's output.
             devtool: options.mode === "development" && "inline-source-map",
@@ -136,20 +136,20 @@ module.exports = (env, options) => {
                         use: "ts-loader",
                         exclude: /node_modules/,
                     },
-                ]
+                ],
             },
 
             plugins: [
                 new CopyWebpackPlugin({
                     patterns: [
                         { from: "../public", filter: (name) => !name.endsWith(".html") },
-                        { from: "../packaging", filter: (name) => !name.includes(".gen.") }
+                        { from: "../packaging", filter: (name) => !name.includes(".gen.") },
                     ],
                 }),
                 new HtmlWebpackPlugin({
                     template: "../public/index.html",
                     hash: true,
-                    ...envVariables
+                    ...envVariables,
                 }),
                 new GenerateJsonPlugin("taipy.status.json", envVariables),
                 new ESLintPlugin({
@@ -159,94 +159,94 @@ module.exports = (env, options) => {
                 }),
                 new webpack.DllReferencePlugin({
                     name: reactBundleName,
-                    manifest: reactManifestPath
+                    manifest: reactManifestPath,
                 }),
                 new AddAssetHtmlPlugin([{
                     filepath: reactDllPath,
-                    hash: true
-                },{
+                    hash: true,
+                }, {
                     filepath: taipyDllPath,
-                    hash: true
+                    hash: true,
                 }]),
             ],
-    },
-    {
-        mode: options.mode,
-        target: "web",
-        entry: {
-            "default": "./base/src/index.ts",
         },
-        output: {
-            filename: (arg) => {
-                if (arg.chunk.name === "default") {
-                    return "taipy-gui-base.js";
-                }
-                return "[name].taipy-gui-base.js";
-            },
-            chunkFilename: "[name].taipy-gui-base.js",
-            path: webAppPath,
-            globalObject: "this",
-            library: {
-                name: taipyGuiBaseBundleName,
-                type: "umd",
+        {
+            mode: options.mode,
+            target: "web",
+            entry: {
+                "default": "./base/src/index.ts",
             },
-        },
-        optimization: {
-            splitChunks: {
-                chunks: 'all',
-                name: "shared",
+            output: {
+                filename: (arg) => {
+                    if (arg.chunk.name === "default") {
+                        return "taipy-gui-base.js";
+                    }
+                    return "[name].taipy-gui-base.js";
+                },
+                chunkFilename: "[name].taipy-gui-base.js",
+                path: webAppPath,
+                globalObject: "this",
+                library: {
+                    name: taipyGuiBaseBundleName,
+                    type: "umd",
+                },
             },
-        },
-        module: {
-            rules: [
-                {
-                    test: /\.tsx?$/,
-                    use: "ts-loader",
-                    exclude: /node_modules/,
+            optimization: {
+                splitChunks: {
+                    chunks: "all",
+                    name: "shared",
                 },
-            ],
-        },
-        resolve: {
-            extensions: [".tsx", ".ts", ".js", ".tsx"],
-        },
-        // externals: {
-        //     "socket.io-client": {
-        //         commonjs: "socket.io-client",
-        //         commonjs2: "socket.io-client",
-        //         amd: "socket.io-client",
-        //         root: "_",
-        //     },
-        // },
-    },
-    {
-        entry: "./base/src/exports.ts",
-        output: {
-            filename: "taipy-gui-base.js",
-            path: taipyGuiBaseExportPath,
-            library: {
-                name: taipyGuiBaseBundleName,
-                type: "umd",
             },
-            publicPath: "",
+            module: {
+                rules: [
+                    {
+                        test: /\.tsx?$/,
+                        use: "ts-loader",
+                        exclude: /node_modules/,
+                    },
+                ],
+            },
+            resolve: {
+                extensions: [".tsx", ".ts", ".js", ".tsx"],
+            },
+            // externals: {
+            //     "socket.io-client": {
+            //         commonjs: "socket.io-client",
+            //         commonjs2: "socket.io-client",
+            //         amd: "socket.io-client",
+            //         root: "_",
+            //     },
+            // },
         },
-        module: {
-            rules: [
-                {
-                    test: /\.tsx?$/,
-                    use: "ts-loader",
-                    exclude: /node_modules/,
+        {
+            entry: "./base/src/exports.ts",
+            output: {
+                filename: "taipy-gui-base.js",
+                path: taipyGuiBaseExportPath,
+                library: {
+                    name: taipyGuiBaseBundleName,
+                    type: "umd",
                 },
-            ],
-        },
-        resolve: {
-            extensions: [".tsx", ".ts", ".js", ".tsx"],
-        },
-        plugins: [
-            new CopyWebpackPlugin({
-                patterns: [
-                    { from: "./base/src/packaging", to: taipyGuiBaseExportPath },
+                publicPath: "",
+            },
+            module: {
+                rules: [
+                    {
+                        test: /\.tsx?$/,
+                        use: "ts-loader",
+                        exclude: /node_modules/,
+                    },
                 ],
-            }),
-        ],
-    }];
+            },
+            resolve: {
+                extensions: [".tsx", ".ts", ".js", ".tsx"],
+            },
+            plugins: [
+                new CopyWebpackPlugin({
+                    patterns: [
+                        { from: "./base/src/packaging", to: taipyGuiBaseExportPath },
+                    ],
+                }),
+            ],
+        }];
 };

+ 1 - 0
taipy/gui/_renderers/factory.py

@@ -132,6 +132,7 @@ class _Factory:
                 ("width", PropertyType.string_or_number),
                 ("height", PropertyType.string_or_number),
                 ("layout", PropertyType.dynamic_dict),
+                ("animation_data", PropertyType.data),
                 ("plot_config", PropertyType.dict),
                 ("on_range_change", PropertyType.function),
                 ("active", PropertyType.dynamic_boolean, True),

+ 4 - 3
taipy/gui/utils/chart_config_builder.py

@@ -192,6 +192,7 @@ def _build_chart_config(  # noqa: C901
         or ({"color": t[_Chart_iprops.color.value]} if t[_Chart_iprops.color.value] else None)
         for t in traces
     ]
+
     opt_cols: t.List[t.Set[str]] = [set()] * len(traces)
     for idx, m in enumerate(markers):
         if isinstance(m, (dict, _MapDict)):
@@ -227,9 +228,9 @@ def _build_chart_config(  # noqa: C901
         [
             c2
             for c2 in [
-                __get_col_from_indexed(c1, i)
-                for c1 in t.cast(dict, col_dicts[i] if i < len(col_dicts) else col_dicts[0]).keys()
-            ]
+            __get_col_from_indexed(c1, i)
+            for c1 in t.cast(dict, col_dicts[i] if i < len(col_dicts) else col_dicts[0]).keys()
+        ]
             if c2
         ]
         for i in range(len(traces))

+ 5 - 0
taipy/gui/viselements.json

@@ -508,6 +508,11 @@
                         "default_value": "\"scatter\"",
                         "doc": "Chart type.<br/>See the Plotly <a href=\"https://plotly.com/javascript/reference/\">chart type</a> documentation for more details."
                     },
+                    {
+                        "name": "animation_data",
+                        "type": "dynamic(Any)",
+                        "doc": "A dictionary representing updated values for the dataset defined in <i>data</i>.<br/>Each key in <i>animation_data</i> must exist in <i>data</i>. Changing this property triggers an animation of the chart, showing the changes between the original (<i>data</i>) and updated (<i>animation_data</i>) values.<br/> This animation functionality currently works only with <i>scatter</i> trace types. If you are using other trace types, the animation may not behave as expected."
+                    },
                     {
                         "name": "mode",
                         "type": "indexed(str)",