frontend
Persist Zoom and Bounds in a React Plotly.js Map
Avoid the reset: keep your map's zoom level and coordinates stable across re-renders.
Introduction
There are a fair number of data visualization, charting, and mapping libraries out there for web developers to choose from. A popular, and flexible, set of such libraries goes by the name of Plotly.
Plotly is used by companies like Intuit and Grafana, which is a pretty good endorsement by itself, so when I needed to build a data visualization dashboard complete with maps and charts for a project at work, I reached for Plotly to help with those parts.
A nice bonus for me is that Plotly not only has a JavaScript version of its library, but it also has a React-specific implementation, and planned to build my web app with the React meta framework Next.js.
The documentation for the JavaScript implementation of Plotly is decent, but the React Plotly docs... not so much. While the React docs mention certain code quirks that are unique to the React way of doing things, there is a distinct lack of code samples to help illustrate how to handle these quirks.
One such quirk I ran into is around controls within a map: typical interactions like zooming in and out, grabbing and scrolling with mouse clicks, and including useful information in map marker tooltips on hover. For me, after I zoomed or scrolled around the map just a bit, when the React app would re-render (as React apps are wont to do) it would reset to its original map coordinates and zoom level like the page had just been loaded.
Not one to be beaten by code, I experimented, read code on GitHub, checked the docs, and eventually found my way to how to persist the map's current state in between re-renders.
In this blog, I'll show you how to avoid your Plotly.js map resetting its zoom level and map bounds during a React re-render, and instead persist the user's last settings so they can keep interacting as if nothing has changed with some simple state handling in the map's parent component.
Here is a working demo in CodeSandbox where you can interact with a map to your heart's content and never have it reset to its original coordinates as you do so.
Install Plotly.js and React-Plotly.js
In order to use Plotly's maps and charts inside of a React-based application, the first thing you'll need to do is install the Plotly.js and React-Plotly.js libraries.
Run this command in the terminal in the root of your project to add them both.
npm install react-plotly.js plotly.js
In other JavaScript projects in the past, I've most often reached for the Mapbox library for my mapping needs, but since I also needed data visualizations with a high degree of customization, instead of having to install two different libraries (one for maps, one for charts), I went with Plotly, which offers both.
Create a Reusable Map Component
Once the Plotly libraries were installed, I created a reusable map component that could simply be passed the data it needs to display.
Mapbox is a mapping library I'm fairly familiar with so I opted for a Plotly Mapbox-style scatter plot map, but I believe a similar solution will work for the other Plotly mapping options as well.
Here is the full code for my <PlotlyMap/>
component, I'll explain its contents below.
NOTE: All of my JavaScript-related code is actually written in TypeScript, so if you prefer to use JavaScript, just know that it can be fairly easily adapted to plain JS by removing things like types from the components and variable declarations.
import dynamic from "next/dynamic";
import {
getMarkerColor,
findMostRecentWithAqiLevel,
getAverageValue,
formatTooltipText,
} from "./utils/mapHelpers";
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
const PlotlyMap = ({
data,
onBoundsChange,
mapBounds,
}: {
data: any;
onBoundsChange;
mapBounds: any;
}) => {
const mapLayout = {
font: {
color: "white",
},
dragmode: "zoom",
mapbox: {
center: mapBounds.center,
zoom: mapBounds.zoom,
style: "dark",
},
margin: {
r: 20,
t: 40,
b: 20,
l: 20,
pad: 0,
},
paper_bgcolor: "#191A1A",
};
const config = {
mapboxAccessToken: process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
scrollZoom: true,
};
const preparePlotlyMapData = (data) => {
const devicePoints = data.map((row) => ({
deviceId: row.at(-1).device,
lat: getAverageValue(row, "best_lat"),
lon: getAverageValue(row, "best_lon"),
location: row.at(-1).best_location,
aqiLevel: findMostRecentWithAqiLevel(row),
timestamp: new Date(row.at(-1).when * 1000).getTime(),
}));
const mapData = {
type: "scattermapbox",
lat: devicePoints.map((point) => point.lat),
lon: devicePoints.map((point) => point.lon),
mode: "markers",
marker: {
size: 8,
color: devicePoints.map((point) => getMarkerColor(point.aqiLevel)),
},
text: formatTooltipText(devicePoints),
};
return [mapData];
};
return (
<Plot
data={preparePlotlyMapData(data)}
layout={mapLayout}
config={config}
onRelayout={onBoundsChange}
style={{ width: "100%", height: "100%" }}
useResizeHandler
/>
);
};
export default PlotlyMap;
Let's talk about the props getting passed to the <PlotlyMap/>
first.
data
- the list of objects with latitude and longitude coordinates to display their positions on the map (plus any other interesting data about the objects that you want to display in a tooltip)onBoundsChange()
- a function to update themapBounds
variables (more on this later)mapBounds
- an object of the map's current latitude and longitude coordinates: map center, map corners, and the current zoom level
After these props is the mapLayout
variable that is mostly boilerplate for the Plotly map component setup. The layout sets the background color (paper_bgcolor
), font color, and margins for the map container, the map's current center
coordinates, zoom
, and map style
("dark"
), and the ability for a user to click and drag inside the map (dragmode: "zoom"
).
The next variable is the config
variable which includes a Mapbox API token needed to render the Mapbox tile style in the map container (one of the reasons I like Mapbox is because of the variety of nice map styles they have to choose from), and the scrollZoom
boolean which allows users to zoom in and out of maps using the scroll wheel on their mouse and/or a two-finger scroll.
Then there is the preparePlotlyMapData()
function that takes in the array of objects from the parent component and loops over them to get the object's ID, its location coordinates, and the time that device last reported in. Once the devicePoints
are created, the latitude and longitude for each device are fed into the mapData
object which renders each point on the map.
NOTE: There are a couple of helper functions inside of the
preparePlotlyMapData()
function that I did not cover because they're not relevant to this blog post.The data being displayed in the map is devices that are measuring local air quality, so I'm using the device's recorded air quality to determine the color of the map marker and add relevant air quality data to the marker's tooltip.
Finally, all these variables and functions are used in the <Plot/>
component. The data is prepared according to the map's requirements, the mapLayout
variable tells the map container how to style itself on render, the config
has the Mapbox access token and enables zoom scrolling behavior, the useResizeHandler
property enables automatic resizing of Plotly charts when the browser window size changes, and the onRelayout()
function accepts the onBoundsChange()
function passed by the parent component.
In the next section, I'll talk in detail about what the onBoundsChange()
function looks like, but onRelayout()
is used to modify the layout of an existing plot (it works for both charts and maps), and allows for dynamic updates to the plot's appearance and configuration without redrawing the entire chart.
onRelayout()
is one of the keys to successfully tracking and persisting the state changes in the map across React component re-renders. More details follow in the next section.
Leverage React's useState() to track map state and visible devices in the map container
The other key to ensuring the map's current coordinates, zoom level, and markers persist across component re-renders leverages React useState()
hooks in the parent component. There are two pieces of state that need to be tracked: mapBounds
and mapDataPoints
.
The <PlotlyMap/>
component is imported directly into a Next.js app's pages/index.tsx
file, so when you see the code below, you'll also see some placeholders for Next boilerplate code in addition to the code directly related to preserving the map's state.
import { useState, useEffect } from "react";
import {
convertObjectToArray,
groupEventsByDevice,
} from "@/components/utils/helpers";
import PlotlyMap from "@/components/PlotlyMap";
const inter = Inter({ subsets: ["latin"] });
type HomeProps = {
data: any;
};
export default function Home({ data }: HomeProps) {
const rawData = data;
const [mapBounds, setMapBounds] = useState({
center: { lon: -98.5795, lat: 39.8283 },
zoom: 3,
topLeft: { lat: 0, lon: 0 },
topRight: { lat: 0, lon: 0 },
bottomRight: { lat: 0, lon: 0 },
bottomLeft: { lat: 0, lon: 0 },
});
// devices currently visible on the map after a user pans or zooms
const [mapDataPoints, setMapDataPoints] = useState([]);
const [visibleDevices, setVisibleDevices] = useState([]);
{/* function to set current date range in rendered JSX date components */}
// group all the events together by device ID
useEffect(() => {
const groupedEventsByDevice = groupEventsByDevice(rawData);
const devicesList = convertObjectToArray(groupedEventsByDevice);
setMapDataPoints(devicesList);
const filteredVisibleDevices = filterVisibleDevicesWithinBounds(
devicesList,
mapBounds
);
setVisibleDevices(filteredVisibleDevices);
}, [rawData]);
const filterVisibleDevicesWithinBounds = (devices, bounds) => {
return devices.filter((device) => {
const { best_lat, best_lon } = device[0];
return (
best_lat >= bounds.bottomLeft.lat &&
best_lat <= bounds.topLeft.lat &&
best_lon >= bounds.bottomLeft.lon &&
best_lon <= bounds.bottomRight.lon
);
});
};
// when map bounds change, filter out devices that are not within the new bounds
useEffect(() => {
if (mapBounds.topLeft.lat !== 0 && mapBounds.topLeft.lon !== 0) {
const filteredVisibleDevices = filterVisibleDevicesWithinBounds(
mapDataPoints,
mapBounds
);
setVisibleDevices(filteredVisibleDevices);
}
}, [mapBounds]);
const handleMapBoundsChange = (newBounds) => {
if (newBounds["mapbox._derived"]) {
setMapBounds({
center: newBounds["mapbox.center"],
zoom: newBounds["mapbox.zoom"],
topLeft: {
lat: newBounds["mapbox._derived"]["coordinates"][0][1],
lon: newBounds["mapbox._derived"]["coordinates"][0][0],
},
topRight: {
lat: newBounds["mapbox._derived"]["coordinates"][1][1],
lon: newBounds["mapbox._derived"]["coordinates"][1][0],
},
bottomRight: {
lat: newBounds["mapbox._derived"]["coordinates"][2][1],
lon: newBounds["mapbox._derived"]["coordinates"][2][0],
},
bottomLeft: {
lat: newBounds["mapbox._derived"]["coordinates"][3][1],
lon: newBounds["mapbox._derived"]["coordinates"][3][0],
},
});
}
};
return (
<>
{/* Next.js head boilerplate */}
<main>
<div>
{/* page tile and date range display components */}
<div style={{ width: "100%", height: "450px" }}>
<PlotlyMap
data={mapDataPoints}
onBoundsChange={handleMapBoundsChange}
mapBounds={mapBounds}
/>
</div>
<div>
<h3>
Number of devices currently shown on map within map bounds:{" "}
{visibleDevices.length}
</h3>
</div>
<div>
<h3>Current map bounds:</h3>
<p>Northernmost latitude: {mapBounds.topLeft.lat.toFixed(4)}</p>
<p>Southernmost latitude: {mapBounds.bottomLeft.lat.toFixed(4)}</p>
<p>Westernmost longitude: {mapBounds.bottomLeft.lon.toFixed(4)}</p>
<p>Easternmost longitude: {mapBounds.bottomRight.lon.toFixed(4)}</p>
</div>
</div>
</main>
</>
);
}
export async function getStaticProps() {
{/* fetch data to display in map here */}
return {
props: {
data,
},
};
}
NOTE: I glossed over some of the Next.js code in the component above that's irrelevant to this article like data fetching, CSS styling, and other page elements like date range display components. If you'd like to see the full code, you can check out this Codesandbox demo.
Now let's discuss of the variables being tracked in this <Home/>
component.
mapBounds
- an object that tracks all of the map component's relevant information: map's center coordinates, zoom level, and coordinates for each map cornermapDataPoints
- all of the objects to be displayed as markers on the mapvisibleDevices
- once the user's begun interacting with the map, the full list ofmapDataPoints
is filtered down to just the devices currently visible within the map's visible bounds (I included it here to illustrate how the currently visible objects update and persist on the map even as the component re-renders other parts of the browser with updated data)
Once the data is fetched from the server (in this case, I'm using a CSV of air quality data produced by Blues Airnote devices located all around the world), the <Home/>
component uses that rawData
variable to group all the air quality events by device ID and then sets that list as the mapDataPoints
array that get passed to the map component.
Now that same useEffect()
function also calls another function called filterVisibleDevicesWithinBounds()
. This function is what sets the visibleDevices
state variable that displays a count of the devices visible in the map component based on the map's coordinate bounds underneath the map container in the DOM.
NOTE: There is a small bug with this component in that, before a user has interacted with the map immediately after page load, the map bounds (and thus the number of map markers visible) are unknown to the browser.
As soon as the user does interact, the browser can update all the data points under the map container, but until they do, that data remains unknown. It's annoying, but a small enough inconvenience I'm willing to overlook it for the sake of this post.
Further down in the code, there's a handleMapBoundsChange()
function that is attached to the <PlotlyMap/>
component's onRelayout()
function. When that onRelayout()
function is activated by the user interacting with the map, it sends the new map bounds to the handleMapBoundsChange()
function which updates the mapBounds
state in this component.
When the mapBounds
state is updated the second useEffect()
function that gets triggered and calculates the amount of devices currently visible in the current map bounds.
The mapDataPoints
, mapBounds
, and handleMapBoundsChange()
function are all passed to the <PlotlyMap/>
component and any time the user interacts with the map, the new map bounds and zoom level get passed back to this parent component, and the map state is updated and persisted, even as map coordinates and amount of devices visible on screen are also updated and re-rendered.
Interact with the map component and verify the coordinates and device count persist
If you'd like to see a working demo of how you can interact with a React Plotly.js map full of markers and see other data on the screen update while the map bounds and interactions persist, I've got a CodeSandbox for you to try out.
Open a terminal inside of Codesandbox and type npm run dev
to get it started.
In addition to the <PlotlyMap/>
component, I also included a couple of Ant Design date picker components to display the entire date range of the data from the CSV (July 16, 2024 to July 18, 2024).
Feel free to zoom on in on different parts of the map, drag and scroll around, and hover over markers for additional info in tooltips. Every time you interact with the map, coordinates and count of devices being displayed on screen should update accordingly.
And if you want to, comment out the onRelayout={onBoundsChange}
line of code inside of the <PlotlyMap/>
component's JSX, refresh the browser and then try zooming in or navigating around inside the map. You should see now that the count of visible devices and map coordinates displayed under the map don't update despite the user interactions.
Conclusion
Plotly is a great, open source data visualization and map component library, made even more convenient for a JavaScript-heavy web developer like me by the fact that it has a library dedicated specifically to working well with the React JS framework.
Initially when I tried to use React Plotly to render a bunch of objects as markers on a map, shortly after I'd start interacting with the map, when a React re-render would occur I'd be back to square one as if I hadn't touched the map at all.
The trick, I learned, was to keep track of the map's current state in the parent component, and lean on the onRelayout()
function inside of the map component itself to persist the map's current view while DOM elements around it changed. Once I started tracking those things, map interactions worked as expected.
Check back in a few weeks — I’ll be writing more about JavaScript, React, IoT, or something else related to web development.
Thanks for reading. I hope if you choose to use a Plotly.js map inside of your own React-based project you'll find these tips on persisting the map's current state across component re-renders useful. Enjoy!
References & Further Resources
Want to be notified first when I publish new content? Subscribe to my newsletter.