Recently I have found myself in the position to want to show a list of addresses on a map and remembered that I haven’t built a map in quite a while. The plan is to build a map with OpenStreetMap and with some general structure for points of interest (POIs), which will be retrieved from external URLs.
We will not get into the details of creating a map. We will also stick to the basic features provided by Leaflet and try to keep things as simple as possible.
The repository and the demo URLs are the following:
- https://github.com/cristidraghici/generic-map-with-pois/tree/1.0.0
- https://cristidraghici.github.io/generic-map-with-pois/
Setup the project
We will start with the classic commands to setup the project. This is a purely JS/TS project, so we will not use docker, nor get too much into environment matters.
npx create-vite generic-map-with-pois
- React
- typescript
cd generic-map-with-pois
npm i
npm run dev
We should remember to init git
and make commits as we progress with the project.
git init
git add .
git commit -m "First commit"
Add a basic map
The first thing we need to do is add Leaflet and the types for this library. Leaflet is one of the stronger options to use when you want to build maps with Javascript, and also one of the most friendly for beginners. Since it’s a small project, we will also use react-leaflet
, since we are using react. However, for a more enterprise solution, we might want to avoid this and use Leaflet directly.
npm i --save react-leaflet leaflet
npm i --save-dev @types/leaflet
App.tsx.
, App.css
and the ./assets
folders are not needed currently, so we will delete them. Instead we will create a Map.tsx
file:
import { MapContainer, TileLayer } from "react-leaflet";
import type { LatLngExpression } from "leaflet";
import "leaflet/dist/leaflet.css";
/**
* The default center of the map (currently Bucharest :) )
*/
const MAP_CENTER: LatLngExpression = [44.4268, 26.1025];
/**
* The initial zoom of the map
*/
const MAP_ZOOM = 3;
function Map() {
return (
<MapContainer
center={MAP_CENTER}
zoom={MAP_ZOOM}
style={{ height: "100vh" }}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
/>
</MapContainer>
);
}
export default Map;
Code language: JavaScript (javascript)
Next, we must update the import in main.tsx
to use the new Map
component and while we are in this area of the app, we will also change the content of index.css
to:
html, body {
margin: 0;
padding: 0;
}
Code language: CSS (css)
Add some POIs
We will first add a constant with a few points of interest. We aim to show the points on the map, then center the map on the area where they are displayed.
/**
* Some cities in Romania used as default POIs
*/
const DEFAULT_POINTS: {
latitude: number;
longitude: number;
title: string;
description?: string;
}[] = [
{ title: "Bucharest", latitude: 44.4268, longitude: 26.1025 },
{ title: "Cluj-Napoca", latitude: 46.7712, longitude: 23.6236 },
{ title: "Iasi", latitude: 47.1585, longitude: 27.6014 },
{
title: "Constanta",
description: "A city by the Black Sea",
latitude: 44.181,
longitude: 28.6348,
},
{ title: "Timisoara", latitude: 45.7489, longitude: 21.2087 },
];
Code language: PHP (php)
We will put the markers directly in the MapContainer
, given that we don’t need a layer functionality for this project:
{DEFAULT_POINTS.map((poi, index) => (
<Marker key={index} position={[poi.latitude, poi.longitude]}>
<Tooltip direction="top" offset={[-15, -15]} opacity={1}>
<div className="tooltip-content">
<h3>{poi.title}</h3>
{!!poi.description && <>{poi.description}</>}
</div>
</Tooltip>
</Marker>
))}
Code language: HTML, XML (xml)
In this step we will also handle the map boundaries, depending on the displayed POIs. Since we have a static page, we will calculate the bounds directly in the main useEffect()
of the Map.tsx
component:
useEffect(() => {
if (!map) {
return;
}
const bounds = L.latLngBounds(
DEFAULT_POINTS.map<LatLngExpression>((poi) => [
poi.latitude,
poi.longitude,
])
);
map.fitBounds(bounds.pad(0.1));
map.setMaxBounds(bounds.pad(0.5));
}, [map]);
Code language: JavaScript (javascript)
Make a request for POIs
Our intention with this project is to make it able to display POIs from external sources. As a minimal security feature we will use a whitelist with regexes matching the allowed api URLs, but this will be it. The first regex will be one that matches the github pages, the address where we will publish a demo of this page:
/^https:\/\/\w+\.github\.io\/[\w-]+(\/[\w-]+\/)?$/
To implement this, we will first create a custom hook which will make a request to the provided URL, if any. If no URL is provided, then it will fallback to the list of POIs mentioned above. The type on the response should match the one of the DEFAULT_POINTS
:
import { useState, useEffect } from "react";
/**
* Some cities in Romania used as default POIs
*/
const DEFAULT_POINTS: {
latitude: number;
longitude: number;
title: string;
description?: string | string[];
}[] = [
{ title: "Bucharest", latitude: 44.4268, longitude: 26.1025 },
{ title: "Cluj-Napoca", latitude: 46.7712, longitude: 23.6236 },
{ title: "Iasi", latitude: 47.1585, longitude: 27.6014 },
{
title: "Constanta",
description: "A city by the Black Sea",
latitude: 44.181,
longitude: 28.6348,
},
{ title: "Timisoara", latitude: 45.7489, longitude: 21.2087 },
];
/**
* List of regular expressions used to whitelist the URL given as a source of POIs.
* This is a minimal security feature to prevent the most basic abuse.
*/
const ALLOWED_API_URLS: RegExp[] = [
/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)*\/?$/, // A regex for the Github pages
];
const useGetPOIs = (url?: string) => {
const [data, setData] = useState<CustomMarker[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
// No url was provided
if (!url) {
setLoading(false);
setData(DEFAULT_POINTS);
return;
}
// The provided url does not match the regex values in the whitelist
if (ALLOWED_API_URLS.every((regex) => !regex.test(url))) {
setLoading(false);
setError(
"The URL provided does not match the safety rules in this project's whitelist."
);
return;
}
// Fetch the data
fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json() as Promise<CustomMarker[]>;
})
.then((json) => {
setData(json);
})
.catch((error) => {
setError(error as string);
})
.finally(() => {
setLoading(false);
});
}, [url]);
return { data, loading, error };
};
export default useGetPOIs;
Code language: JavaScript (javascript)
To get the value from the ?api=
parameter in the URL, we will create a custom hook. For the fun of it, I have asked the already very famous Chat-GPT for some support, and the code was mostly what you see here. So kudos to it, some requests it completes beautifully:
import { useState, useEffect } from "react";
type URLParams = { [key: string]: string };
function useURLParams(): URLParams {
const [params, setParams] = useState<URLParams>({});
useEffect(() => {
// Function to parse the URL query string and extract parameters
const parseQueryString = (queryString: string) => {
const params: URLParams = {};
const query = new URLSearchParams(queryString);
query.forEach((value, key) => {
params[key] = value;
});
return params;
};
// Get the current URL
const currentURL = window.location.href;
// Extract the query string from the URL
const queryString = currentURL.split("?")[1];
// Parse the query string and update the state with parameters
if (queryString) {
const parsedParams = parseQueryString(queryString);
setParams(parsedParams);
}
// Update the state when the URL changes
const handleURLChange = () => {
const newURL = window.location.href;
const newQueryString = newURL.split("?")[1];
if (newQueryString) {
const newParams = parseQueryString(newQueryString);
setParams(newParams);
}
};
// Add event listener for URL changes
window.addEventListener("popstate", handleURLChange);
// Clean up the event listener when the component unmounts
return () => {
window.removeEventListener("popstate", handleURLChange);
};
}, []);
return params;
}
export default useURLParams;
Code language: JavaScript (javascript)
The last piece we need to update is the Map.tsx
component. We will use the two new hooks to get the parameter and after get the data. We will also have some minimal error handling and some styling.
import { useState, useEffect, useMemo } from "react";
import { MapContainer, TileLayer, Marker, Tooltip } from "react-leaflet";
import L, { LatLngExpression, Map as MapType } from "leaflet";
import useGetPOIs from "./hooks/useGetPOIs";
import useURLParams from "./hooks/useURLParams";
import "leaflet/dist/leaflet.css";
/**
* The default center of the map (currently Bucharest :) )
*/
const MAP_CENTER: LatLngExpression = [44.4268, 26.1025];
/**
* The initial zoom of the map
*/
const MAP_ZOOM = 3;
function Map() {
const URLParams = useURLParams();
const [map, setMap] = useState<MapType | null>(null);
const { data, loading, error } = useGetPOIs(URLParams.api || undefined);
const bounds = useMemo(
() =>
L.latLngBounds(
data.map<LatLngExpression>((poi) => [poi.latitude, poi.longitude])
),
[data]
);
useEffect(() => {
if (!map) {
return;
}
if (!bounds.isValid()) {
return;
}
map.fitBounds(bounds.pad(0.5));
map.setMaxBounds(bounds.pad(0.5));
}, [map, bounds]);
return (
<>
<div
style={{
position: "absolute",
top: 0,
right: 0,
zIndex: 999,
background: "#fff",
padding: 10,
marginLeft: 130,
}}
>
{loading && <>The data for the map is loading...</>}
{!loading && error && <>{error}</>}
</div>
<MapContainer
center={MAP_CENTER}
zoom={MAP_ZOOM}
style={{ height: "100vh" }}
ref={setMap}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
/>
{data.map((poi, index) => (
<Marker key={index} position={[poi.latitude, poi.longitude]}>
<Tooltip direction="top" offset={[-15, -15]} opacity={1}>
<div className="tooltip-content">
<h3>{poi.title}</h3>
{!!poi.description && <>{poi.description}</>}
</div>
</Tooltip>
</Marker>
))}
</MapContainer>
</>
);
}
export default Map;
Code language: PHP (php)
One piece that is missing is validating that the data inside the API response is in the right format. This can be added as a future improvement.
Add a search input
The most useful tool when browsing data is the search functionality. We would like that implemented in our map project.
Add tailwind
To avoid the hustle of writing CSS inside the style property, we will start using tailwind, a CSS framework quite largely adopted nowadays:
- npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
- in tailwind.config.js update
content
to:content: [ "./index.html", "./src/**/*.{js,ts,jsx,tsx}", ]
- replace the content of the
index.css
with:
@tailwind base;
@tailwind components;
@tailwind utilities;
Code language: CSS (css)
- update the styles to use
className
and tailwind classes, except for thestyle
prop to set the map height on the map itself.
More info:
Add SVG imports
In our search input we will use a magnifier icon to indicate that we are searching stuff there. It is recommended that we use the SVG file as a direct import, instead of using it in an image tag. For that we will use a vite plugin:
npm i -D vite-plugin-svgr
- update the
vite.config.ts
file:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
// https://vitejs.dev/config/
export default defineConfig({
base: "/generic-map-with-pois",
plugins: [react(), svgr()],
});
Code language: JavaScript (javascript)
- in
tsconfig.json
add:
"compilerOptions":
{
[...rest...]
"types": ["vite-plugin-svgr/client"] <--- this line
},
Code language: JavaScript (javascript)
Now you will be able to import files like:
import { ReactComponent as IconSearchSVG } from "../assets/icon-search.svg";
For compressing *.svg
files we will use a custom command which can also be stored as a script in package.json
:
npx --yes -- svgo -r ./src/assets
For formatting, when using VSCode, we can update the settings by editing the .vscode/settings.json
file:
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"files.associations": {
"*.svg": "html"
}
}
Code language: JSON / JSON with Comments (json)
More info:
Create the search component
The search component will be an input with a magnifier icon and an “x” icon to cancel the value, which can be extended upon in the future.
import {
InputHTMLAttributes,
FunctionComponent,
useEffect,
useRef,
} from "react";
import { ReactComponent as IconSearchSVG } from "../assets/icon-search.svg";
import { ReactComponent as IconCloseSVG } from "../assets/icon-close.svg";
interface SearchInputProps extends InputHTMLAttributes<HTMLInputElement> {
onCancel?: () => void;
}
const SearchInput: FunctionComponent<SearchInputProps> = ({
className,
onCancel,
value,
...rest
}) => {
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const inputElement = inputRef.current;
if (!inputElement) {
return;
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && onCancel) {
onCancel();
}
};
// Add the event listener when the component mounts
inputElement.addEventListener("keydown", handleEscapeKey);
// Clean up the event listener when the component unmounts
return () => {
inputElement.removeEventListener("keydown", handleEscapeKey);
};
}, [onCancel]);
return (
<div className={`relative ${className || ""}`}>
<input
ref={inputRef}
type="text"
value={value}
className="w-full p-1 pl-8 pr-8 border-[2px] bg-white hover:bg-gray-50 border-gray-400 rounded-md focus:outline-none focus:border-gray-800"
{...rest}
/>
<div className="absolute inset-y-0 left-0 flex items-center pl-2 text-black">
<IconSearchSVG className="fill-gray-800" width={20} />
</div>
{!!value && (
<div className="absolute inset-y-0 right-0 flex items-center pr-2 text-black cursor-pointer">
<IconCloseSVG
className="fill-gray-800"
width={20}
onClick={() => onCancel && onCancel()}
/>
</div>
)}
</div>
);
};
export default SearchInput;
Code language: JavaScript (javascript)
We will then import it in the Map.tsx
component, which will continue to handle the whole state of the application. The search will be performed in the title and the description of each marker, before representing them on the map:
const [search, setSearch] = useState<string>("");
const filteredData = useMemo(
() =>
data.filter((poi) => {
const allText = `${poi.title} ${
Array.isArray(poi.description)
? poi.description.concat(" ")
: poi.description
}`;
return allText.toLowerCase().includes(search.toLowerCase());
}),
[data, search]
);
Code language: JavaScript (javascript)
For the moment we will not debounce the data, but we might find that it is a useful addition in the future.
Create a modal for POI details
So far, our map as one big inconvenient: because it uses a tooltip to show the details of a POI, we do not have the option to select any details.
The fix for this will be implementing a modal window to show all the details when double clicking on a point. We use double click mainly because on a mobile view the tooltip is displayed on click, since hover is not available there.
We will first create a new component in ./components/Modal.tsx
:
import { FunctionComponent, PropsWithChildren, useEffect, useRef } from "react";
import { ReactComponent as IconCloseSVG } from "../assets/icon-close.svg";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
}
const Modal: FunctionComponent<PropsWithChildren<ModalProps>> = ({
isOpen,
onClose,
children,
}) => {
const overlayRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const overlayElement = overlayRef.current;
if (!overlayElement) {
return;
}
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && onClose) {
onClose();
}
};
// Add the event listener when the component mounts
window.addEventListener("keydown", handleEscapeKey);
// Clean up the event listener when the component unmounts
return () => {
window.removeEventListener("keydown", handleEscapeKey);
};
}, [onClose]);
return (
<div
className={`fixed inset-0 flex items-center justify-center z-[99999] ${
isOpen ? "" : "hidden"
}`}
>
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
ref={overlayRef}
className="absolute w-full h-full bg-gray-900 opacity-50"
onClick={() => onClose()}
/>
<div className="relative bg-white w-full max-w-md mx-auto rounded shadow-lg z-50 overflow-y-auto">
<div className="absolute top-0 right-0 cursor-pointer p-1">
<IconCloseSVG
className="w-6 h-6 text-gray-600 hover:text-gray-800 transition duration-300 ease-in-out"
onClick={onClose}
/>
</div>
<div className="p-4">{children}</div>
</div>
</div>
</div>
);
};
export default Modal;
Code language: JavaScript (javascript)
Then we need to update the Map.tsx
component to handle selection of a POI when clicked:
...
import Modal from "./components/Modal";
...
function Map () {
const [selectedPOI, setSelectedPoi] = useState<CustomMarker | null>(null);
...
return (
<>
...
{filteredData.map((poi, index) => (
<Marker key={index} position={[poi.latitude, poi.longitude]} eventHandlers={{
dblclick: () => {
setSelectedPoi(poi);
},
}}>
<Tooltip direction="top" offset={[-15, -15]} opacity={1}>
<div className="tooltip-content">
<h3>{poi.title}</h3>
{!!poi.description && <>{poi.description}</>}
</div>
</Tooltip>
</Marker>
))}
...
<Modal isOpen={selectedPOI !== null} onClose={() => setSelectedPoi(null)}>
{selectedPOI && (
<>
<h3 className="font-bold mb-2">{selectedPOI.title}</h3>
{!!selectedPOI.description && !Array.isArray(selectedPOI.description) && (
<div>{selectedPOI.description}</div>
)}
{!!description && Array.isArray(selectedPOI.description) && (
<>
{selectedPOI.description.map((descriptionItem) => (
<div>{descriptionItem}</div>
))}
</>
)}
</>
)}
</Modal>
</>
);
}
Code language: HTML, XML (xml)
At this point, the code is rather messy. Therefore, some refactoring is in order:
- we will first create a
POIDetails.tsx
component which will receive as props the details of the point of interest and return the current content of the tooltip; - then we will create a
MarkerElement..tsx
component with children, which will receive as props the marker details, theonClick
handler which will set the selected POI and thePOIDetails.tsx
component as a child; - finally, we will use the
POIDetails.tsx
component to create the content of the modal as well and by doing so, we will reduce the code complexity and redundancy.
Create custom buttons
At this point, we might feel the need to add a refresh button to reload the data in the map, without refreshing the page. In order to keep the styling aligned, we will remove the existing two buttons used for zooming and replace them with our own.
The first component we will create is ButtonWrapper
, which is a simple component only used to put a border around the group of buttons:
import { FunctionComponent } from "react";
const ButtonWrapper: FunctionComponent<{
children?: JSX.Element | JSX.Element[];
className?: string;
}> = ({ children, className }) => {
return (
<div className={`z-[9999] ${className}`}>
<div className="flex flex-col bg-white border-[2px] border-gray-700 border-opacity-50 rounded-md [&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md [&>*:last-child]:border-none">
{children}
</div>
</div>
);
};
export default ButtonWrapper;
Code language: JavaScript (javascript)
The second component we need is the button MapButton.tsx
itself:
import {
FunctionComponent,
ButtonHTMLAttributes,
PropsWithChildren,
} from "react";
interface MapButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {}
const MapButton: FunctionComponent<PropsWithChildren<MapButtonProps>> = ({
children,
className,
disabled,
...rest
}) => {
return (
<button
className={`flex items-center justify-center min-w-[30px] min-h-[30px] p-[2px] text-2xl leading-5 text-black bg-white hover:bg-gray-100 border-b-[1px] border-gray-700 border-opacity-50 cursor-pointer ${className} ${
disabled ? "opacity-50" : ""
}`}
{...rest}
disabled={disabled}
>
{children}
</button>
);
};
export default MapButton;
Code language: JavaScript (javascript)
We can then add the zoomControl={false}
prop in Map.tsx
at the MapContainer
element and do the rest of the necessary changes:
const [isZoomInDisabled, setIsZoomInDisabled] = useState(false);
const [isZoomOutDisabled, setIsZoomOutDisabled] = useState(false);
...
useEffect(() => {
if (!map) {
return;
}
...
map.on("zoom", () => {
const zoom = map.getZoom();
const maxZoom = map.getMaxZoom();
setIsZoomOutDisabled(zoom === 0);
setIsZoomInDisabled(zoom === maxZoom);
});
}, [map, bounds, setMapBounds]);
...
return (
<>
...
<MapContainer
...
zoomControl={false}
>
...
</MapContainer>
...
<ButtonWrapper className="absolute left-[10px] top-[10px]">
<MapButton
onClick={() => map && map.zoomIn()}
disabled={isZoomInDisabled}
>
<IconPlusSVG width={15} height={15} />
</MapButton>
<MapButton
onClick={() => map && map.zoomOut()}
disabled={isZoomOutDisabled}
>
<IconMinusSVG width={15} height={15} />
</MapButton>
</ButtonWrapper>
</>
);
Code language: JavaScript (javascript)
Now we can add the new button button wrapper below the other one, containing the functionality we want:
import { ReactComponent as IconRefreshSVG } from "./assets/icon-refresh.svg";
...
<ButtonWrapper className="absolute left-[10px] top-[80px]">
<MapButton
onClick={() => {
setSearch("");
}}
disabled={loading}
>
<IconRefreshSVG width={15} height={15} />
</MapButton>
</ButtonWrapper>
...
Code language: HTML, XML (xml)
Publish to Github
As we already have git initialized with our project, what we need to do first is create a new repository on Github. We won’t go into details about that, as there are many tutorials out there to show how it’s done. What we will do, though, is add the a new remote to our git repo, once it is created.
Please remember to change the address to your repo, should you use the commands below:
git remote add origin git@github.com:cristidraghici/generic-map-with-pois.git
git branch -M master
git push -u origin master
The next step is to create the github action in ./.github/workflows/publish-master.yml
:
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["master"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: 18
cache: "npm"
- name: Install dependencies
run: npm install
- name: Build
run: npm run build
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
# Upload dist repository
path: "./dist"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
Code language: PHP (php)
In vite.config.js we might need to add a base for the project, to match the address where it will be published:
export default defineConfig({
base: "/generic-map-with-pois",
plugins: [react()],
});
Code language: CSS (css)
More info:
- https://docs.github.com/en/actions/quickstart
- https://vitejs.dev/guide/static-deploy.html
- https://cescobaz.com/2023/06/14/setup-leaflet-with-svelte-and-vite/
Fix the leaflet marker
We must also add a fix for the Leaflet marker, as it will not be shown in the build. The images are added to the project from node_modules/leaflet/dist/images/
. The following code needs to be added in Map.tsx
or some util function:
// Fix for github pages not showing the icon
import MARKER_ICON_URL from "./assets/images/marker-icon.png";
import MARKER_ICON_RETINA_URL from "./assets/images/marker-icon-2x.png";
import MARKER_SHADOW_URL from "./assets/images/marker-shadow.png";
const DefaultIcon = L.icon({
iconUrl: MARKER_ICON_URL,
iconRetinaUrl: MARKER_ICON_RETINA_URL,
shadowUrl: MARKER_SHADOW_URL,
iconSize: [35, 46],
iconAnchor: [17, 46],
});
L.Marker.prototype.options.icon = DefaultIcon;
Code language: JavaScript (javascript)
More info: