瀏覽代碼

Chart: support click via on_click property (#1747)

* support map click via on_map_click property
plotly.js has deprecated mapbox => move to mapLibre
resolves #1739

* doc

* typo

* support all kind of charts
rename on_map_click => on_click

* doc

* Fab's comment

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

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>
Fred Lefévère-Laoide 8 月之前
父節點
當前提交
880f3438c2

+ 2 - 2
frontend/taipy-gui/src/components/Taipy/Chart.spec.tsx

@@ -90,14 +90,14 @@ const mapConfig = JSON.stringify({
     traces: [["Lat", "Lon"]],
     xaxis: ["x"],
     yaxis: ["y"],
-    types: ["scattermapbox"],
+    types: ["scattermap"],
     modes: ["markers"],
     axisNames: [["lon", "lat"]],
 });
 
 const mapLayout = JSON.stringify({
     dragmode: "zoom",
-    mapbox: { style: "open-street-map", center: { lat: 38, lon: -90 }, zoom: 3 },
+    map: { style: "open-street-map", center: { lat: 38, lon: -90 }, zoom: 3 },
     margin: { r: 0, t: 0, b: 0, l: 0 },
 });
 

+ 64 - 7
frontend/taipy-gui/src/components/Taipy/Chart.tsx

@@ -44,6 +44,8 @@ import {
     useModule,
 } from "../../utils/hooks";
 import { darkThemeTemplate } from "../../themes/darkThemeTemplate";
+import { Figure } from "react-plotly.js";
+import { ligthenPayload } from "../../context/wsUtils";
 
 const Plot = lazy(() => import("react-plotly.js"));
 
@@ -66,6 +68,7 @@ interface ChartProp extends TaipyActiveProps, TaipyChangeProps {
     template_Light_?: string;
     //[key: `selected_${number}`]: number[];
     figure?: Array<Record<string, unknown>>;
+    onClick?: string;
 }
 
 interface ChartConfig {
@@ -175,6 +178,23 @@ const MARKER_TO_COL = ["color", "size", "symbol", "opacity", "colors"];
 
 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;
+        geo?: PlotlyMap;
+        mapbox?: PlotlyMap;
+        xaxis?: Axis;
+        yaxis?: Axis;
+    };
+}
+
 interface WithpointNumbers {
     pointNumbers: number[];
 }
@@ -263,6 +283,7 @@ const Chart = (props: ChartProp) => {
         data = emptyData,
         onRangeChange,
         propagate = true,
+        onClick,
     } = props;
     const dispatch = useDispatch();
     const [selected, setSelected] = useState<number[][]>([]);
@@ -575,9 +596,45 @@ const Chart = (props: ChartProp) => {
         ]
     );
 
-    const onAfterPlot = useCallback(() => {
-        // Manage loading Animation ... One day
-    }, []);
+    const clickHandler = useCallback(
+        (evt?: MouseEvent) => {
+            const map =
+                (evt?.currentTarget as PlotlyDiv)?._fullLayout?.map ||
+                (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;
+            if (!xaxis || !yaxis) {
+                console.info("clickHandler: Plotly div does not have an xaxis object", evt);
+                return;
+            }
+            const transform = (axis: Axis, delta: keyof DOMRect) => {
+                const bb = (evt?.target as HTMLDivElement).getBoundingClientRect();
+                return (pos?: number) => axis.p2d((pos || 0) - (bb[delta] as number));
+            };
+            dispatch(
+                createSendActionNameAction(
+                    id,
+                    module,
+                    ligthenPayload({
+                        action: onClick,
+                        lat: map ? yaxis.p2c() : undefined,
+                        y: map ? undefined : transform(yaxis, "top")(evt?.clientY),
+                        lon: map ? xaxis.p2c() : undefined,
+                        x: map ? undefined : transform(xaxis, "left")(evt?.clientX),
+                    })
+                )
+            );
+        },
+        [dispatch, module, id, onClick]
+    );
+
+    const onInitialized = useCallback(
+        (figure: Readonly<Figure>, graphDiv: Readonly<HTMLElement>) => {
+            onClick && graphDiv.addEventListener("click", clickHandler);
+        },
+        [onClick, clickHandler]
+    );
 
     const getRealIndex = useCallback(
         (index?: number) =>
@@ -585,8 +642,8 @@ const Chart = (props: ChartProp) => {
                 ? props.figure
                     ? index
                     : data[dataKey].tp_index
-                      ? (data[dataKey].tp_index[index] as number)
-                      : index
+                    ? (data[dataKey].tp_index[index] as number)
+                    : index
                 : 0,
         [data, dataKey, props.figure]
     );
@@ -631,11 +688,11 @@ const Chart = (props: ChartProp) => {
                             layout={layout}
                             style={style}
                             onRelayout={onRelayout}
-                            onAfterPlot={onAfterPlot}
                             onSelected={onSelect}
                             onDeselect={onSelect}
                             config={plotConfig}
                             useResizeHandler
+                            onInitialized={onInitialized}
                         />
                     ) : (
                         <Plot
@@ -643,12 +700,12 @@ const Chart = (props: ChartProp) => {
                             layout={layout}
                             style={style}
                             onRelayout={onRelayout}
-                            onAfterPlot={onAfterPlot}
                             onSelected={isOnClick(config.types) ? undefined : onSelect}
                             onDeselect={isOnClick(config.types) ? undefined : onSelect}
                             onClick={isOnClick(config.types) ? onSelect : undefined}
                             config={plotConfig}
                             useResizeHandler
+                            onInitialized={onInitialized}
                         />
                     )}
                 </Suspense>

+ 3 - 0
frontend/taipy-gui/src/themes/darkThemeTemplate.ts

@@ -272,6 +272,9 @@ export const darkThemeTemplate = {
         mapbox: {
             style: "dark",
         },
+        map: {
+            style: "dark",
+        },
         paper_bgcolor: "rgb(17,17,17)",
         plot_bgcolor: "rgb(17,17,17)",
         polar: {

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

@@ -121,6 +121,7 @@ class _Factory:
                 ("template[dark]", PropertyType.dict, gui._get_config("chart_dark_template", None)),
                 ("template[light]", PropertyType.dict),
                 ("figure", PropertyType.to_json),
+                ("on_click", PropertyType.function),
             ]
         )
         ._get_chart_config("scatter", "lines+markers")

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

@@ -69,10 +69,14 @@ __CHART_AXIS: t.Dict[str, t.Tuple[_Chart_iprops, ...]] = {
         _Chart_iprops.low,
     ),
     "choropleth": (_Chart_iprops.locations, _Chart_iprops.z),
+    "choroplethmap": (_Chart_iprops.locations, _Chart_iprops.z),
+    "choroplethmapbox": (_Chart_iprops.locations, _Chart_iprops.z),
+    "densitymap": (_Chart_iprops.lon, _Chart_iprops.lat, _Chart_iprops.z),
     "densitymapbox": (_Chart_iprops.lon, _Chart_iprops.lat, _Chart_iprops.z),
     "funnelarea": (_Chart_iprops.values,),
     "pie": (_Chart_iprops.values, _Chart_iprops.labels),
     "scattergeo": (_Chart_iprops.lon, _Chart_iprops.lat),
+    "scattermap": (_Chart_iprops.lon, _Chart_iprops.lat),
     "scattermapbox": (_Chart_iprops.lon, _Chart_iprops.lat),
     "scatterpolar": (_Chart_iprops.r, _Chart_iprops.theta),
     "scatterpolargl": (_Chart_iprops.r, _Chart_iprops.theta),

+ 19 - 0
taipy/gui/viselements.json

@@ -674,6 +674,25 @@
                         "name": "figure",
                         "type": "dynamic(plotly.graph_objects.Figure)",
                         "doc": "A figure as produced by plotly."
+                    },
+                    {
+                        "name": "on_click",
+                        "type": "Callable",
+                        "doc": "The callback that is invoked when the user clicks in the chart background.<br/>The function receives three parameters:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the chart control if it has one.</li>\n<li>payload (dict[str, any]): a dictionary containing the <i>x</i> and <i>y</i> coordinates of the click <b>or</b> <i>latitude</i> and <i>longitude</i> in the case of a map. This feature relies on non-public Plotly structured information.</li>\n</ul>",
+                        "signature": [
+                            [
+                                "state",
+                                "State"
+                            ],
+                            [
+                                "id",
+                                "str"
+                            ],
+                            [
+                                "payload",
+                                "dict"
+                            ]
+                        ]
                     }
                 ]
             }

+ 3 - 3
tests/gui/builder/control/test_chart.py

@@ -157,13 +157,13 @@ def test_map_builder(gui: Gui, helpers):
     marker = {"color": "fuchsia", "size": 4}  # noqa: F841
     layout = {  # noqa: F841
         "dragmode": "zoom",
-        "mapbox": {"style": "open-street-map", "center": {"lat": 38, "lon": -90}, "zoom": 3},
+        "map": {"style": "open-street-map", "center": {"lat": 38, "lon": -90}, "zoom": 3},
         "margin": {"r": 0, "t": 0, "b": 0, "l": 0},
     }
     with tgb.Page(frame=None) as page:
         tgb.chart(  # type: ignore[attr-defined]
             data="{mapData}",
-            type="scattermapbox",
+            type="scattermap",
             marker="{marker}",
             layout="{layout}",
             lat="Lat",
@@ -177,7 +177,7 @@ def test_map_builder(gui: Gui, helpers):
         "&quot;Lat&quot;: &#x7B;&quot;index&quot;:",
         "&quot;Lon&quot;: &#x7B;&quot;index&quot;:",
         "data={_TpD_tpec_TpExPr_mapData_TPMDL_0}",
-        'defaultLayout="{&quot;dragmode&quot;: &quot;zoom&quot;, &quot;mapbox&quot;: &#x7B;&quot;style&quot;: &quot;open-street-map&quot;, &quot;center&quot;: &#x7B;&quot;lat&quot;: 38, &quot;lon&quot;: -90&#x7D;, &quot;zoom&quot;: 3&#x7D;, &quot;margin&quot;: &#x7B;&quot;r&quot;: 0, &quot;t&quot;: 0, &quot;b&quot;: 0, &quot;l&quot;: 0&#x7D;}"',  # noqa: E501
+        'defaultLayout="{&quot;dragmode&quot;: &quot;zoom&quot;, &quot;map&quot;: &#x7B;&quot;style&quot;: &quot;open-street-map&quot;, &quot;center&quot;: &#x7B;&quot;lat&quot;: 38, &quot;lon&quot;: -90&#x7D;, &quot;zoom&quot;: 3&#x7D;, &quot;margin&quot;: &#x7B;&quot;r&quot;: 0, &quot;t&quot;: 0, &quot;b&quot;: 0, &quot;l&quot;: 0&#x7D;}"',  # noqa: E501
         'updateVarName="_TpD_tpec_TpExPr_mapData_TPMDL_0"',
     ]
     helpers.test_control_builder(gui, page, expected_list)

+ 3 - 3
tests/gui/control/test_chart.py

@@ -143,17 +143,17 @@ def test_map_md(gui: Gui, helpers):
     marker = {"color": "fuchsia", "size": 4}  # noqa: F841
     layout = {  # noqa: F841
         "dragmode": "zoom",
-        "mapbox": {"style": "open-street-map", "center": {"lat": 38, "lon": -90}, "zoom": 3},
+        "map": {"style": "open-street-map", "center": {"lat": 38, "lon": -90}, "zoom": 3},
         "margin": {"r": 0, "t": 0, "b": 0, "l": 0},
     }
-    md = "<|{mapData}|chart|type=scattermapbox|marker={marker}|layout={layout}|lat=Lat|lon=Lon|text=Globvalue|mode=markers|>"  # noqa: E501
+    md = "<|{mapData}|chart|type=scattermap|marker={marker}|layout={layout}|lat=Lat|lon=Lon|text=Globvalue|mode=markers|>"  # noqa: E501
     gui._set_frame(inspect.currentframe())
     expected_list = [
         "<Chart",
         "&quot;Lat&quot;: &#x7B;&quot;index&quot;:",
         "&quot;Lon&quot;: &#x7B;&quot;index&quot;:",
         "data={_TpD_tpec_TpExPr_mapData_TPMDL_0}",
-        'defaultLayout="{&quot;dragmode&quot;: &quot;zoom&quot;, &quot;mapbox&quot;: &#x7B;&quot;style&quot;: &quot;open-street-map&quot;, &quot;center&quot;: &#x7B;&quot;lat&quot;: 38, &quot;lon&quot;: -90&#x7D;, &quot;zoom&quot;: 3&#x7D;, &quot;margin&quot;: &#x7B;&quot;r&quot;: 0, &quot;t&quot;: 0, &quot;b&quot;: 0, &quot;l&quot;: 0&#x7D;}"',  # noqa: E501
+        'defaultLayout="{&quot;dragmode&quot;: &quot;zoom&quot;, &quot;map&quot;: &#x7B;&quot;style&quot;: &quot;open-street-map&quot;, &quot;center&quot;: &#x7B;&quot;lat&quot;: 38, &quot;lon&quot;: -90&#x7D;, &quot;zoom&quot;: 3&#x7D;, &quot;margin&quot;: &#x7B;&quot;r&quot;: 0, &quot;t&quot;: 0, &quot;b&quot;: 0, &quot;l&quot;: 0&#x7D;}"',  # noqa: E501
         'updateVarName="_TpD_tpec_TpExPr_mapData_TPMDL_0"',
     ]
     helpers.test_control_md(gui, md, expected_list)