Creating a Dynamic & Responsive Weather Map with OpenWeatherMap API and React-Leaflet with Custom Marker Icon: A Step-by-Step Guide

·

10 min read

Here I will share my learning on how I implement a weather Map in my Cloudsify project.

https://cloudsify-59854.web.app/

github.com/AakashRaj20/Cloudify

Let's start with creating a react-app in vs-code

npx create-react-app weatherMap

This command will create a react project with the name weatherMap

Now let's install the dependencies for the project

npm install react-leaflet leaflet @mui/material @mui/icons-material @reduxjs/toolkit axios

With our dependencies installed let's start with the code

Create a components folder and inside the src folder and then inside the Components folder create a file called Map.jsx

//import all the neccessary packages and functions
import React, { useEffect, useState } from "react";
import { MapContainer, TileLayer, LayersControl } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import { fetch50CityData } from "../slice/citiesDataSlice";
import { cityData } from "../slice/inputSlice";
import { useSelector, useDispatch } from "react-redux";

const Map = () => {
  const dispatch = useDispatch();
  const selectedCity = useSelector(cityData);
  useEffect(() => {
    selectedCity &&
      dispatch(
        fetch50CityData({
          lon: selectedCity?.location.lon,
          lat: selectedCity?.location.lat,
        })
      );
  }, [selectedCity, dispatch]);

  const [zoomLevel, setZoomLevel] = useState(12);

  const handleZoomChange = (e) => {
    setZoomLevel(e.target._zoom);
  };

  const [center, setCenter] = useState([19.076, 72.8777]);
  useEffect(() => {
    selectedCity &&
      setCenter([selectedCity.location.lat, selectedCity.location.lon]);
  }, [selectedCity]);
}

export default Map;

The selectedCity variable captures the city name input provided by the user, which is subsequently stored in the Redux store.

Incorporating the useEffect hook, we validate the existence of selectedCity. When present, we dispatch the fetch50CitiesData Async Thunk function. This function interfaces with OpenWeatherMap API, gathering current weather data for about 50 nearby cities, utilizing selectedCity's latitude and longitude.

The useEffect hook is optimized with a dependency array [selectedCity, dispatch]. This array dictates the hook's execution upon changes to these values. Thus, the hook responds solely to alterations in selectedCity or dispatch.

Subsequently, we define a useState variable zoomLevel that establishes the initial map zoom level state as 12.

Next, we construct a handleZoom function. This function utilizes the setZoomLevel mechanism to update the zoomLevel state with the current zoom level.

Following this, we create a center state variable through the useState hook. This variable establishes the initial center point for our map.

Subsequently, we employ another instance of the useEffect hook. In this scenario, we perform a conditional check to determine the presence of the selectedCity variable. When selectedCity is truthy, we employ the setCenter function to adjust the map's center coordinates, aligning them with the latitude and longitude of the chosen city.

This useEffect activation is confined to changes in the selectedCity value, as stipulated within the dependency array. As a result, this component remains optimized, recalibrating the map's focus exclusively when the user selects a new city.

//all the above code
const maps = (
    <MapContainer
      center={center}
      zoom={zoomLevel}
      style={{ width: "100%", height: "412px", borderRadius: "20px" }}
      onZoomEnd={handleZoomChange}
    >
      <LayersControl position="topright">
        <LayersControl.BaseLayer name="Temperature">
          <TileLayer
            url={`https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=85716d70713b33bf033f8a37df623121`}
            attribution='&copy; <a href="https://openweathermap.org/">OpenWeatherMap</a>'
          />
        </LayersControl.BaseLayer>
        <LayersControl.BaseLayer name="Precipitation">
          <TileLayer
            url={`https://tile.openweathermap.org/map/precipitation_new/{z}/{x}/{y}.png?appid=85716d70713b33bf033f8a37df623121`}
            attribution='&copy; <a href="https://openweathermap.org/">OpenWeatherMap</a>'
          />
        </LayersControl.BaseLayer>
        <LayersControl.BaseLayer name="Wind">
          <TileLayer
            url={`https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=85716d70713b33bf033f8a37df623121`}
            attribution='&copy; <a href="https://openweathermap.org/">OpenWeatherMap</a>'
          />
        </LayersControl.BaseLayer>
        <LayersControl.BaseLayer name="Clear Map" checked="Clear Map">
          <TileLayer
            url={`https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`}
            attribution='&copy; <a href="https://openweathermap.org/">OpenWeatherMap</a>'
          />
        </LayersControl.BaseLayer>
      </LayersControl>

      <Markers />
    </MapContainer>
  );

We configure the map container as specified by the React Leaflet library.

To begin, we initialize a <MapContainer> element. Within this component, we incorporate various attributes to enhance map interactivity and responsiveness.

We assign the center attribute to the <MapContainer> to determine its center coordinates. The zoom attribute is populated with the zoomLevel variable, optimizing the map's magnification. Additionally, we apply styling by defining the dimensions—height, width, and border radius—of the container. Crucially, either the height or width property should be specified in percentage units, while the other should be expressed as a fixed value so that the map remains responsive.

To facilitate the integration of various map types, we employ the <LayerControl> tag. By inserting this tag, we enable the inclusion of distinct map layers within a single map view.

To position the layer control div optimally, we set the position attribute to topRight, thereby situating the layer control panel in the upper-right corner of the map. You can place it according to your convenience.

Subsequently, we craft a <LayerControl.BaseLayer> element, delineating the map layer that we intend to incorporate. Within this element, we define the name attribute, specifying the desired label to be displayed on the layer control panel. You can also pass the checked attribute with the same value as in the name attribute to make that layer be checked as the default layer.

Now, we proceed by employing a self-closing <TileLayer> tag. This tag facilitates the integration of map data from an OpenWeatherMap API or another preferred map API. Upon user interaction, typically a click event, the tag effectively retrieves and renders the specified map on the interface.

Following this, we present the <Markers /> component, meticulously designed with HTML and CSS to serve as custom markers.

To initiate the creation of the Markup component begins by generating a new file named markup.jsx within the designated component folder.

import { useEffect, useCallback } from "react";
import ReactDOMServer from "react-dom/server";
import { cityData50 } from "../slice/citiesDataSlice";
import { cityData } from "../slice/inputSlice";
import { useSelector } from "react-redux";
import { Typography, Box } from "@mui/material";
import { Marker, Popup } from "react-leaflet";
import L from "leaflet";
import { useMap } from "react-leaflet";

const Marker = () => {
    const icon = (iconCode, name) => {
    return L.divIcon({
      className: "custom-icon",
      html: ReactDOMServer.renderToString(
        <div className="custom-icon-container">
          <div className="custom-icon-image">
            <img src={getWeatherIconUrl(iconCode)} alt="icon" />
          </div>
          <div className="custom-icon-name-div">
            <p className="custom-icon-name">{name}</p>
          </div>
        </div>
      ),
      iconAnchor: [16, 32],
      iconSize: [40, 40],
      iconPosition: "top",
      popupAnchor: [0, -32],
    });
  };
}

In this segment, we construct an icon function, designed to return an L.divIcon object. This object defines how our custom marker should appear on the map. The function accepts two parameters: iconcode and name. iconcode provides the weather icon code for each city among the 50 cities, while name offers the respective city's name.

Subsequently, we adhere to the React-Leaflet library's documentation, constructing an object housing various attributes. The classname attribute assigns a class to our custom marker, while the html attribute generates a customized HTML element to display the weather icon and city name. Additionally, the iconsize attribute specifies the icon's dimensions, with the remaining settings retained as default.

Within the <img> tag, we invoke the getWeatherIconUrl function, passing the iconcode to retrieve the weather icon's URL. This process necessitates implementing OpenWeatherMap API's functionalities. This will make your custom Marker. CSS code will be attached at the end.

Here is the code for getWeatherIconUrl function.

  const getWeatherIconUrl = (iconCode) => {
    return `https://openweathermap.org/img/wn/${iconCode}.png`;
  };
const cities = useSelector(cityData50);
const selectedCity = useSelector(cityData);
const maps = useMap();

const changeLocation = useCallback(() => {
    selectedCity &&
      maps.flyTo(
        [selectedCity.location.lat, selectedCity.location.lon],
        maps.getZoom()
      );
  }, [selectedCity, maps]);

  useEffect(() => {
    changeLocation();
  }, [selectedCity, changeLocation]);

//previous code for marker component

Here, we utilize a cities constant to retain data for the 50 cities fetched from the Redux store. Similarly, the selectedCity constant captures the city sought by the user through a search, also stored in the Redux store.

In the changeLocation function, we employ useCallback to prevent excessive re-renders. Within this hook, we initially ascertain whether the selectedCity is valid. Utilizing the maps.flyTo function from React Leaflet, we seamlessly transition to the selected city's location by furnishing its latitude and longitude.

Furthermore, we leverage the getZoom function from React Leaflet to acquire the current zoom level. This level is then employed to ensure a smooth and appropriate transition, aligning with the user's current zoom preference.

// all the previous code 
return (
    <>
      {cities &&
        cities.map((city) => (
          <Marker
            eventHandlers={{ changeLocation }}
            key={city.id}
            position={[city.coord.lat, city.coord.lon]}
            icon={icon(city.weather[0].icon, city.name)}
          >
            <Popup className="custom-popup">
              <Box sx={{ width: "100%", color: "white" }}>
                <Typography sx={{ lineHeight: "10px" }}>
                  Temperature: {city.main.temp} °C
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Description: {city.weather[0].description}
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Humidity: {city.main.humidity}%
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Wind: {city.wind.speed} km/h
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Cloudiness: {city.clouds.all}%
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Pressure: {city.main.pressure} MB
                </Typography>
              </Box>
            </Popup>
          </Marker>
        ))}
    </>
  );
};

Within this code snippet, we iterate over the cities array, creating a <Marker> element for each city. Additionally, we incorporate a <Popup> component for enhanced interactivity.

Attributes are assigned to the <Marker> tag, each serving a distinct purpose. Initially, we apply an event handler that triggers the changeLocation function, facilitating navigation to the searched city's latitude and longitude on the map. Subsequently, we employ the position attribute to accurately position the icons in the respective locations.

The icon attribute seamlessly integrates the icon function, thereby rendering the icons and city names unique to each city.

Next, we capitalize on the <Popup> tag, employed to showcase the data for each city within its respective popup.

Code for App.js

import Map from "./components/Map";

const App = () => {
    return (
        <Map />
    )
}

export default App;

Code For Map.jsx

import React, { useEffect, useState } from "react";
import { Grid } from "@mui/material";
import { MapContainer, TileLayer, LayersControl } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import { fetch50CityData } from "../slice/citiesDataSlice";
import { cityData } from "../slice/inputSlice";
import { useSelector, useDispatch } from "react-redux";
import Markers from "./Markers";

const WeatherMap = () => {
  const dispatch = useDispatch();
  const selectedCity = useSelector(cityData);
  useEffect(() => {
    selectedCity &&
      dispatch(
        fetch50CityData({
          lon: selectedCity?.location.lon,
          lat: selectedCity?.location.lat,
        })
      );
  }, [selectedCity, dispatch]);

  const [zoomLevel, setZoomLevel] = useState(12);

  const handleZoomChange = (e) => {
    setZoomLevel(e.target._zoom);
  };

  const [center, setCenter] = useState([19.076, 72.8777]);
  useEffect(() => {
    selectedCity &&
      setCenter([selectedCity.location.lat, selectedCity.location.lon]);
  }, [selectedCity]);

  const maps = (
    <MapContainer
      center={center}
      zoom={zoomLevel}
      style={{ width: "100%", height: "412px", borderRadius: "20px" }}
      onZoomEnd={handleZoomChange}
    >
      <LayersControl position="topright">
        <LayersControl.BaseLayer name="Temperature">
          <TileLayer
            url={`https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=85716d70713b33bf033f8a37df623121`}
            attribution='&copy; <a href="https://openweathermap.org/">OpenWeatherMap</a>'
          />
        </LayersControl.BaseLayer>
        <LayersControl.BaseLayer name="Precipitation">
          <TileLayer
            url={`https://tile.openweathermap.org/map/precipitation_new/{z}/{x}/{y}.png?appid=85716d70713b33bf033f8a37df623121`}
            attribution='&copy; <a href="https://openweathermap.org/">OpenWeatherMap</a>'
          />
        </LayersControl.BaseLayer>
        <LayersControl.BaseLayer name="Wind">
          <TileLayer
            url={`https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=85716d70713b33bf033f8a37df623121`}
            attribution='&copy; <a href="https://openweathermap.org/">OpenWeatherMap</a>'
          />
        </LayersControl.BaseLayer>
        <LayersControl.BaseLayer name="Clear Map" checked="Clear Map">
          <TileLayer
            url={`https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`}
            attribution='&copy; <a href="https://openweathermap.org/">OpenWeatherMap</a>'
          />
        </LayersControl.BaseLayer>
      </LayersControl>

      <Markers />
    </MapContainer>
  );

  return (
    <Grid item xs={12} sm={12} md={4} container>
      {maps}
    </Grid>
  );
};

export default WeatherMap;

Code for Marker.jsx

import { useEffect, useCallback } from "react";
import ReactDOMServer from "react-dom/server";
import { cityData50 } from "../slice/citiesDataSlice";
import { cityData } from "../slice/inputSlice";
import { useSelector } from "react-redux";
import { Typography, Box } from "@mui/material";
import { Marker, Popup } from "react-leaflet";
import L from "leaflet";
import { useMap } from "react-leaflet";

const Markers = () => {
  const cities = useSelector(cityData50);
  const selectedCity = useSelector(cityData);
  const maps = useMap();
  const getWeatherIconUrl = (iconCode) => {
    return `https://openweathermap.org/img/wn/${iconCode}.png`;
  };

  const changeLocation = useCallback(() => {
    selectedCity &&
      maps.flyTo(
        [selectedCity.location.lat, selectedCity.location.lon],
        maps.getZoom()
      );
  }, [selectedCity, maps]);

  useEffect(() => {
    changeLocation();
  }, [selectedCity, changeLocation]);

  const icon = (iconCode, name) => {
    return L.divIcon({
      className: "custom-icon",
      html: ReactDOMServer.renderToString(
        <div className="custom-icon-container">
          <div className="custom-icon-image">
            <img src={getWeatherIconUrl(iconCode)} alt="icon" />
          </div>
          <div className="custom-icon-name-div">
            <p className="custom-icon-name">{name}</p>
          </div>
        </div>
      ),
      iconAnchor: [16, 32],
      iconSize: [40, 40],
      iconPosition: "top",
      popupAnchor: [0, -32],
    });
  };
  return (
    <>
      {cities &&
        cities.map((city) => (
          <Marker
            eventHandlers={{ changeLocation }}
            key={city.id}
            position={[city.coord.lat, city.coord.lon]}
            icon={icon(city.weather[0].icon, city.name)}
          >
            <Popup className="custom-popup">
              <Box sx={{ width: "100%", color: "white" }}>
                <Typography sx={{ lineHeight: "10px" }}>
                  Temperature: {city.main.temp} °C
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Description: {city.weather[0].description}
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Humidity: {city.main.humidity}%
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Wind: {city.wind.speed} km/h
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Cloudiness: {city.clouds.all}%
                </Typography>
                <Typography sx={{ lineHeight: "10px" }}>
                  Pressure: {city.main.pressure} MB
                </Typography>
              </Box>
            </Popup>
          </Marker>
        ))}
    </>
  );
};

export default Markers;

Code for Custom Marker Styles and Dark mode for the map

html {
  scroll-behavior: smooth;
}

.leaflet-layer,
.leaflet-control-zoom-in,
.leaflet-control-zoom-out,
.leaflet-control-attribution {
  filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
}

.speedometer {
  max-width: 500px;
  width: 100%;
  height: 100%;
  max-height: 300px;
}

.custom-icon-container {
  display: flex;
  width: 170px;
  height: 40px;
  align-items: center;
  padding: 0.5rem;
  border-radius: 0.5rem;
  background-color: #518554;
}

.custom-icon-name {
  font-size: 0.8rem;
  font-weight: 600;
  textalign: justify;
  padding: 0 5px;
}

.custom-icon-image {
  display: flex;
  justify-content: flex-start;
}

.custom-icon-name-div {
  display: flex;
}

.leaflet-popup-content-wrapper {
    background-color: #1B1A1D;
}

Run npm run start to see the map in action.

I'm venturing into the world of blogging for the very first time, so any constructive suggestions you might have to enhance my content are warmly welcomed.

Stay tuned for my upcoming blog, where I'll delve into the implementation of the Recharts library.

If you happen to come across any remote frontend development opportunities, I'm actively seeking them. Don't hesitate to reach out or recommend me if my work aligns with your expectations.

A big thank you for taking the time to read my blog!

Aakash Raj Signing Off!