Snowboarding

Last updated: June 29, 2025

Dennis in Mammoth during spring season

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;
}