Repository: Graylog2/graylog2-server (language: java)

Issue:
Worldmap visualization - update preview ignores parameter changes
## Expected Behavior
When changing parameters (Zoom, Latitude and Longitude) and pressing `Update preview`, I'd like to see updated view of the map.

## Current Behavior
When changing parameters (Zoom, Latitude and Longitude) and pressing `Update preview`, map remains unchanged. I have to leave editing mode by pressing `Update widget` to see the effect of those changes.

![image](https://github.com/Graylog2/graylog2-server/assets/100699120/9e86dd21-7def-41ce-b39b-bbed3333d730)



## Your Environment

* Graylog Version: 6.0.0




Discussion / hints:

Fix patch:
diff --git a/changelog/unreleased/issue-19002.toml b/changelog/unreleased/issue-19002.toml
new file mode 100644
index 000000000000..69e4d8255385
--- /dev/null
+++ b/changelog/unreleased/issue-19002.toml
@@ -0,0 +1,5 @@
+type = "fixed"
+message = "Update Map when center/zoom parameters have been changed manually."
+
+issues = ["19002"]
+pulls = ["22220"]
diff --git a/graylog2-web-interface/src/views/components/visualizations/worldmap/MapVisualization.tsx b/graylog2-web-interface/src/views/components/visualizations/worldmap/MapVisualization.tsx
index 8125b5ad5546..80ae6a4918b1 100644
--- a/graylog2-web-interface/src/views/components/visualizations/worldmap/MapVisualization.tsx
+++ b/graylog2-web-interface/src/views/components/visualizations/worldmap/MapVisualization.tsx
@@ -14,7 +14,8 @@
  * along with this program. If not, see
  * <http://www.mongodb.com/licensing/server-side-public-license>.
  */
-import React from 'react';
+import * as React from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
 import { CircleMarker, MapContainer, Popup, TileLayer, useMap, useMapEvents } from 'react-leaflet';
 import chroma from 'chroma-js';
 import flatten from 'lodash/flatten';
@@ -26,10 +27,7 @@ import style from './MapVisualization.css';
 
 import InteractiveContext from '../../contexts/InteractiveContext';
 
-const DEFAULT_VIEWPORT = {
-  center: [0, 0],
-  zoom: 1,
-};
+const DEFAULT_VIEWPORT = Viewport.create([0, 0], 1);
 
 type MapVisualizationProps = {
   attribution?: string;
@@ -125,7 +123,9 @@ const MapEvents = ({
   const _onViewportChanged = () => {
     const { lat, lng } = map.getCenter();
 
-    return onViewportChanged(Viewport.create([lat, lng], map.getZoom()));
+    return onViewportChanged(
+      Viewport.create([Number.parseFloat(lat.toFixed(4)), Number.parseFloat(lng.toFixed(4))], map.getZoom()),
+    );
   };
 
   useMapEvents({
@@ -136,121 +136,127 @@ const MapEvents = ({
   return null;
 };
 
-class MapVisualization extends React.Component<MapVisualizationProps> {
-  _isMapReady = false;
-
-  _areTilesReady = false;
-
-  static defaultProps = {
-    data: {},
-    url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
-    attribution: '&copy; <a href="http://osm.org/copyright" target="_blank">OpenStreetMap</a> contributors',
-    onRenderComplete: () => {},
-    locked: false,
-    viewport: DEFAULT_VIEWPORT,
-    markerRadiusSize: 10,
-    markerRadiusIncrementSize: 10,
-  };
-
-  componentDidMount() {
+type MapRef = React.ComponentProps<typeof MapContainer>['ref'];
+
+const defaultOnRenderComplete = () => {};
+const MapVisualization = ({
+  attribution = '&copy; <a href="http://osm.org/copyright" target="_blank">OpenStreetMap</a> contributors',
+  data,
+  height,
+  id,
+  locked = false,
+  markerRadiusIncrementSize = 10,
+  markerRadiusSize = 10,
+  onChange,
+  onRenderComplete = defaultOnRenderComplete,
+  url = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
+  viewport = DEFAULT_VIEWPORT,
+  width,
+}: MapVisualizationProps) => {
+  const [_isMapReady, setIsMapReady] = useState<boolean>(false);
+  const [_areTilesReady, setAreTilesReady] = useState<boolean>(false);
+  const [_viewport, setViewport] = useState<Viewport>(DEFAULT_VIEWPORT);
+  const _map: MapRef = useRef();
+
+  useEffect(() => {
     leafletStyles.use();
-  }
 
-  componentWillUnmount() {
-    leafletStyles.unuse();
-  }
+    return leafletStyles.unuse;
+  }, []);
 
-  _handleRenderComplete = () => {
-    if (this._areTilesReady && this._isMapReady) {
-      const { onRenderComplete } = this.props;
+  useEffect(() => {
+    if (_viewport && _map?.current) {
+      if (viewport.center !== _viewport.center || viewport.zoom !== _viewport.zoom) {
+        _map.current.setView([viewport.center[0], viewport.center[1]], viewport.zoom);
+      }
+    }
+    // Leaving out _viewport from dependencies, so it only runs when prop has changed.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [viewport.center, viewport.zoom]);
 
+  const _handleRenderComplete = useCallback(() => {
+    if (_areTilesReady && _isMapReady) {
       onRenderComplete();
     }
-  };
+  }, [_areTilesReady, _isMapReady, onRenderComplete]);
 
-  _handleMapReady = () => {
-    this._isMapReady = true;
-    this._handleRenderComplete();
-  };
+  const _handleMapReady = useCallback(() => {
+    setIsMapReady(true);
+    _handleRenderComplete();
+  }, [_handleRenderComplete]);
 
-  _handleTilesReady = () => {
-    this._areTilesReady = true;
-    this._handleRenderComplete();
-  };
+  const _handleTilesReady = useCallback(() => {
+    setAreTilesReady(true);
+    _handleRenderComplete();
+  }, [_handleRenderComplete]);
 
-  render() {
-    const {
-      data,
-      id,
-      height,
-      width,
-      url,
-      attribution,
-      locked,
-      viewport,
-      onChange,
-      markerRadiusSize,
-      markerRadiusIncrementSize,
-    } = this.props;
-
-    const noOfKeys = data.length;
-    const chromaScale = chroma.scale('Spectral');
-
-    const markers = data.flatMap(({ keys, name, values }, idx) => {
-      const y = Object.values(values);
-      const min = Math.min(...y);
-      const max = Math.max(...y);
-      const color = chromaScale(idx * (1 / noOfKeys));
-
-      return Object.entries(values).map(([coord, value], valueIdx) => (
-        <Marker
-          key={`${name}-${coord}-${value}`}
-          coordinates={coord}
-          value={value}
-          min={min}
-          max={max}
-          radiusSize={markerRadiusSize}
-          increment={markerRadiusIncrementSize}
-          color={color}
-          name={name}
-          keys={keys[valueIdx]}
-        />
-      ));
-    });
-
-    return (
-      <InteractiveContext.Consumer>
-        {(interactive) => (
-          <div className={locked ? style.mapLocked : ''} style={{ position: 'relative', zIndex: 0 }}>
-            {locked && <div className={style.overlay} style={{ height, width }} />}
-            <MapContainer
-              boundsOptions={{ maxZoom: 19, animate: interactive }}
-              center={viewport.center}
-              className={style.map}
-              closePopupOnClick={interactive}
-              doubleClickZoom={interactive}
-              dragging={interactive}
-              fadeAnimation={interactive}
-              id={`visualization-${id}`}
-              key={`visualization-${id}-${width}-${height}`}
-              markerZoomAnimation={interactive}
-              scrollWheelZoom={interactive}
-              style={{ height, width }}
-              touchZoom={interactive}
-              trackResize={interactive}
-              whenReady={this._handleMapReady}
-              zoom={viewport.zoom}
-              zoomAnimation={interactive}
-              zoomControl={interactive}>
-              <MapEvents onViewportChanged={onChange} />
-              <TileLayer url={url} attribution={attribution} eventHandlers={{ load: this._handleTilesReady }} />
-              {markers}
-            </MapContainer>
-          </div>
-        )}
-      </InteractiveContext.Consumer>
-    );
-  }
-}
+  const _onChange = useCallback(
+    (newViewport: Viewport) => {
+      setViewport(newViewport);
+
+      return onChange(newViewport);
+    },
+    [onChange],
+  );
+
+  const noOfKeys = data.length;
+  const chromaScale = chroma.scale('Spectral');
+
+  const markers = data.flatMap(({ keys, name, values }, idx) => {
+    const y = Object.values(values);
+    const min = Math.min(...y);
+    const max = Math.max(...y);
+    const color = chromaScale(idx * (1 / noOfKeys));
+
+    return Object.entries(values).map(([coord, value], valueIdx) => (
+      <Marker
+        key={`${name}-${coord}-${value}`}
+        coordinates={coord}
+        value={value}
+        min={min}
+        max={max}
+        radiusSize={markerRadiusSize}
+        increment={markerRadiusIncrementSize}
+        color={color}
+        name={name}
+        keys={keys[valueIdx]}
+      />
+    ));
+  });
+
+  return (
+    <InteractiveContext.Consumer>
+      {(interactive) => (
+        <div className={locked ? style.mapLocked : ''} style={{ position: 'relative', zIndex: 0 }}>
+          {locked && <div className={style.overlay} style={{ height, width }} />}
+          <MapContainer
+            ref={_map}
+            boundsOptions={{ maxZoom: 19, animate: interactive }}
+            center={viewport.center}
+            className={style.map}
+            closePopupOnClick={interactive}
+            doubleClickZoom={interactive}
+            dragging={interactive}
+            fadeAnimation={interactive}
+            id={`visualization-${id}`}
+            key={`visualization-${id}-${width}-${height}`}
+            markerZoomAnimation={interactive}
+            scrollWheelZoom={interactive}
+            style={{ height, width }}
+            touchZoom={interactive}
+            trackResize={interactive}
+            whenReady={_handleMapReady}
+            zoom={viewport.zoom}
+            zoomAnimation={interactive}
+            zoomControl={interactive}>
+            <MapEvents onViewportChanged={_onChange} />
+            <TileLayer url={url} attribution={attribution} eventHandlers={{ load: _handleTilesReady }} />
+            {markers}
+          </MapContainer>
+        </div>
+      )}
+    </InteractiveContext.Consumer>
+  );
+};
 
 export default MapVisualization;


Test patch: