TECH

Sanity GPX Input and Map Component

JARRETT RETZ June 11th, 2021 sanity programming javascript react input mapping gpx reactjs frontend web development

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
    }
}
Getting Started with Sanity

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='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
                    url="customMapboxUrlWithAccessToken"
                />
                {points && points.length > 0 && <Polyline positions={points} />}
            </Map>
        </div>
    )
}

export default PreviewMap;
Mapbox

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.


Have a thought about the article?

Send JRTS a message!

We'll use this email to respond to your message.
Contact