Building a reactive scrolling map article in Svelte 🗺
Tags: svelte, frontend, javascript, tutorial
Published: 2019-12-15 16:17:10

Love it or hate it, [Eater](https://www.eater.com/) has a great interactive map listicle (mapicle? 🤔) format. For lists of places, it makes a complicated reading experience simple and fun. Here's an [example](https://chicago.eater.com/maps/best-chicago-coffee-shops-map) if you've never read one.

I wanted to try implementing it in Svelte so I decided to make this tutorial! 

Here's the [demo](https://bryce.io/svelte-reactive-map-list) in action. I used an [article](https://sprudge.com/the-sprudge-guide-to-coffee-in-kyoto-128389.html) on coffee recommendations in Kyoto from Sprudge for the content, I hope they don't mind. 😇

---

The page can be broken down into two components: (1) listening to and overriding the scroll position of the text and (2) adjusting the center of the map. More directly, these two user interactions:
 - Scroll to place in list; map centers on the corresponding map marker
 - Click on a map marker; list scrolls to the corresponding position in list

## Setting things up 🏗

Some basic scaffolding to get things started.

### index.html: to render the page

Just your basic HTML file, nothing crazy here. 

```html
<!DOCTYPE html>
<html>
<body>
  <script src="./main.js"></script>
</body>
</html>
```

### main.js: to mount our Svelte app

Like most frontend frameworks, Svelte needs to know what DOM element to hook into.

```js
import App from './components/App.svelte';

const app = new App({
  target: document.body
});

export default app;
```

### App.svelte, List.svelte, and Map.svelte: where the logic will go

> Note: since this isn't a CSS tutorial, I left out the styles in each of these components to keep the article shorter. Same for some configuration (map setup, etc). You can see the full source on <a href="https://github.com/brycedorn/svelte-reactive-map-list/blob/master/src/components">GitHub</a> to fill in the blanks.

## Creating the components 👷‍♀️

### App.svelte

Sets up the left/right containers and renders components within them.

This is what [Svelte components](https://svelte.dev/tutorial/svelte-component) look like. Much like Vue, all of the code associated with a component is contained within one file which makes it simple to encapsulate logic.

```html
<style>
  .container {
    height: 100vh;
    display: flex;
  }

  .pane {
    display: flex;
    flex-direction: column;
    width: 50%;
  }
</style>

<script>
  import List from './List.svelte';
  import Map from './Map.svelte';
</script>

<div class="container">
  <div class="pane left">
    <List />
  </div>
  <div class="pane right">
    <Map />
  </div>
</div>
```

### List.svelte 📝

The visual part is simple, just an `#each` iterator on the list. I included the `index` to be able to differentiate them when checking visibility. Note the `@html` tag to render the description, as I want to render the `<a>` tags properly:

```html
<div id="list-items" bind:this={listRef}>
  {#each listItems as listItem, index}
    <div class="list-item" id="list-item-{index}">
      <img src="{listItem.image}" alt="{listItem.name}" />
      <a href="{listItem.website}"><h2>{listItem.name}</h2></a>
      {@html listItem.description}
    </div>
  {/each}
</div>
```

Now for scroll listening/setting. We can only do this once the component is ready, so let's use the `onMount` lifecycle method Svelte provides. I'm also going to use [in-view](https://www.npmjs.com/package/in-view) to check if DOM elements are in the viewport.

👀 Did you notice that `bind:this` above? That gives us a reference to the DOM element, so we can put a listener on it:

```js
<script>
  import { onMount } from "svelte";

  // Define the ref
  let listRef;

  listRef.addEventListener('scroll', function(e) {
    // Active list item is top-most fully-visible item
    const visibleListItems = Array.from(document.getElementsByClassName('list-item')).map(inView.is);
    // Array.indexOf() will give us the first one in list, so the current active item
    const topMostVisible = visibleListItems.indexOf(true);
   });
</script>
```

So now we know based on _scrolling_ what the current active list item is, now what? Let's set it in a [`store`](https://svelte.dev/tutorial/writable-stores) (you'll see why later):

```js
// Import above
import { activeListItem } from './stores.js';

if (topMostVisible !== $activeMapItem) {
  activeListItem.set(topMostVisible);
}
```

Here's what stores.js looks like:

```js
import { writable } from 'svelte/store'

// 0 is the default value, e.g. first item in list
export const activeListItem = writable(0);
```

### Map.svelte 🌎

I'm using Mapbox over Google Maps since it has the highest free tier (50k/daily requests), and has way better documentation.

The visual part of this component is simple, just a `<div>` with an `id` that Mapbox can hook into. Again, we need to use `onMount` to wait until the component is ready to perform operations:

```js
onMount(async () => {
  // Create the map
  mapRef = new mapboxgl.Map({
    container: "map"
  });
});
```

There are two things the map needs: (1) markers for each location, and (2) click handlers for each marker. To add the markers, we'll use the `addLayer` function on `mapRef` to add a `FeatureCollection` to the map once it's ready:

```js
mapRef.on('load', function () {
  // Add markers to map
  mapRef.addLayer({
    id: 'places',
    type: 'symbol',
    source: {
      type: 'geojson',
      data: {
        type: 'FeatureCollection',
        features: listItems.map(generateFeature)
      }
    }
  });
});
```

I made a `generateFeature` helper to generate a marker from a list item, you can see it [here](https://gitlab.com/brycedorn/svelte-reactive-map-list/blob/master/src/components/Map.svelte#L26). To to show the popup & center the map on click, we'll add another event listener - this time for the map's `click` event (with the `id: places` from above):

```js
// When clicking on a map marker
mapRef.on('click', 'places', function ({
  coordinates
}) {
  // Show popup
  new mapboxgl.Popup()
    .setLngLat(coordinates)
    .setHTML(description)
    .addTo(mapRef);
  // Center the map on it
  mapRef.flyTo({
    center: coordinates
  });
});
```

To tell the list that this is the new active item, we can reference the same `store` as the list, e.g.`activeListItem.set`.

## Inter-component communication 🗣

All we need to do now is listen for changes in each component. This is why we used a `store`! It's as simple as calling `store.subscribe`, but we'll need the `onDestroy` lifecycle method to stop listening on unmount:

```js
import { onDestroy } from "svelte";

// Update map center when active list item is updated via list
const unsubscribeActiveMapItem = activeMapItem.subscribe(newActiveMapItem => {
  if (mapRef) {
    mapRef.flyTo({
      center: listItems[newActiveMapItem].coordinates
    });
  }
});

// Remove listener on unmount
onDestroy(unsubscribeActiveMapItem);
```

Then repeat this for the list, but replacing `mapRef.flyTo` with `listRef.scrollTop = newActiveListItem.offsetTop`. You could animate this like the Eater article for a nicer experience, but I didn't.

### Minor gotcha ✋

Because the `subscribe` works both ways, the list will update its own scroll position (annoying when there's a visibility threshold and it will jump to the next article mid-scroll). This is easily remedied by keeping _separate_ stores for what the map and list think is active, respectively.

---

And voilà! 🎉 A functional reactive map that listens to both sides of the page. You can play with it [here](https://brycedorn.gitlab.io/svelte-reactive-map-list/), or clone the [repo](https://gitlab.com/brycedorn/svelte-reactive-map-list) to run it locally. I hope this helped you understand the benefits of Svelte, and may you use it for tiny projects like this in the future!