Build A Map View With Remix and MapKit JS

Photo by Domino on Unsplash

Build A Map View With Remix and MapKit JS

Featured on Hashnode

Gowalla is all about sharing where you're going with close friends. We deal a lot in maps and map experiences. The case is not so different in our first web product, Lists, where you share places you care about. Here's an example of F1 Tracks Around the World!

Now the actual Lists feature is still in development at the time of this writing, but we gave a sneak peek of the web view. It being our first big web project, I wanted to make sure it set the tone for future web products we build which meant:

  • Interactions feel snappy, fluid and otherwise engaging.

  • Support and encourage sharing your Lists.

  • Try to match the character of our iOS app with animations.

There's a lot I could dive into for this project so I'll follow up with separate posts on things like our custom Open Graph images in a separate post. This article will focus on building a map experience using Remix.

What I'm Trying To Build

It's kind of a standard map and list layout found around the web on sites like Zillow or AirBnB. The interesting bit is tapping a Place in the list will take you to a Place Detail View that shares a similar layout.

The trick would be sharing the map context when navigating. I didn't want the map to reload, but instead, update the markers rendered and pan to the associated marker for the selected Place.

Luckily Remix is really good at this sort of thing! My game plan was to utilize the nested routes feature to maintain the map while updating the side panel with the active subroute.

As far as the actual map goes we knew we wanted to use Apple Maps for two primary reasons:

  • Gowalla is an iOS-only product at the moment and aesthetically we wanted to match.

  • With our recent app launch, we thought staying in the Apple ecosystem would nudge the App Store folks to feature us ;).

With that in mind let's get into the details.

Remix Routes

This bit is fairly straightforward at a high level, but some considerations helped inform the more nuanced bits like coordinating map state.

I landed on 3 route files:

_map.lists.$slug.$listId.tsx
_map.places.$slug.$placeId.tsx
_map.tsx

I landed here because when navigating to a Place Detail View and sharing it I felt it was important to share a canonical Place resource, so something that could stand alone from the Lists feature. Alternatively, I considered nesting the place route under lists so something like:

_map.places.$slug.$listId.$placeId.tsx

but the Place Detail View didn't truly feel like it was owned by or should be nested under a List. Additionally, there's potentially more SEO juice when sharing around a /places/:slug/:id. Someone in the Remix Discord suggested an interesting idea of supporting both routes by reusing the route module definition in a second route module but ultimately didn't feel it was necessary. The final concession was even though the Place Detail View would live under a canonical place URL the UX still needed to support a List context within the Place Detail View to support navigating back to a List and paging through a set of Places from the Place Detail View. This is achieved using a query param with the List ID to conditionally fetch List data and render the navigation UI.

Yet another alternative…
I also messed with using Location state and passing in list data so the Place route could render the necessary UI, but we decided it was confusing that after refreshing you would lose that state and perhaps people would intentionally want to share a Place link w/ the List context included.

The slight trade-off to this approach is redundancy in fetching List data again where if I had nested the Place under List I could reuse that data. This can be mitigated with some caching if it was truly an issue, but the performance is negligible in this case.

_map.tsx

This pathless layout route is the parent of the List and Place routes. In addition to rendering the map, it coordinates with matching child routes to know what markers to render. This means there has to be a shared understanding of markers. In a following section I'll go into more detail about Apple Map specifics, but for now let's focus on the exported Component.

export default function Component() {
  const { mapKitToken } = useLoaderData<typeof loader>()
  // support list hover/focus interaction so map can focus the 
  // associated marker 
  const [focusedPlaceId, setFocusedPlaceId] = useState<null | string>(null)

  const matches = useMatches()

  const hasMarkers = matches.filter((m) => m.data?.markers)
  const markers = hasMarkers?.[0]?.data.markers ?? []

  return (
    <div className="bg-white map-layout">
      <div
        className="h-[320px] desktop:h-[100vh] w-full"
        style={{ '--stamp-transition-duration': 100 } as CSSProperties}
      >
        <MapView
          token={mapKitToken}
          markers={markers}
          focusedPlaceId={focusedPlaceId}
        />
      </div>
      <Outlet
        context={{ focusedPlaceId, setFocusedPlaceId }}
      />
    </div>
  )
}

Pretty simple implementation, setting up the layout, rendering a single MapView component and the Outlet component which accepts some context for child routes to consume as needed. The loader is also very simple and only handles fetching a MapKit JS token.

export async function loader({ request }: LoaderArgs) {
  return json({
    mapKitToken: mapKitToken(),
  })
}

The MapView component is a wrapper that uses the ClientOnly component from remix-utils which I'll expand on in a section below, but the actual Map implementation looks something like this:

// This component is rendered via ClientOnly in a `MapView` component.
function ClientMap({
  token,
  markers,
  focusedPlaceId,
}: {
  token: string;
  focusedPlaceId?: boolean | null;
  markers: {
    lat: number;
    lng: number;
    place?: Place;
  }[];
}) {
  const mapRef = useRef<null | mapkit.Map>(null);

  useEffect(() => {
    if (!mapRef.current) return;

    handleMapChange(mapRef.current, markers);
  }, [markers]);

  const mapCallback = useCallback(
    (node: null | mapkit.Map) => {
      if (node === null) return;

      mapRef.current = node;
      mapkit.addEventListener("configuration-change", function (event) {
        switch (event.status) {
          case "Initialized":
            handleMapChange(node, markers);
            // force because mapkit-react prop not working as expected
            node.showsPointsOfInterest = false;
            break;
        }
      });
    },
    [markers]
  );

  return (
    <Map
      token={token}
      ref={mapCallback}
    >
      {markers.map(({ lat, lng, place, size, toUrl }) => {
        const Comp = toUrl ? Link : "div";
        const props = toUrl ? { to: toUrl } : {};
        return (
          <Annotation
            key={place.id}
            latitude={lat}
            longitude={lng}
            selected={focusedPlaceId === place?.id}
          >
            <Comp {...props}>
              <StampArt />
            </Comp>
          </Annotation>
        );
      })}
    </Map>
  );
}

The code is annotated with some rationale, but essentially we want to:

  • Help MapKit know the initial map bounds instead of rendering far away and then panning on load.

  • Optionally render a Link if desired.

  • Handle map initialization and when markers change (aka when route changes).

const DEFAULT_DELTA = 0.02;

function centerMarker(map, marker) {
  map.setRegionAnimated(
    new mapkit.CoordinateRegion(
      new mapkit.Coordinate(centeredMarker.lat, centeredMarker.lng),
      new mapkit.CoordinateSpan(DEFAULT_DELTA, DEFAULT_DELTA)
    )
  );
}

function fitToMarkers(map) {
  map.showItems(map.annotations, {
    animate: true,
    padding: new mapkit.Padding(20, 20, 20, 20),
  });
}

function initialRegion() {
  let initialRegion = undefined;
  if (markers.length > 0) {
    let minLat = markers[0].lat;
    let maxLat = markers[0].lat;
    let minLng = markers[0].lng;
    let maxLng = markers[0].lng;

    for (let i = 1; i < markers.length; i++) {
      let lat = markers[i].lat;
      let lng = markers[i].lng;

      if (lat < minLat) {
        minLat = lat;
      }

      if (lat > maxLat) {
        maxLat = lat;
      }

      if (lng < minLng) {
        minLng = lng;
      }

      if (lng > maxLng) {
        maxLng = lng;
      }
    }

    // Calculate the center latitude and longitude
    let centerLat = (minLat + maxLat) / 2;
    let centerLng = (minLng + maxLng) / 2;

    // Calculate the latitude and longitude deltas
    let latDelta = maxLat - minLat;
    let lngDelta = maxLng - minLng;

    // Create the region object
    initialRegion = {
      centerLatitude: centerLat,
      centerLongitude: centerLng,
      latitudeDelta: latDelta > 0 ? latDelta : DEFAULT_DELTA,
      longitudeDelta: lngDelta > 0 ? lngDelta : DEFAULT_DELTA,
    };
  }

  return initialRegion;
}

function handleMapChange(map, markers) {
  const centeredMarker = R.find(markers, (m) => m.center);
  if (centeredMarker) {
    centerMarker(mapRef.current, centeredMarker);
  } else {
    fitToMarkers(mapRef.current);
  }
}

So we now have a pathless layout route that will render markers (or Annotations per MapKit terms) provided by child routes. When navigating between routes using Link the map rendering will persist and MapKit will handle animations/panning.

_map.lists.$slug.$listId.tsx

I won't focus too much on the list UI itself, but more so on providing marker data.

export async function loader({ request, params }: LoaderArgs) {
  const list = await fetchList(params.listId);

  const markers: {
    lat: number;
    lng: number;
    place?: Place;
  }[] = list.places.map((place) => {
    return {
      lat: place.location?.latitude ?? 0,
      lng: place.location?.longitude ?? 0,
      place,
      toUrl: `${place.urlPath}?listId=${result.data.id}`,
    };
  });

  return json({
    list,
    markers,
  });
}

Simply including markers in my loader data will cause the parent route to render them accordingly. If I were investing more time or in a more robust approach (say we planned on developing many more map views in the near future) I'd probably codify this convention in the _map route module and use it here… something like:

import { mapMarkers } from './_map'

export async function loader() {
    // light wrapper that returns a { markers } object
    return json(mapMarkers(markers))
}

To close the thread of why I'm using Outlet context is when list items are hovered we want to highlight the associated on the marker. That looks a little like this:

export default function Component() {
  const { list } = useLoaderData<typeof loader>();
  // useFocusedPlaceId is just a wrapper around useOutletContext
  const { setFocusedPlaceId } = useFocusedPlaceId()

  return (
    <ul>
      {list.places.map((item) => (
        <ListItem 
            key={item.place.id}
            title={place.name}
            onMouseEnter={setFocusedPlaceId(place.id)} 
        />
      ))}
    </ul>
  );
}

_map.places.$slug.$placeId.tsx

Again I'll focus more on the map coordination than the Place Detail UI itself.

export async function loader({ params, request }: LoaderArgs) {
  const searchParams = new URL(request.url).searchParams
  const listId = searchParams.get('listId')

  // in this case we'll also attach the associated list to 
  // the place object (e.g. place.list.places…)
  const place = await fetchPlace(params.placeId, { listId })

  // On Place detail we only want to render a single marker, but 
  // if we still wanted to render them all we could when `listId` 
  // is provided.
  const markers = [
    {
      lat: place.location?.latitude ?? 0,
      lng: place.location?.longitude ?? 0,
      place,
      // This keys the _map layout to center on this marker. 
      center: true,
    },
  ]

  return json({ place, markers })
}

Here we only want to render a single marker (even if we have all places in the list when listId is provided). We can set center to true which is a soft convention for _map. This could also be codified in a helper provided by _map.

The interesting bit in the Place Detail UI is rendering the List navigation when listId is provided, but that's a simple conditional check on if list data is present on the place object.

Optimizations and Other Bits

Ok so that's the high level of building a map view using Remix. I wanted to stay as concise as possible and the actual implementation is a bit more involved. Things like performance, security, and dealing with third-party APIs require a lot of code it turns out!

Caching and Prefetching

Just going to post some code snippets that hopefully provide enough context to kick-start your light bulb. To achieve a snappy UX I relied heavily on Remix's link prefetching and standard HTTP caching.

// When hovering over these the data for the Place Detail view is cached in the browser so when actually navigating the loading feels instant.
<Link to={placeUrl} prefetch="intent" />

As far as HTTP caching I'm able to set a public Cache-Control header for about 2 minutes. Long enough to keep things snappy, but short enough to keep things like the list view count some what accurate.

Preconnecting to various CDNs used in MapKit and our own.

// in _map.tsx
export const links: LinksFunction = () => {
  return [
    {
      rel: 'preconnect',
      href: 'https://world.gowalla.com',
    },
    {
      rel: 'preconnect',
      href: 'https://cdn.apple-mapkit.com',
    },
    {
      rel: 'preconnect',
      href: 'https://cdn1.apple-mapkit.com',
    },
    {
      rel: 'preconnect',
      href: 'https://cdn2.apple-mapkit.com',
    },
    {
      rel: 'preconnect',
      href: 'https://cdn3.apple-mapkit.com',
    },
    {
      rel: 'preconnect',
      href: 'https://cdn4.apple-mapkit.com',
    },
  ]
}

MapKit JS with mapkit-react

Using a nice light react wrapper around MapKit JS https://github.com/Nicolapps/mapkit-react.

By default, it will inject the mapkit javascript resource and is quite performant by not blocking rendering. I was trying to improve the loading perf so much that I actually discovered it isn't reliable to statically render the script tag and utilize the data-callback attribute. Hit a lot of race conditions (mostly in non-Safari browsers… conspiracy?!) using this approach so I fell back to injecting the script. The mapkit-react component takes a load prop to customize how the map is loaded and this is where I landed:

import { Map } from 'mapkit-react'

<Map
    load={(token: string) =>
    new Promise((resolve) => {
      const element = document.createElement('script')
      // @ts-ignore-next-line
      window.initMapKit = () => {
        // @ts-ignore-next-line
        delete window.initMapKit
        window.mapkit.init({
          // this should actually fetch a resource route/api
          // that produces a new token on demand. as is 
          // the map will stop rendering if the tab is left open.
          authorizationCallback: (done) => {
            done(token)
          },
        })
        resolve()
      }
      element.src = 'https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.core.js'
      element.dataset.initialToken = token
      // we have a very specific use case for the map and 
      // don't need every feature.
      element.dataset.libraries = 'map,annotations'
      // this is a mapview so loading this is p important.
      element.setAttribute('fetchpriority', 'high')
      element.crossOrigin = 'anonymous'
      document.head.appendChild(element)
    })
  }
  token={token}
  // disable anything we don't care about
  showsPointsOfInterest={false}
  showsMapTypeControl={false}
  showsUserLocationControl={false}
  showsUserLocation={false}
  allowWheelToZoom
/>

This article is getting lengthy, but there are a lot of other fun details I hope to cover soon. If you want to chat Remix or maps in Remix hit me up in their Discord drk#9638 or on twitter!