There are a lot of cool APIs out there that let you work with interesting datasets. If you are interested in space at all, then the NASA APIs might be something you want to check out.
In this post, we'll use one of the NASA APIs to create an asteroid map. This will give us a representation of how many asteroids came close to hitting the Earth and how big they were. We'll save these images to Cloudinary so we can review them later.
Initial setup
There are a few things we need to have in place before starting on the code. First, you'll need an API key for the NASA Asteroids - NeoWs API we'll be using. You can get a free one here. It'll send the API key to the email you enter.
Next, you'll need a Cloudinary account to store the asteroid map images that you can reference later. You can sign up for a free account here.
We'll be working with a local Postgres database, so if you don't have that installed, you can download it here.
Now that we have all of these things set up, we can start working on the app.
Generate a new Redwood project
In a terminal, run the following command:
$ yarn create redwood-app asteroid-map
This will create a lot of new files and directories for you. Our focus will be in the web
and api
folders. The web
folder is where we'll write all fo the front-end code in React. The api
folder is where we'll handle the Postgres connection and the GraphQL back-end.
Create the database schema and connection
We'll start by connecting to the database and setting up our schema. First, open the .env
file in the root of the project. You'll see a commented-out line that defines the DATABASE_URL
. Uncomment that line and update it to match your local connection string. That might look similar to this:
DATABASE_URL=postgres://postgres:admin@localhost:5432/asteroids
You won't need to create a new database manually. When we run the first migration, the asteroids
database will be created for you.
Now we can write the schema for the database. In the api > db
folder, open schema.prisma
. Redwood uses Prisma to handle the database operations. This file is where we use the connection string and write the schema for all of our tables.
Update the provider
value from sqlite
to postgresql
. This tells Primsa we're working with a Postgres instance. You can see where the connection string is being read from the DATABASE_URL
value we set earlier.
Next, you can delete the example model and replace it with the following:
model Map {
id Int @id @default(autoincrement())
name String
startDate DateTime
endDate DateTime
mapUrl String
}
This model represents the data we'll store in the database. The NASA API returns asteroid information based on the dates we submit, so we're storing those to know which dates correspond to the asteroid maps.
Running a database migration
Since we have the schema in place for the table we'll save the asteroid maps to, let's go ahead and run a database migration. In your terminal, run the following command:
$ yarn redwood prisma migrate dev
This will create the database (if needed) and then add the Map
table to it.
Make the GraphQL types and resolvers
That's all we need to do on the database side of this app. Now we can turn to the GraphQL server. Redwood's CLI has a lot of commands that do some heavy lifting for us. We're going to generate the types and resolvers for our back-end with this command:
$ yarn redwood generate sdl --crud map
This will generate several files for us that handle all of the CRUD functionality for our maps. The only things we need to add are the types for the data we get from the NASA API and a resolver to fetch that data.
Adding the asteroid data types
In the api > src > graphql
directory, open the newly generated maps.sdl.ts
file. This already has the type definitions for the CRUD queries and mutations we might use to update the database.
Now we'll add the type to define the data we'll get from the API, the input type to send to the API, and the query we can use to return the data. Right below the Map
type, add this code:
type Asteroid {
missDistance: String
estimatedDiameter: String
}
input AsteroidInput {
startDate: Date!
endDate: Date!
viewDate: Date!
}
type Query {
asteroids(input: AsteroidInput): [Asteroid] @requireAuth
maps: [Map!]! @requireAuth
map(id: Int!): Map @requireAuth
}
That will give us access to the query and what it needs. Let's go define the resolver to fetch this data.
Calling the NASA API through a resolver
This is one of the cool things about GraphQL. You can call another API in a resolver and the data gets sent through the same endpoint as if it were hitting your own database.
In api > src > services > maps
, open the maps.js
file. This has the CRUD resolvers created from that CLI command we ran earlier. Below all of these, add the following resolver to fetch the asteroid data:
export const asteroids = ({ input }) => {
return fetch(`https://api.nasa.gov/neo/rest/v1/feed?start_date=${input.startDate.toISOString().split('T')[0]}&end_date=${input.endDate.toISOString().split('T')[0]}&api_key=${your_api_key_really_goes_here}`)
.then(response => {
return response.json()
})
.then(rawData => {
const data = rawData.near_earth_objects[input.viewDate.toISOString().split('T')[0]]
const asteroids = data.map(value => {
return {
missDistance: value.close_approach_data[0].miss_distance.kilometers,
estimatedDiameter: value.estimated_diameter.kilometers.estimated_diameter_max
}
})
return asteroids
})
}
This resolver takes the input we pass to it and makes this request to the API. Like with many API requests, we have to send the inputs in a particular format. That's why we're splitting the date string the way we are. GraphQL passes the date in a format the NASA API doesn't like.
Then we get the data from the response and check out the asteroids that were close by on the viewDate
we pass in. This date can be any time between the start and end dates. We take the data returned from the API and extract the values we need and that's what we pass in a successful response.
That's everything for the back-end! We have all of the types and resolvers we need to get the asteroid data and save things to the database. We can turn our attention to the front-end where we'll wrap things up.
Building the user interface
Let's jump right in. There's one package that we need to install in order to save the asteroid maps we create. In your terminal, go to the web
directory and run:
$ yarn add html-to-image
This will allow us to capture an image of the asteroid map really quickly.
We can use the Redwood CLI to generate the asteroid map page for us. So in your terminal go back to the root of the project and run the following command:
$ yarn redwood generate page asteroid
This will update the Routes.tsx
file to have this new path and it generates a few files for us in web > src > pages > AsteroidPage
. The file we will work in is AsteroidPage.tsx
. Open this file and delete all of the existing import statements and replace them with these:
import { useQuery, useMutation } from '@redwoodjs/web'
import { useState, useRef } from 'react'
import { toPng } from 'html-to-image'
After these imports, we can add the GraphQL query to get our asteroid data and the mutation to save the map to the Cloudinary and the database.
const CREATE_MAP_MUTATION = gql`
mutation CreateMapMutation($input: CreateMapInput!) {
createMap(input: $input) {
id
}
}
`
const GET_ASTEROIDS = gql`
query GetAsteroids($input: AsteroidInput!) {
asteroids(input: $input) {
missDistance
estimatedDiameter
}
}
`
Adding states and using hooks in the component
With all of the imports and GraphQL definitions in place, let's start working inside the AsteroidPage
component. You can delete everything out of the component because we'll be writing a lot of different code.
We'll start by adding the states and other hooks we need for the component.
const [createMap] = useMutation(CREATE_MAP_MUTATION)
const canvasRef = useRef(null)
const [startDate, setStartDate] = useState("2021-08-12")
const [endDate, setEndDate] = useState("2021-08-15")
const [viewDate, setViewDate] = useState("2021-08-13")
const { loading, data } = useQuery(GET_ASTEROIDS, {
variables: { input: { startDate: startDate, endDate: endDate, viewDate: viewDate }},
})
First, we create the method that does the mutation to add new records to the database. Then we set the canvas ref that will hold the image of the asteroid map. Next, we set a few different date states. These will let us adjust what's in the map we save and what we see in the app.
Then there's the data fetch query. This calls that resolver we made to get the asteroid data from the NASA API. We pass in the input
in the shape we defined in the types on the back-end. These values come from the states, so whenever the state values change we can get a new asteroid map.
Having a loading state
You'll notice that we have a loading
value from the useQuery
call. This tells us if the data is still being fetched. It's important to have some kind of element that tells the user a page is loading. This also prevents the app from crashing when the data isn't available yet. So below the data query, add this code:
if (loading) {
return <div>Loading...</div>
}
This just renders a loading message on the page.
The elements that get rendered
Now that we have the data coming in, let's write the return statement for what should render on the page. Below the loading state check, add the following code and we'll go through it:
return (
<>
<h1>AsteroidPage</h1>
<form onSubmit={submit}>
<div>
<label htmlFor="mapName">Map Name</label>
<input type="text" name="mapName" />
</div>
<div>
<label htmlFor="startDate">Start Date</label>
<input type="date" name="startDate" />
</div>
<div>
<label htmlFor="endDate">End Date</label>
<input type="date" name="endDate" />
</div>
<div>
<label htmlFor="viewDate">View Date</label>
<input type="date" name="viewDate" />
</div>
<button type="submit">Save Asteroid Map</button>
</form>
<button type="button" onClick={makeAsteroidMap}>View Map</button>
<canvas id="asteroidMap" ref={canvasRef} height="3000" width="3000"></canvas>
</>
)
There's not as much going on as it might seem. We have a form that has a few input elements for the name we want to give an asteroid map and the dates we need to get the data and image. This form has a submit button that fetches new asteroid data based on our inputs and saves a new map.
There's another button that lets us view the asteroid map in the canvas element below it. The canvas element is what we target in the useRef
hook above. The form and view map buttons have functions that we need to write.
If you want to look at the app so far, run yarn redwood dev
in your terminal. You should see something like this.
The submit function
We'll add this function right below the loading state check. This will get the form data, update the date states, take a snapshot of the asteroid map in the canvas, upload it to Cloudinary, and then make a new database record.
async function submit(e) {
e.preventDefault()
const mapName = e.currentTarget.mapName.value
const startDate = e.currentTarget.startDate.value
const endDate = e.currentTarget.endDate.value
const viewDate = e.currentTarget.viewDate.value
setStartDate(startDate)
setEndDate(endDate)
setViewDate(viewDate)
if (canvasRef.current === null) {
return
}
const dataUrl = await toPng(canvasRef.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', upload_preset_value)
const cloudinaryRes = await fetch(uploadApi, {
method: 'POST',
body: formData,
})
const input = {
name: mapName,
startDate: new Date(startDate),
endDate: new Date(endDate),
mapUrl: cloudinaryRes.url
}
createMap({
variables: { input },
})
}
You'll need to get your cloudName
and upload preset value from your Cloudinary console. The only function left to write is the one to draw the asteroid map on the canvas.
Drawing the asteroid map
This will create a different sized circle at various distances from the left side of the page to show how close they were to Earth and how big they were.
function makeAsteroidMap() {
if (canvasRef.current.getContext) {
let ctx = canvasRef.current.getContext('2d')
data.asteroids.forEach((asteroid) => {
const scaledDistance = asteroid.missDistance / 75000
const scaledSize = asteroid.estimatedDiameter * 100
let circle = new Path2D()
circle.arc(scaledDistance * 2, scaledDistance, scaledSize, 0, 2 * Math.PI)
ctx.fill(circle)
})
}
}
The scaling here isn't based on anything in particular, so feel free to play around with the math!
Now if you run the app and click the View Map button, you'll see something like this.
If you update the dates, you can view a different map and save it to the database. That's all of the code for this app!
Now you can see how close we almost came to an asteroid event every day.
Finished code
You can take a look at the complete project in the asteroid-map
folder of this repo. Or you can take a look at the front-end in this Code Sandbox. You'll have to update some values to match yours in order for this to work.
Conclusion
Working with external APIs is something we commonly do and GraphQL is one of the ways we can centralize all of the APIs we call. Using this as a tool to make visual representations of how close we came to being hit by asteroids every day is just a fun way to use that functionality.