Introduction
Last week I built a map in Python that displayed the GPS coordinates from a GPX file on a map inside a Jupyter Notebook. I got the GPX file from an app, Maps3D that I use when hiking. I thought this was pretty cool because the file included lat/long, time, and altitude data.
I thought, wow, I want to do this for my hiking posts or trip reports.
I use Sanity.io as my content management system. Consequently, I use their hosted Sanity Studio for adding article data, etc., to my website. Sanity lets the user define schemas for different content types. There are image, URL, array, and many more built-in types. However, you can create more complex schemas like a post
schema for a blog article or youtube
schema for YouTube videos.
I realized I needed to build a schema for my map data before I could get to rendering a map with the GPX data on the front end. But—even before that—I wanted the ability to drag-n-drop the GPX file into the Sanity Studio and see the points on a preview map.
So, this post is how I could define a schema, create a custom input component, and preview the map as block content for an article.
Schema
Sanity recommends not to build schemas based on the appearance, or display, of data. Despite that warning, a few properties in the schema directly map to how I plan to display this map on the frontend. Most front-end maps have a zoom and center property and have drag and zoom configuration options.
Below is the map
schema, to use it, remember to import it into your schema definitions file.
// import UploadMap from '../../components/UploadMap'
// import LeafletGeopointInput from 'sanity-plugin-leaflet-input'
// import PreviewMap from "../../components/PreviewMap";
export default {
title: 'Map',
type: 'object',
name: 'map',
fields: [
{
name: 'title',
title: 'Map Title',
type: 'string'
},
{
name: 'center',
title: 'Center of map',
type: 'geopoint',
validation: Rule => Rule.required(),
// inputComponent: LeafletGeopointInput
},
{
name: 'zoom',
title: 'Zoom Level',
type: 'number',
validation: Rule => Rule.required().min(1).max(12)
},
{
name: 'canZoom',
title: 'Zoomable',
type: 'boolean'
},
{
name: 'canDrag',
title: 'Draggable',
type: 'boolean'
},
{
name: 'points',
title: 'Points',
type: 'array',
// inputComponent: UploadMap,
of: [
{
type: 'geopoint'
}
]
}
],
preview: {
select: {
title: 'title',
center: 'center',
zoom: 'zoom',
draggable: 'canDrag',
zoomable: 'canZoom',
points: 'points'
},
// component: PreviewMap
}
}
This post won't detail how to create a post schema or customize the block content editor. In short, I added the new map type to my block content schema. You can learn how to do these things in the Sanity docs.
Input Components
Sanity Studio handles the majority of the input components well. That means that we don't need to change the string and boolean field inputs.
Optionally, I installed Espen Hovlandsdal's Leaflet Input component for selecting the center coordinates of my map.
All that took was installing the package and adding setting the inputComponent
property for that field. This component saves me from having to copy and paste coordinates into the form using Google Maps.
At the bottom of the input dialog, you can see the zoom level and a couple of boolean inputs that are fine. The Points input is where we're going to implement a drag-n-drop input.
Preview the Map
I built a custom preview component for the map
type so I could tell if my drag-n-drop works. It uses Leaflet, and I got a lot of help from Evan's Leaflet input component code because a few problems arose.
Also, it's worth noting that I'm using react-leaflet
2.7 because the latest version was having problems.
import React from 'react'
import {
Map, TileLayer, Polyline
} from 'react-leaflet'
import leafStyles from "./leaflet.css";
const PreviewMap = ({value}) => {
const {
center = {lat: 46.83157139843617, lng: -121.6481687128544},
draggable = false,
points = "",
title = "",
zoom = 13,
zoomable = false
} = value;
return (
<div style={{ padding: '8px', paddingTop: '40px' }} className={leafStyles.leaflet}>
<h2>{title}</h2>
<Map
style={{
width: '100%',
height: '400px'
}}
center={[center.lat, center.lng]}
zoom={zoom}
zoomControl={zoomable}
scrollWheelZoom={false}
>
<TileLayer
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
url="customMapboxUrlWithAccessToken"
/>
{points && points.length > 0 && <Polyline positions={points} />}
</Map>
</div>
)
}
export default PreviewMap;
I'm using a custom Mapbox style for my map. Therefore, if you want to copy the code, you'll need a Mapbox account and your own tiles URL with access token.
What is a GPX File?
GPX files are, to my surprise, in XML format.
"A GPX file is a GPS data file saved in the GPS Exchange format, which is an open standard used by many GPS programs. It contains longitude and latitude location data that may include waypoints, routes, and tracks. GPX files are saved in XML format, which allows GPS data to be more easily imported and read by multiple programs and web services."
https://fileinfo.com/extension/gpx
The goal is to create an array of geopoints that Sanity can digest and save. The geopoint object looks like this:
{
"_type": "geopoint",
"_key": `coords`, // unique string within array
"lat": lat,
"lng": lon,
"alt": ele
}
We need to:
- Read the file
- Parse the GPX
- Transform the data to an array of geopoints
- Save the array to Sanity's data lake
Custom GPX Input Component
I installed two libraries and watched one YouTube video to help me build the component.
The two libraries:
react-dropzone
gpxparser
The YouTube video was a Sanity tutorial on custom inputs for maps and geopaths:
// /src/MyCustomString.js
import React, { useCallback, useMemo } from 'react';
import { useDropzone } from 'react-dropzone';
import { Stack, Label, Text, Button } from '@sanity/ui';
import { FormField } from '@sanity/base/components';
import PatchEvent, { set, unset, insert } from '@sanity/form-builder/PatchEvent'
import gpxParser from 'gpxparser';
/**
* Start react-dropzone styles
* https://react-dropzone.js.org/#!/Styling%20Dropzone
*/
const baseStyle = {
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '20px',
borderWidth: 2,
borderRadius: 2,
borderColor: '#eeeeee',
borderStyle: 'dashed',
backgroundColor: '#fafafa',
color: '#bdbdbd',
outline: 'none',
transition: 'border .24s ease-in-out'
};
const activeStyle = {
borderColor: '#2196f3'
};
const acceptStyle = {
borderColor: '#00e676'
};
const rejectStyle = {
borderColor: '#ff1744'
};
/**
* Stop react-dropzone styles
*/
const Map = React.forwardRef((props, ref) => {
/**
*
* The onDrop function executes after the file is dropped
*
* Most of the code is from react-dropzone docs
* https://react-dropzone.js.org/#section-event-propagation
*/
const onDrop = useCallback((acceptedFiles) => {
acceptedFiles.forEach((file) => {
const reader = new FileReader()
reader.onabort = () => console.log('file reading was aborted')
reader.onerror = () => console.log('file reading has failed')
reader.onload = () => {
// Access text result of file
const binaryStr = reader.result;
// Create gpxParser Object using gpxparser library
let gpx = new gpxParser();
// Parse GPX
gpx.parse(binaryStr);
// Step down into object and grab data
const track = gpx.tracks[0];
const distance = track?.distance?.total; // meters
const eleMax = track?.elevation?.max; // meters
const eleMin = track?.elevation?.min; // meters
const elePos = track?.elevation?.pos; // meters
const eleNeg = track?.elevation?.neg; // meters
/**
*
* Transform point data to Sanity geopoint
*
* The 'map' schema has a 'points' property which is
* an array of geopoints
*
*/
const points = track?.points?.map(point => {
return {
"_type": "geopoint",
"_key": `coord-${point.lat}${point.lon}`, // unique string within array
"lat": point.lat,
"lng": point.lon,
"alt": point.ele
}
})
// Save to Sanity
props.onChange(PatchEvent.from([
set(points)
// insert(points, 'after', [-1])
]))
}
reader.readAsText(file)
})
}, [])
// react-dropzone callbacks
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject
} = useDropzone({ onDrop });
// react-dropzone styling
const style = useMemo(() => ({
...baseStyle,
...(isDragActive ? activeStyle : {}),
...(isDragAccept ? acceptStyle : {}),
...(isDragReject ? rejectStyle : {})
}), [
isDragActive,
isDragReject,
isDragAccept
]);
const clearPoints = () => {
props.onChange(PatchEvent.from([
unset()
]))
}
return (
<Stack space={2}>
<FormField
description={props.type.description} // Creates description from schema
title={props.type.title} // Creates label from schema title
__unstable_markers={props.markers} // Handles all markers including validation
__unstable_presence={props.presence} // Handles presence avatars
compareValue={props.compareValue} // Handles "edited" status
>
{props.value ?
<>
<Text size={2}>You've added {props.value.length} points</Text>
<Button onClick={clearPoints} text="Clear Points" tone="critical" style={{ margin: '15px 0px' }} padding={[3, 3, 4]} />
</>
:
<div className="container">
<div {...getRootProps({ style })}>
<input {...getInputProps()} />
<p>Drag 'n' drop GPX files here</p>
</div>
</div>
}
</FormField>
</Stack >
)
})
export default Map
Dragging a GPX file into the drop zone will parse the GPX file and patch the data points into the document. The preview map should automatically update with the route data because Sanity Studio sends patch events on every edit.
Leaflet can take the geopoint array and render a blue line representing the trip.
I love it, and I'm excited to build a serializer or adapt the preview component to use on the frontend.