Having locations marked on a map is something that a lot of us depend on. Whether it's figuring out where a restaurant is or trying to find the nearest metro, we use maps quite a bit. Knowing how to work with maps as a developer is a niche skill with plenty of demand.
In this tutorial, we'll make a map app that lets users choose the location for the pin. It will save this location and upload a snapshot of the map to Cloudinary.
Some initial things
There are a couple of tools we need to set up before we start writing any code. We'll be using a local PostgreSQL database. If you don't have a local instance installed, you can download it for free here.
You'll also need a Cloudinary account to host your map snapshots. You can sign up for a free one here. Since we'll be uploading images from the front-end directly to Cloudinary, we need to set up an upload preset in our account settings. On this page, click the "Add upload preset" button.
This will bring us to a new page where we can define specific values for our preset. There are only a couple of things we'll change here.
Update the "Signing Mode" to "Unsigned". This is how we'll handle the uploads from our client app. Then add a "Folder" value of "map-shots". This will put all of our uploaded map snapshots into one folder. Your settings should look similar to this.
Take a quick note of the "Upload preset name". You'll need this when it's time to upload images from the user. Now we can write some code.
Creating the app
We're going to make this app using Redwood. Open a terminal and run the following command.
yarn create redwood-app map-shot
This creates a new Redwood app that has a fully functioning React front-end and GraphQL back-end. The front-end code is stored in the web
directory and the back-end code is in the api
directory. These are the two main folders we'll be working with.
Let's start by setting up a quick environment variable. You should have a .env
file at the root of your project. Open that and add the connection string for your local Postgres instance. That might look something like this.
DATABASE_URL=postgres://admin:postgres@localhost:5432/map_shots
Now we can start writing the schema for our database.
Defining the database schema
In api > db
, open the schema.prisma
file. This is where we define our database connection and table schema using Prisma. We first need to update the provider
value to postgresql
from sqlite
. We set the DATABASE_URL
value in the .env
file so that's ready for us.
Now we can delete the example model and replace it with a schema for our map snapshots. Add the following code to replace the example model.
model Snapshot {
id String @id @default(uuid())
name String
xPosition Float
yPosition Float
imageUrl String
}
These are the values that will be taken from the user and stored in our database. This is the only table we'll have for now so we can save these changes and run a database migration to persist them to Postgres. To do that, open your terminal and run:
yarn rw prisma migrate dev
This will create the new database and this table we've defined. With all of this in place, we're done with the database work. Let's turn our attention to the GraphQL server.
Generating the GraphQL server
One of the best things about working with Redwood is how much work it does for you. We need to define the types and resolvers to make updates to the rows in our database. Normally we would make those files and write the code ourselves, but there's a command in Redwood that does this for us based on the schema we just wrote.
In your terminal, run the following command:
yarn rw g sdl snapshot --crud
Now take a look in the api > src
directory. You'll see some updates to the graphql
and services
directories. The command we just ran generated both the GraphQL types and all of the CRUD mutations and queries for us.
Take a look at api > src > graphql > snapshots.sdl.js
to see the types. Then look through api > src > services > snapshots
to see the queries, mutations, and test files that have been created for us.
We just created our entire GraphQL server with one command. You can modify any of the code in these files if you ever need more complex types or resolvers, but this gives you a way to quickly set up the basics.
Since we have the back-end ready to go, let's switch over to the front-end and set things up for our users to interact with.
Making the user interface
We'll need to add three packages in order to make our map. In a terminal, go to the web
directory and run the following:
yarn add leaflet react-leaflet html-to-image
This will give us access to react-leaflet which lets us do a lot of cool things with maps.
We need to use leaflet
's stylesheet so that the map components render correctly. That means we need to add this code to index.html
inside the <head>
.
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin="" />
With this package installed and configured, we can start working on the page for this map.
Add a new page
We'll use another Redwood command to generate the page and route for where the map will be rendered. In a terminal go to the root of your project and run this:
yarn rw g page map
If you go to web > src > pages
, you'll see a newly created folder called MapPage
. This has several files to help enforce coding best practices, but the main one we'll work with is MapPage.js
.
Add the core functionality to the page
We can delete all of the imports and clear out the code in the component to start fresh. Then we'll add the following packages we'll be using.
import { useMutation } from '@redwoodjs/web'
import { useCallback, useRef, useState } from 'react'
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'
import { toPng } from 'html-to-image'
These are all of the methods and components we need to make this map app stored values in the database and images in Cloudinary.
Next we'll define the GraphQL mutation we need to add a new snapshot to the database. Right below the imports, add the following code:
const CREATE_SNAPSHOT_MUTATION = gql`
mutation CreateSnapshotMutation($input: CreateSnapshotInput!) {
createSnapshot(input: $input) {
id
}
}
`
Now we need to start adding functionality inside the MapPage
component. We'll start by setting up some variables. Your component should look like this:
const MapPage = () => {
const mapRef = useRef(null)
const [createSnapshot] = useMutation(CREATE_SNAPSHOT_MUTATION)
const [position, setPosition] = useState([36.49, -100])
const [name, setName] = useState('The pit of the Midwest')
}
We can write one of the functions that will let us handle our user changes. First, we'll write the function that will get called when a user submits the form we create a bit later. This will handle setting some states and it will call the method that uploads the image and adds the record to the database.
Below the initial variables, add the following function:
const updatePin = (e) => {
e.preventDefault()
const { xPosition, yPosition, name } = e.target.elements
setPosition([xPosition.value, yPosition.value])
setName(name.value)
onCapture()
}
This is going to handle the event from our form submission so that the page doesn't refresh. It gets the form values from the inputs and then updates the respective states. Then it calls the onCapture
method, which is where some interesting things happen.
Save a snapshot of the map to Cloudinary and the database
Let's go ahead and make this onCapture
method. Add the following code to your component:
const onCapture = useCallback(async () => {
if (mapRef.current === null) {
return
}
const dataUrl = await toPng(mapRef.current, { cacheBust: true })
const uploadApi = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`
const formData = new FormData()
formData.append('file', dataUrl)
formData.append('upload_preset', uploadPresetName)
const res = await fetch(uploadApi, {
method: 'POST',
body: formData,
})
const result = await res.json()
const input = {
name: name,
xPosition: Number(position[0]),
yPosition: Number(position[1]),
imageUrl: result.url,
}
createSnapshot({
variables: { input },
})
}, [mapRef])
Quite a few things are happening here. First, we're checking to make sure our referenced component is loaded. Then we're using the toPng
method from html-to-image
to create a png data URL based on the elements inside our mapRef
.
Next, add the API string for Cloudinary. This is where you'll need to use the cloud name from your account. Then we create a new FormData
variable and add the data URL for the snapshot and the upload preset we created earlier.
Then we do a POST request to the API and upload the snapshot. We wait for the response to come back because it will have a Cloudinary URL we can use as the source for images in HTML elements. This is what we'll be saving to the database.
The last thing we do is make an input variable for our GraphQL call and then add this record to the database. All that's left is to write the elements that need to show to the user.
Adding the elements to the page
We need a return statement to render these elements. So add this code to your file and we'll talk through what's happening.
return (
<div style={{ display: 'flex' }}>
<div ref={mapRef}>
<h1>{name}</h1>
<MapContainer
center={position}
zoom={7}
scrollWheelZoom={false}
style={{ height: '550px', width: '750px' }}
>
<TileLayer
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<Marker position={position}>
<Popup>The armpit of Oklahoma.</Popup>.
</Marker>
</MapContainer>
</div>
<form onSubmit={updatePin}>
<div>
<label for="name">Change map name</label>
<input type="text" name="name" />
</div>
<div>
<label for="xPosition">Change pin x-position</label>
<input type="number" name="xPosition" />
</div>
<div>
<label for="yPosition">Change pin y-position</label>
<input type="number" name="yPosition" />
</div>
<button type="submit">Update</button>
</form>
</div>
)
We have a containing element with a flex style applied. This will show the map on the left of the screen and the form to its right. Then we'll have the container with our ref
prop to capture the screenshot.
Inside of this, we have a title for the map and the map itself. The <MapContainer>
component has a few props defined that tell the map where to be centered when a user first views the page.
In the <MapContainer>
component, the <TileLayer>
component is used to make up the map sections. You can check out the react-leaflet documentation for more information. The <Marker>
component lets us set the little pinpoint you see on most maps.
Lastly, we have the <form>
that calls the updatePin
method whenever it is submitted. This is what triggers all of the functionality we wrote out earlier.
It's time to run the app and see all of this in action. At the root of your project, run this in the terminal:
yarn rw dev
This starts up the local development server for both the GraphQL server and the front-end. When the app opens in the browser, you should see something that looks like this:
Now you can update the map with the fields to the right. Keep in mind that the map won't auto-focus on the new point, so you might need to zoom out to see where you land. Here's an example of an update.
If you take a look at your Postgres instance, you'll see new records in your table and if you look on Cloudinary you'll see a new "map-shots" folder.
That's it! We're finally finished with the project.
Finished code
If you want to take a look at the complete code, check out the map-shot
folder of this repo.
You can check out the front-end in this Code Sandbox.
Conclusion
You can expand this app to do many interesting things like making paths to special locations or pointing out obscure landmarks. One of the tricky things is making sure you stay on land! Hopefully, you see how you can bring together a lot of different technologies quickly to make a sophisticated app.
Top comments (0)