Snowboarding
Last updated:

This page is a continuing work in progress in which I am building an interactive map of all the places on Earth where I have snowboarded. Click on the marked locations to reveal (maybe, if I had a chance to add them) some of my thoughts, stories, and/or interesting facts about a particular location. If you are interested, see below for some implementation details.
Mammoth Mountain
Bear Mountain
Snow Summit
Snow Valley
Mount Baldy
June Mountain
Palisades Tahoe
Heavenly Mountain Resort
Revelstoke Mountain Resort
Big White Ski Resort
Banff Sunshine
Lake Louise
Mount Norquay
Stowe Mountain Resort
Copper Mountain
Winter Park Resort
Loveland Ski Area
Arapahoe Basin
Big Sky Resort
Hausberg, Garmisch-Partenkirchen
Fun fact: This is where I learned how to snowboard.
Oedberg, Tegernsee
Schatzberg, Wildschönau
St. Anton am Arlberg
Chamonix Mont-Blanc
Sestriere, Via Lattea
Zermatt Matterhorn
Implementation details noadd
Implementation details...
I built this page using Org Mode (obviously), Protomaps, and MapLibre GL JS. This file starts with a list of locations as regular Org headlines in plain text. I define metadata like the latitude and longitude using a property drawer. For example, the headline and property drawer for Mammoth Mountain looks like this:
* Mammoth Mountain :PROPERTIES: :HTML_CONTAINER_CLASS: location-info :CUSTOM_ID: mammoth :LONGITUDE: -119.02920 :LATITUDE: 37.63119 :LOGO: /img/gallery/mammoth-logo.svg :END:
At export time, I run some Emacs Lisp code which reads these property drawers, generates a GeoJSON object for each headline, and saves them as a FeatureCollection Object in the JavaScript environment. I then use MapLibre GL JS to generate a world map where I overlay markers from this collection. The JavaScript code supporting the map then dynamically hides and un-hides whichever location was clicked to reveal the contents of the Org mode headline. As usual this is all a bit wonky, but it was a lot of fun to put together. All code for this can be found below, for a longer meditation on self-hosted mapping see this blog post.
Emacs Lisp
This snippet generates the GeoJSON FeatureCollection from my org-mode headlines and inserts it into the exported HTML:
(defun generate-loc-data () "Generate a GeoJSON FeatureCollection from the property drawers of the headlines in this file" (let ((locs)) (org-map-entries (lambda () (let ((props (org-entry-properties))) (cl-flet ((get (p) (alist-get p props nil nil #'equal))) (push `((type . "Feature") (geometry . ((type . "Point") (coordinates . ,(vconcat (mapcar #'string-to-number (list (get "LONGITUDE") (get "LATITUDE"))))))) (properties . ((name . ,(get "ITEM")) (id . ,(get "CUSTOM_ID")) ; this links the Feature to the org headline container... (logo . ,(get "LOGO"))))) locs)))) ;; with tag :noadd: can disable export! "-noadd" (list (file-name-concat my-website-pages-dir "snowboarding.org"))) (json-serialize `((type . "FeatureCollection") (features . ,(vconcat locs)))))) ;; add the location data as javascript source code directly into the document (concat "<script type=\"text/javascript\">const locations=" (generate-loc-data) ";</script>")
JavaScript
This code gives some scaffolding around MapLibre GL JS which lets me define markers and popups:
// -*- mode: js; -*- // some js that can be used to create a clickable, zoomable world map with // clickable logo popups, etc. // a home button for maplibre-gl-js that flies to the starting location // cc: https://stackoverflow.com/a/74283884 class ResetControl { // can pass in a reset function that is called on click as well constructor(reset_fun) { this._reset_fun = reset_fun; } onAdd(map) { this._map = map; this._container = document.createElement('div'); this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group'; this._container.addEventListener('contextmenu', (e) => e.preventDefault()); this._container.addEventListener('click', (e) => this.onClick()); this._container.innerHTML = `<button type="button" title="Reset map" aria-label="Reset map" aria-disabled="false"> <svg focusable="false" viewBox="0 0 24 24" aria-hidden="true" style="font-size: 20px;"><title>Reset map</title><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"></path></svg> </button>`; return this._container; } onClick() { this._reset_fun && this._reset_fun(); if (this._map.popups) { this._map.popups.forEach((p)=> { p.remove(); }) } this._map.flyTo({center: starting_location, zoom: starting_zoom, speed: 1, pitch: 0, roll: 0, bearing: 0}); } onRemove() { this._container.parentNode.removeChild(this._container); this._map = undefined; } } // generate a popup from a GeoJSON feature with the logo and name as contents // and the correct value of the offset property function createPopup(location) { const logo_height = Math.min(document.getElementById("map-container").offsetHeight / 6, 60) - 10; // generate and style the popup const elem = document.createElement("div"); elem.className = "map-popup"; if (location.properties.logo) { const img = document.createElement("img"); img.className = "map-popup-logo"; img.setAttribute("src", location.properties.logo) elem.append(img); } const text = document.createElement("div"); text.className = "map-popup-text"; text.textContent = location.properties.name; elem.append(text); // ok so this is a bit of a hack. For reasons, I don't want to bind the // popup to a marker. I instead wrap each popup in closures. however, one // nice thing that happens when you bind a popup to a marker is that the // library computes the offset of the marker for you. see here: // https://github.com/maplibre/maplibre-gl-js/blob/1a8ef7ff9848105e49a29e3f5e59a52c14b9fbe2/src/ui/marker.ts#L447 // since I don't get this for free anymore, I set the offset here and // everything should work... Note that if the default marker changes, this // will have to be recomputed... const markerHeight = 41 - (5.8 / 2); const markerRadius = 13.5; const linearOffset = Math.abs(markerRadius) / Math.SQRT2; const offset = { 'top': [0, 0], 'top-left': [0, 0], 'top-right': [0, 0], 'bottom': [0, -markerHeight], 'bottom-left': [linearOffset, (markerHeight - markerRadius + linearOffset) * -1], 'bottom-right': [-linearOffset, (markerHeight - markerRadius + linearOffset) * -1], 'left': [markerRadius, (markerHeight - markerRadius) * -1], 'right': [-markerRadius, (markerHeight - markerRadius) * -1] }; const popup = new maplibregl.Popup({ closeButton: true, closeOnClick: false, closeOnMove: false, offset: offset, }); popup.setLngLat(location.geometry.coordinates); popup.setDOMContent(elem); return popup; } // configure the map object after it is loaded function configureMap(map, places_geoJSON, activate_fun, reset_fun) { map.on("load", async () => { // add zoom and reset buttons map.addControl(new maplibregl.NavigationControl({showZoom: true, showCompass: false}), "bottom-left"); map.addControl(new ResetControl(reset_fun), "bottom-left"); // save the popups as part of the map... dirty but whatever. map.popups = []; // add clickable markers for location data ("locations" variable is an // array of GeoJSON Features that I generated from org-mode) places_geoJSON.features.forEach((loc) => { const marker = new maplibregl.Marker({className: "map-marker", color: "#ff4646"}) .setLngLat(loc.geometry.coordinates) .addTo(map) // add a reference to the location object to the marker marker.feature = loc; // create a popup, but don't bind it to the marker (see comment // above). also don't show it at first. const popup = createPopup(loc); popup.remove(); map.popups.push(popup); // show a popup when hovering over a marker. however, when clicked, // we want the popup to stay! const removePopup = (e) => { popup.remove(); } const addPopup = (e) => { marker.getElement().addEventListener("mouseleave", removePopup); popup.addTo(map); } marker.getElement().addEventListener("mouseenter", addPopup); // zoom to the marker and activate the location on click const active_zoom = 6.4; // when zoomed in on a location marker.getElement().addEventListener("click", (e) => { marker.getElement().removeEventListener("mouseleave", removePopup); activate_fun && activate_fun(marker.feature); map.flyTo({center: marker.getLngLat(), zoom: active_zoom, speed: 1, pitch: 0, roll: 0, bearing: 0}); }); // save the markers and popups. we mainly need the popups marker.addTo(map); }); }); }
Finally, this is how the map gets actually loaded on page startup:
// hold the map object let map; // we always start out with a full view of the world const starting_location = window.screen.width > 760 ? [11.344, 26.352] : [7.758, 34.834]; const starting_zoom = window.screen.width > 760 ? 1 : - 0.404; // activate a location's div by id function makeLocationActive(feature) { const id = feature ? feature.properties.id : null; locations.features.forEach((loc) => { const loc_id = loc.properties.id; const elem = document.getElementById("outline-container-" + loc_id); if (!elem) return; if (id == loc_id) { // elem.setAttribute("style", "display: block;"); elem.style.display = "block"; } else { // elem.setAttribute("style", "display: none;"); elem.style.removeProperty("display"); } }); } document.addEventListener("DOMContentLoaded", async () => { fetch("/res/pmtiles-light.json") .then(async (r) => { return r.json() }) .then((s) => { map = new maplibregl.Map({ container: 'map-container', style: s, center: starting_location, zoom: starting_zoom, }); }) .then(async () => { configureMap(map, locations, makeLocationActive, () => { makeLocationActive(null); }); }) .catch((e) => { console.log(e); }); });
CSS
/* -*- mode: css; -*- */ .map-container { position: relative; margin-top: 10px; margin-bottom: 0px; border: solid; border-width: 1px; border-radius: 5px; padding: 0; aspect-ratio: 1.95; } .map-popup { display: flex; flex-direction: column; text-align: center; } .map-popup-logo { max-height: 55px; } .map-marker { cursor: pointer; } @media (max-width: 760px) { .map-container { aspect-ratio: 5 / 4; } } /* extra style for the location info boxes */ .location-info { /* by default don't show */ display: none; border: solid; border-width: 1px; border-radius: 5px; margin-top: 15px; margin-bottom: 15px; /* honestly unclear why 12, but it's right */ width: calc(100% - 12px); padding: 3px 5px 3px 5px; } .location-info h2 { text-align: center; margin: 5px 0 10px 0; }