Creating an Asteroid Map with a NASA API

Milecia

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:

1$ 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:

1DATABASE_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:

1model Map {
2 id Int @id @default(autoincrement())
3 name String
4 startDate DateTime
5 endDate DateTime
6 mapUrl String
7}

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:

1$ 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:

1$ 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:

1type Asteroid {
2 missDistance: String
3 estimatedDiameter: String
4}
5
6input AsteroidInput {
7 startDate: Date!
8 endDate: Date!
9 viewDate: Date!
10}
11
12type Query {
13 asteroids(input: AsteroidInput): [Asteroid] @requireAuth
14 maps: [Map!]! @requireAuth
15 map(id: Int!): Map @requireAuth
16}

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:

1export const asteroids = ({ input }) => {
2 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}`)
3 .then(response => {
4 return response.json()
5 })
6 .then(rawData => {
7 const data = rawData.near_earth_objects[input.viewDate.toISOString().split('T')[0]]
8
9 const asteroids = data.map(value => {
10 return {
11 missDistance: value.close_approach_data[0].miss_distance.kilometers,
12 estimatedDiameter: value.estimated_diameter.kilometers.estimated_diameter_max
13 }
14 })
15
16 return asteroids
17 })
18}

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:

1$ 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:

1$ 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:

1import { useQuery, useMutation } from '@redwoodjs/web'
2import { useState, useRef } from 'react'
3import { 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.

1const CREATE_MAP_MUTATION = gql`
2 mutation CreateMapMutation($input: CreateMapInput!) {
3 createMap(input: $input) {
4 id
5 }
6 }
7`
8
9const GET_ASTEROIDS = gql`
10 query GetAsteroids($input: AsteroidInput!) {
11 asteroids(input: $input) {
12 missDistance
13 estimatedDiameter
14 }
15 }
16`

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.

1const [createMap] = useMutation(CREATE_MAP_MUTATION)
2
3const canvasRef = useRef(null)
4
5const [startDate, setStartDate] = useState("2021-08-12")
6const [endDate, setEndDate] = useState("2021-08-15")
7const [viewDate, setViewDate] = useState("2021-08-13")
8
9const { loading, data } = useQuery(GET_ASTEROIDS, {
10 variables: { input: { startDate: startDate, endDate: endDate, viewDate: viewDate }},
11})

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:

1if (loading) {
2 return <div>Loading...</div>
3}

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:

1return (
2 <>
3 <h1>AsteroidPage</h1>
4 <form onSubmit={submit}>
5 <div>
6 <label htmlFor="mapName">Map Name</label>
7 <input type="text" name="mapName" />
8 </div>
9 <div>
10 <label htmlFor="startDate">Start Date</label>
11 <input type="date" name="startDate" />
12 </div>
13 <div>
14 <label htmlFor="endDate">End Date</label>
15 <input type="date" name="endDate" />
16 </div>
17 <div>
18 <label htmlFor="viewDate">View Date</label>
19 <input type="date" name="viewDate" />
20 </div>
21 <button type="submit">Save Asteroid Map</button>
22 </form>
23 <button type="button" onClick={makeAsteroidMap}>View Map</button>
24 <canvas id="asteroidMap" ref={canvasRef} height="3000" width="3000"></canvas>
25 </>
26)

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.

1async function submit(e) {
2 e.preventDefault()
3 const mapName = e.currentTarget.mapName.value
4 const startDate = e.currentTarget.startDate.value
5 const endDate = e.currentTarget.endDate.value
6 const viewDate = e.currentTarget.viewDate.value
7
8 setStartDate(startDate)
9 setEndDate(endDate)
10 setViewDate(viewDate)
11
12 if (canvasRef.current === null) {
13 return
14 }
15
16 const dataUrl = await toPng(canvasRef.current, { cacheBust: true })
17
18 const uploadApi = `https://api.cloudinary.com/v1_1/${cloudName}/image/upload`
19
20 const formData = new FormData()
21 formData.append('file', dataUrl)
22 formData.append('upload_preset', upload_preset_value)
23
24 const cloudinaryRes = await fetch(uploadApi, {
25 method: 'POST',
26 body: formData,
27 })
28
29 const input = {
30 name: mapName,
31 startDate: new Date(startDate),
32 endDate: new Date(endDate),
33 mapUrl: cloudinaryRes.url
34 }
35
36 createMap({
37 variables: { input },
38 })
39}

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.

1function makeAsteroidMap() {
2 if (canvasRef.current.getContext) {
3 let ctx = canvasRef.current.getContext('2d')
4
5 data.asteroids.forEach((asteroid) => {
6 const scaledDistance = asteroid.missDistance / 75000
7 const scaledSize = asteroid.estimatedDiameter * 100
8 let circle = new Path2D()
9
10 circle.arc(scaledDistance * 2, scaledDistance, scaledSize, 0, 2 * Math.PI)
11
12 ctx.fill(circle)
13 })
14 }
15}

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.

Milecia

Software Team Lead

Milecia is a senior software engineer, international tech speaker, and mad scientist that works with hardware and software. She will try to make anything with JavaScript first.