Using HERE Studio to Map National Parks
I've been an avid HERE Studio user since I joined HERE, and my "go to" data set for a while has been a list of American parks provided by the NPS (National Parks Service).In this blog post, I will share how I built maps with this data and what I did to improve the data and get better visualization. The process I went through (taking data and improving it for the map) is somewhat common so I hope this example will help guide others.
The Original Data
Let's start by talking about the original set of data. I began by searching for "national parks service geojson" which led me here: https://www.nps.gov/maps/tools/npmap.js/examples/geojson-layer/. This page shows a simple JavaScript demo rendering a map using a GeoJSON layer. While not exactly called out, if you actually read the JavaScript, you can find this URL: https://www.nps.gov/lib/npmap.js/4.0.0/examples/data/national-parks.geojson If you open this in your browser, you get a list of every single park in the service. Here's a small subset:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": 0,
"properties": {
"Code": "FRLA",
"Name": "Frederick Law Olmsted National Historic Site"
},
"geometry": {
"type": "Point",
"coordinates": [
-71.131129569256473,
42.325508673715092
]
}
},
{
"type": "Feature",
"id": 1,
"properties": {
"Code": "GLDE",
"Name": "Gloria Dei Church National Historic Site"
},
"geometry": {
"type": "Point",
"coordinates": [
-75.143583605984745,
39.934377409572079
]
}
},
One thing you'll notice is that there really isn't a lot of data. Each park has a Code
value (which appears to be an internal way of identifying the park) and a Name
. But we can still work with this and create a map from it! I downloaded this file to my computer, went to HERE Studio and created a new project. After making my project, I added the data layer by uploading it via the browser. We've got a CLI as well that I'll use later, but for now the browser-based tools are enough.
Once uploaded and added to my project, I get this:
Cool. Studio picks some default colors for your data but we can change that easily enough. I went with a green color and an icon ("nature") which feels more appropriate for mapping parks:
The last step (for this version) is to publish it and share it with the world. You can see this map here: https://studio.here.com/viewer/?project_id=bec76599-1715-42bd-b46c-9b5c4837b735
We Can Do Better!
So to recap, we got a GeoJSON file containing all the parks in the National Park Service, uploaded it to HERE Studio, and built a beautiful, if simple, map. The National Park Service though has a lot more information about their parks. Luckily for us, this is all available via a free to use National Parks Service API. The API covers a lot of different use cases but the one we're most interested in is the parks endpoint. This returns detailed information about one or more parks. The API looks like so:
https://developer.nps.gov/api/v1/parks?parkCode=avia&api_key=YOURKEY
And returns a huge amount of information on the park:
{
"total": "1",
"data": [
{
"contacts": {
"phoneNumbers": [
{
"phoneNumber": "9372257705",
"description": "",
"extension": "",
"type": "Voice"
}
],
"emailAddresses": [
{
"description": "",
"emailAddress": "tom_engberg@nps.gov"
}
]
},
"states": "OH",
"longitude": "-84.0711364746094",
"activities": [
{
"id": "B33DC9B6-0B7D-4322-BAD7-A13A34C584A3",
"name": "Guided Tours"
}
],
"entranceFees": [
{
"cost": "1.0000",
"description": "The National Aviation Heritage Area is comprised of many sites. While some sites are free of charge to the public, others may have entrance fees and/or event or participation fees. Please check on the specific National Aviation Heritage Area site prior to your visit.",
"title": "Entrance Fees Vary"
}
],
"directionsInfo": "The National Aviation Heritage Area has multiple sites located throughout eight counties in the Dayton, Ohio and western Ohio area. Please be sure to visit a specific National Aviation Heritage Area website for directions and/or maps to each location.",
"entrancePasses": [
{
"cost": "1.0000",
"description": "The National Aviation Heritage Area is comprised of many sites. While some sites are free of charge to the public, others may have entrance fees and/or event or participation fees. Please check on the specific National Aviation Heritage Area site prior to your visit.",
"title": "Fee Pass Costs Vary"
}
],
"directionsUrl": "http://www.aviationheritagearea.org/",
"url": "https://www.nps.gov/avia/index.htm",
"weatherInfo": "The National Aviation Heritage Area lies in a humid continental zone with a generally temperate climate. Winters are mildly cold with average temperatures around 39 degrees (F). Summers are hot and humid with an average temperature around 74 degrees (F). Average annual total rainfall is just above 41\". Snowfall in the winter is generally light with an average total snowfall of about 25\".",
"name": "National Aviation",
"operatingHours": [
{
"exceptions": [],
"description": "While this website is not meant to be an exhaustive resource for all of the National Aviation Heritage Area partners and organizations, there is an official partner organization (National Aviation Heritage Alliance) which operates a separate and full-functioning website with a plethora of site information. Visit National Aviation Heritage Alliances' webpage for up-to-date information, directions and breaking news for all of the historical sites and member organizations.",
"standardHours": {
"wednesday": "All Day",
"monday": "All Day",
"thursday": "All Day",
"sunday": "All Day",
"tuesday": "All Day",
"friday": "All Day",
"saturday": "All Day"
},
"name": "Various Heritage Area Sites"
}
],
"topics": [
{
"id": "B912363F-771C-4098-BA3A-938DF38A9D7E",
"name": "Aviation"
}
],
"latLong": "lat:39.9818229675293, long:-84.0711364746094",
"description": "Aviation is chock-full of tradition & history and nowhere will you find a richer collection of aviation than here, the birthplace of aviation. From the straightforward bicycle shops that fostered the Wright brothers' flying ambitions to the complex spacecraft that carried man to the moon, the National Aviation Heritage Area has everything you need to learn about this country’s aviation legacy.",
"images": [
{
"credit": "NPS Photo / Tom Engberg",
"altText": "Visitor center building in background with plaza in foreground",
"title": "Dayton's National Park",
"caption": "The Wright-Dunbar Interpretive Center located just west of downtown Dayton",
"url": "https://www.nps.gov/common/uploads/structured_data/DCB2628F-1DD8-B71B-0BD78D1063069C70.jpg"
}
],
"designation": "Heritage Area",
"parkCode": "avia",
"addresses": [
{
"postalCode": "45402",
"city": "Dayton",
"stateCode": "OH",
"line1": "16 South Williams St.",
"type": "Physical",
"line3": "",
"line2": ""
},
{
"postalCode": "45402",
"city": "Dayton",
"stateCode": "OH",
"line1": "16 South Williams St.",
"type": "Mailing",
"line3": "",
"line2": ""
}
],
"id": "C8C207D8-49C4-4891-9915-0007205A0284",
"fullName": "National Aviation Heritage Area",
"latitude": "39.9818229675293"
}
],
"limit": "1",
"start": "0"
}
This is an awesome set of data and would be a great addition to our map, so how do we get it? Remember that HERE Studio needs GeoJSON. We've got a GeoJSON file of parks. What if we take that original source file, get the codes from them, and then make requests to the API to fill in the gaps? Here's my initial solution using Node.js. It failed so don't copy this as is!
const fs = require('fs');
const fetch = require('node-fetch');
require('dotenv').config();
const NPS_KEY = process.env.NPS_KEY;
const BUCKET_SIZE = 25;
let rawData = fs.readFileSync('./national-parks.geojson','utf8');
let data = JSON.parse(rawData);
console.log(`Read in my file and I have ${data.features.length} features to parse.`);
let buckets = [];
let idx = 0;
buckets[idx] = [];
for(let i=0;i<data.features.length;i++) {
let code = data.features[i].properties.Code;
buckets[idx].push(code);
if(buckets[idx].length === BUCKET_SIZE) {
idx++;
buckets[idx] = [];
}
}
console.log(`Please stand by, I'm requesting a lot of data.`);
let requestQueue = [];
for(let i=0;i<buckets.length;i++) {
requestQueue.push(getParkData(buckets[i]));
}
Promise.all(requestQueue).then(results => {
console.log(`Fetched a LOT of data. Now I need to get this into my JSON.`);
//its an array of array, so let's make one big one
let result = [];
for(let i=0;i<results.length;i++) {
for(let k=0;k<results[i].length;k++) {
result.push(results[i][k]);
}
}
/*
Now we need to connect ths data to the original data. Going to use a
lot of looping which is slow but meh. Also note the API returns codes in lowercase,
the original data was uppercase. Also know we will be storing stuff in properties we don't need,
like latlong, longitude, and latitude. I'm ok with the extra though. I will remove .parkCode though so as to not conflict with .Code
Actually scratch that, I'm going to replace the orig properties w/ the new props cuz woot woot
*/
for(let i=0;i<result.length;i++) {
let park = result[i];
let origIndex = data.features.findIndex(p => {
return p.properties.Code === park.parkCode.toUpperCase();
});
if(origIndex >= 0) {
data.features[origIndex].properties = park;
}
}
fs.writeFileSync('./nationl-parks-full.geojson', JSON.stringify(data));
console.log('All done, yo!');
});
function getParkData(list) {
return new Promise((resolve, reject) => {
let url = `https://developer.nps.gov/api/v1/parks?api_key=${NPS_KEY}&parkCode=${list}`;
fetch(url)
.then(res => res.json())
.then(res => {
resolve(res.data);
})
.catch(e => {
console.error(e);
reject(e);
});
});
}
When requesting details about a park, the API lets you send a list of park codes. Given that I've got a list of nearly 400, I decided to create a list of lists, or what I called a bucket
array. Each item in the bucket array is another array of 25 park codes.
Once I have my "list of lists", I then fire off calls to the API for all of them at once and wait for them to finish before I start processing. If you're a bit concerned about this approach you should be. Once done, I loop over my result data and for each park, find the corresponding original park in my geojson file and update it with the result from the API.
So this seemed like a good idea, but in my testing, the API would randomly spit out server errors. This is to be expected as I was basically creating a minor DDOS on the API. Luckily I realized this rather quickly and stopped using the script. Version two was modified to only do a few entries at a time, keeping track of "processed" parks by adding a _processed
flag. Here's the updated file, minus the getParkData()
function which didn't change.
const fs = require('fs');
const fetch = require('node-fetch');
require('dotenv').config();
const NPS_KEY = process.env.NPS_KEY;
const BUCKET_SIZE = 20;
let rawData = fs.readFileSync('./national-parks-full.geojson','utf8');
let data = JSON.parse(rawData);
console.log(`Read in my file and I have ${data.features.length} features to parse.`);
let bucket = [];
for(let i=0;i<data.features.length;i++) {
if(!data.features[i].properties._processed) {
let code = data.features[i].properties.Code;
bucket.push(code);
} else {
console.log(`Skipping ${data.features[i].properties.parkCode} as it has been done.`);
}
}
console.log(`I've got ${bucket.length} parks to process.`);
//Our bucket may be too big
bucket = bucket.slice(0, BUCKET_SIZE);
console.log(`Please stand by, I'm requesting a lot of data (specifically, ${bucket.length} items).`);
getParkData(bucket)
.then(result => {
/*
Now we need to connect this data to the original data. Going to use a
lot of looping which is slow but meh. Also note the API returns codes in lowercase,
the original data was uppercase. Also know we will be storing stuff in properties we don't need,
like latlong, longitude, and latitude. I'm ok with the extra though. I will remove .parkCode though so as to not conflict with .Code
Actually scratch that, I'm going to replace the orig properties w/ the new props cuz woot woot
*/
for(let i=0;i<result.length;i++) {
let park = result[i];
let origIndex = data.features.findIndex(p => {
return p.properties.Code === park.parkCode.toUpperCase();
});
if(origIndex >= 0) {
data.features[origIndex].properties = park;
data.features[origIndex].properties._processed=1;
}
}
fs.writeFileSync('./national-parks-full.geojson', JSON.stringify(data));
console.log('All done, yo!');
})
.catch(e => {
console.error(e);
});
This script will read in a geojson file, find the parks without _processed
, and then process BUCKET_SIZE
items. It may sound lame, but I ran this, waited a while, ran it again, and so forth, over an hour or two until the entire set of parks was done. To be clear, this was an ugly fix, but probably indicative of the kinds of things folks have to deal with in getting their data in the best form to map.
The final result looked a bit like this (I'm including just the top of the geojson file and the first result):
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": 0,
"properties": {
"contacts": {
"phoneNumbers": [
{
"phoneNumber": "6175661689",
"description": "",
"extension": "",
"type": "Voice"
}
],
"emailAddresses": [
{
"description": "",
"emailAddress": "frla_interpretation@nps.gov"
}
]
},
"states": "MA",
"longitude": "-71.13202567",
"activities": [
{
"id": "B33DC9B6-0B7D-4322-BAD7-A13A34C584A3",
"name": "Guided Tours"
},
{
"id": "DF4A35E0-7983-4A3E-BC47-F37B872B0F25",
"name": "Junior Ranger Program"
},
{
"id": "C8F98B28-3C10-41AE-AA99-092B3B398C43",
"name": "Museum Exhibits"
},
{
"id": "A0631906-9672-4583-91DE-113B93DB6B6E",
"name": "Self-Guided Tours - Walking"
}
],
"entranceFees": [
{
"cost": "0.0000",
"description": "No entrance fee required.",
"title": "No Entrance Fee"
}
],
"directionsInfo": "Site is located on the southwest corner of Warren and Dudley Streets in Brookline, south of Route 9, near the Brookline Reservoir.\n\nSite is 0.7 miles from the Brookline Hills MBTA stop on the Green Line, D Branch.",
"entrancePasses": [
{
"cost": "0.0000",
"description": "No entrance passes required.",
"title": "No Entrance Pass"
}
],
"directionsUrl": "http://www.nps.gov/frla/planyourvisit/directions.htm",
"url": "https://www.nps.gov/frla/index.htm",
"weatherInfo": "Summer: Warm temperatures, average high temperature around 80 degrees Fahrenheit, often with humidity. July and August bring the hottest temperatures.\nFall: Cooler temperatures, mean temperatures between 45 and 65 degrees Fahrenheit, sometimes rainy. Peak fall foliage is in mid-October.\nWinter: Cold, with snow, average low temperature around 25 degrees Fahrenheit. \nSpring: Cold to cool temperatures, average mean temperatures between 40 and 60 degrees Fahrenheit.",
"name": "Frederick Law Olmsted",
"operatingHours": [],
"topics": [
{
"id": "A8E54356-20CD-490E-B34D-AC6A430E6F47",
"name": "Civil War"
},
{
"id": "AF4F1CDF-E6C4-4886-BA91-8BC887DC2793",
"name": "Landscape Design"
},
{
"id": "3CDB67A9-1EAC-408D-88EC-F26FA35E90AF",
"name": "Schools and Education"
},
{
"id": "27BF8807-54EA-4A3D-B073-AA7AA361CD7E",
"name": "Wars and Conflicts"
}
],
"latLong": "lat:42.32424266, long:-71.13202567",
"description": "Frederick Law Olmsted (1822-1903) is recognized as the founder of American landscape architecture and the nation's foremost parkmaker. Olmsted moved his home to suburban Boston in 1883 and established the world's first full-scale professional office for the practice of landscape design. During the next century, his sons and successors perpetuated Olmsted's design ideals, philosophy, and influence.",
"images": [
{
"credit": "NPS Photo",
"altText": "Fairsted",
"title": "Frederick Law Olmsted National Historic Site",
"id": "4085",
"caption": "Frederick Law Olmsted National Historic Site",
"url": "https://www.nps.gov/common/uploads/structured_data/3C853BE9-1DD8-B71B-0B625B6B8B89F1A0.jpg"
}
],
"designation": "National Historic Site",
"parkCode": "frla",
"addresses": [
{
"postalCode": "02445",
"city": "Brookline",
"stateCode": "MA",
"line1": "99 Warren Street",
"type": "Physical",
"line3": "",
"line2": ""
},
{
"postalCode": "02445",
"city": "Brookline",
"stateCode": "MA",
"line1": "99 Warren Street",
"type": "Mailing",
"line3": "",
"line2": ""
}
],
"id": "CDC0FE7F-C249-466C-88B2-B81FC5279B91",
"fullName": "Frederick Law Olmsted National Historic Site",
"latitude": "42.32424266",
"_processed": 1
},
"geometry": {
"type": "Point",
"coordinates": [
-71.13112956925647,
42.32550867371509
]
}
},
At this point it's probably a good idea to recap.
- First, we got the GeoJSON file from the NPS website. It included every park in the service, but just the name and code.
- We used the NPS API to "gradually" improve the data by fetching detailed information about each park.
- That information was then stored back in the original GeoJSON data (but saved to a new file)
The next step would be to upload the new data, but we can make it better for Studio by "flattening" and simplifying the data a bit. To do this, I wrote a new script that flattened the data for me. It also made some changes that made sense to me, but may not to you.
So for example, I thought topics
were important, but not the information on entrance passes. That was an arbitrary decision based on the hypothetical map I imagined building. Since I still have my original data I can always change my mind later, but I wanted you, my esteemed reader, to understand what I did. Here's the script:
const fs = require('fs');
let data = JSON.parse(fs.readFileSync('./national-parks-full.geojson'));
data.features = data.features.map(f => {
let newProps = {};
if(f.properties.contacts) {
if(f.properties.contacts.phoneNumbers && f.properties.contacts.phoneNumbers[0].type === 'Voice') {
newProps.phoneNumber = f.properties.contacts.phoneNumbers[0].phoneNumber;
}
if(f.properties.contacts.emailAddresses) {
newProps.emailAddress = f.properties.contacts.emailAddresses[0].emailAddress;
}
}
if(f.properties.activities) {
newProps.activities = f.properties.activities.reduce((list, activity) => {
if(list != '') list += ',';
return list += activity.name;
},'');
}
newProps.directionsInfo = f.properties.directionsInfo;
newProps.directionsUrl = f.properties.directionsUrl;
newProps.url = f.properties.url;
newProps.weatherInfo = f.properties.weatherInfo;
newProps.name = f.properties.name;
if(f.properties.topics) {
newProps.topics = f.properties.topics.reduce((list, topic) => {
if(list != '') list += ',';
return list += topic.name;
},'');
}
newProps.description = f.properties.description;
if(f.properties.images && f.properties.images.length >= 1) {
newProps.image = f.properties.images[0].url;
}
newProps.designation = f.properties.designation;
newProps.parkCode = f.properties.parkCode;
newProps.fullName = f.properties.fullName;
//console.log(newProps);
f.properties = newProps;
return f;
});
fs.writeFileSync('./national-parks-studio.geojson', JSON.stringify(data));
The script is basically one big map call where I flatten and remove stuff I didn't care about. Notice how both activities
and topics
are converted from a complex array of objects into a comma-delimited list. Here's an example of how the properties look after this conversion:
"properties": {
"phoneNumber": "6175661689",
"emailAddress": "frla_interpretation@nps.gov",
"activities": "Guided Tours,Junior Ranger Program,Museum Exhibits,Self-Guided Tours - Walking",
"directionsInfo": "Site is located on the southwest corner of Warren and Dudley Streets in Brookline, south of Route 9, near the Brookline Reservoir.\n\nSite is 0.7 miles from the Brookline Hills MBTA stop on the Green Line, D Branch.",
"directionsUrl": "http://www.nps.gov/frla/planyourvisit/directions.htm",
"url": "https://www.nps.gov/frla/index.htm",
"weatherInfo": "Summer: Warm temperatures, average high temperature around 80 degrees Fahrenheit, often with humidity. July and August bring the hottest temperatures.\nFall: Cooler temperatures, mean temperatures between 45 and 65 degrees Fahrenheit, sometimes rainy. Peak fall foliage is in mid-October.\nWinter: Cold, with snow, average low temperature around 25 degrees Fahrenheit. \nSpring: Cold to cool temperatures, average mean temperatures between 40 and 60 degrees Fahrenheit.",
"name": "Frederick Law Olmsted",
"topics": "Civil War,Landscape Design,Schools and Education,Wars and Conflicts",
"description": "Frederick Law Olmsted (1822-1903) is recognized as the founder of American landscape architecture and the nation's foremost parkmaker. Olmsted moved his home to suburban Boston in 1883 and established the world's first full-scale professional office for the practice of landscape design. During the next century, his sons and successors perpetuated Olmsted's design ideals, philosophy, and influence.",
"image": "https://www.nps.gov/common/uploads/structured_data/3C853BE9-1DD8-B71B-0B625B6B8B89F1A0.jpg",
"designation": "National Historic Site",
"parkCode": "frla",
"fullName": "Frederick Law Olmsted National Historic Site"
},
Alright, so to test this, I made a new project in Studio, uploaded my data into a new space, and did the same changes to the icons. Next, I opened up the Card editing UI. Here's the default order Studio used:
I modified this order like so:
Again, this was arbitrary, but it made sense to me. Having the name, image, description and phone number visible first seemed sensible. Also, Studio does awesome things when it discovers a URL value, like our image.
Beautiful! You can view this map project here: https://studio.here.com/viewer/?project_id=4df1f2de-df48-4300-98ee-0576f629c3c2
What's Next?
I hope this exploration into data, and the work you have to, is useful. While you may get lucky and have data "perfectly" ready for display, most likely you're going to have to do some manipulation before it's ready for a map. I highly recommend documenting your process (so you don't forget steps) and take snapshots of your data along the way so you can compare and contrast what works for you. You can start using Studio today at https://studio.here.com. Everything I've demonstrated is available on our free tier!
Have your say
Sign up for our newsletter
Why sign up:
- Latest offers and discounts
- Tailored content delivered weekly
- Exclusive events
- One click to unsubscribe