Peter Hoffmann

Stable URLs for 42,000 Huts and Peaks

How every hut, bivouac and peak on biwak.info gets a short, readable URL like /p/matterhorn that never changes: unique names keep the plain name, shared names get the region, then the elevation, then the OpenStreetMap id, and a committed registry makes sure a slug never moves to another place.

biwak.info is my map of Alpine huts, bivouacs and peaks. For a long time the only way to link to a hut was a #Name hash that did an exact name match and fell back to the search when the name wasn't unique. I wanted real URLs I can share and that show a proper preview card in a chat: biwak.info/p/matterhorn.

The data comes from OpenStreetMap: 9,429 huts and bivouacs and 33,437 named peaks, 42,866 places in total. Turning a name into a URL is easy. The hard part is that many places share a name, and that a URL, once shared, should keep pointing to the same place, even when the data changes underneath.

The preview card for https://biwak.info/p/bivouac-des-periades

From a name to a slug

The first step is a plain slugify: German umlauts are written out, everything else is folded to ASCII, lowercased, and every run of other characters becomes a single dash.

GERMAN_TRANSLIT = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue",
                                 "ß": "ss", "ẞ": "ss"})


def slugify(text):
    """German transliteration for umlauts and ß, then ASCII-fold, lowercase, everything else to single dashes."""
    text = (text or "").translate(GERMAN_TRANSLIT)
    text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower()
    return re.sub(r"[^a-z0-9]+", "-", text).strip("-")

So Oberaletschhütte becomes oberaletschhuette, Štuhecov dom becomes stuhecov-dom and Rifugio Città di Busto becomes rifugio-citta-di-busto. None of the 42,866 names ends up empty.

Names are not unique

Alpine names repeat a lot. Every valley has its Jagdhütte and its Rosskopf. With the name alone, the most common slugs are:

Slug Places
jagdhuette 70
schwarzkogel 37
buchberg 32
rosskopf 32
mitterberg 31

Before choosing a scheme, I measured how many places would still share a slug with different suffixes:

Slug made of Shared slugs Places affected Worst
name 2,181 6,690 (15.6 %) 70
name + country 1,993 5,936 (13.8 %) 64
name + region 1,516 3,932 (9.2 %) 27
name + region + elevation 118 394 (0.9 %) 21

Adding the country barely helps: most duplicates are in the same country. The region roughly halves them, but there are still 27 places called Jagdhütte in Carinthia alone. Elevation fixes most of the rest. It can't fix everything, because 4,311 of the huts have no elevation in OpenStreetMap. The worst case in the last row is casella in Liguria: 21 small huts with the same name and no elevation.

So there is no single suffix that always works, and adding all of them to every URL would make /p/matterhorn look like /p/matterhorn-wallis-4478.

Add a suffix only while the name is shared

The rule I settled on: a place gets the shortest slug that is unique, trying these in order:

  1. the plain name: /p/matterhorn
  2. name and region: /p/oberaletschhuette stays plain, but a shared name becomes something like /p/rosskopf-tirol
  3. name, region and elevation: /p/rosskopf-tirol-2257
  4. name, region and OpenStreetMap id: /p/jagdhuette-kaernten-w1202594655

In code this is a generator that yields the candidates for one place, given all current places with the same name slug:

def candidates(p, group):
    """Slugs for POI p in order of preference; `group` = all current POIs with the same name slug."""
    base, region = p["base"], slugify(p["region"])
    others = [o for o in group if o["id"] != p["id"]]
    if not others:
        yield base
    if not any(slugify(o["region"]) == region for o in others):
        yield f"{base}-{region}"
    if p["ele"] is not None and not any(slugify(o["region"]) == region and o["ele"] == p["ele"] for o in others):
        yield f"{base}-{region}-{p['ele']}"
    yield f"{base}-{region}-{p['id']}"

The OpenStreetMap id is the last resort because it's always unique, but nobody can read it. On today's data the 42,866 places split like this:

Slug Places
plain name 36,176
+ region 2,758
+ elevation 3,474
+ OpenStreetMap id 458

Regions from Natural Earth

The region has to be something people recognise. I cut the Natural Earth admin-1 boundaries down to the units that make sense in the Alps: the Bundesland in Austria and Germany, the Kanton in Switzerland, the département in France (Savoie and Haute-Savoie are better known than the big régions), the regione in Italy with South Tyrol and Trentino split, and the statistical regions in Slovenia. Names use the German form where that is common (Wallis, Tessin, Aostatal), otherwise the local one. The places on the map fall into 63 of these regions.

Each place gets its region by a point-in-polygon lookup with Shapely. The boundaries are simplified, so a hut right on a border or a lake shore can fall outside every polygon. Then the nearest polygon wins:

def lookup(self, lat, lon):
    """(cc, region name); points just outside every polygon (coast, simplified border) take the nearest."""
    pt = Point(lon, lat)
    for i in self.tree.query(pt):
        if self.geoms[i].contains(pt):
            break
    else:
        i = self.tree.nearest(pt)
    return self.props[i]["cc"], self.props[i]["name"]

The region is also shown on the preview card and in the detail view, next to the type: "Biwak · Haute-Savoie, Frankreich".

Stable: a registry that never forgets

The scheme above depends on the data. If someone adds a second Matterhorn to OpenStreetMap tomorrow, the plain rule would rename the real one to matterhorn-wallis, and every link shared so far would break.

So the build keeps a slug registry, data/slug-registry.json, which is committed to git. It maps every slug ever handed out to the OpenStreetMap id and the name:

"matterhorn": ["n26863664", "Matterhorn"],
"oberaletschhuette": ["n11262553552", "Oberaletschhütte"],

The assignment then follows three rules:

primary = {}
for p in pois:
    fitting = [s for s in by_id.get(p["id"], []) if s == p["base"] or s.startswith(p["base"] + "-")]
    if fitting:
        primary[p["id"]] = min(fitting, key=lambda s: (len(s), s))

for base in sorted(groups):
    for p in groups[base]:
        if p["id"] in primary:
            continue
        slug = next(s for s in candidates(p, groups[base]) if s not in registry)
        registry[slug] = [p["id"], p["name"]]
        primary[p["id"]] = slug

A place that disappears from OpenStreetMap keeps its slug reserved. Opening it starts a search for the old name instead of showing someone else's hut.

Because the registry is a sorted JSON file with one slug per line, a build that changes slugs shows up as a readable git diff. That made one mistake easy to spot: the first version folded ü to u, so the Oberaletschhütte was /p/oberaletschhutte. Two weeks after launch I switched to ue, which changed 7,182 slugs. I rebuilt the registry from scratch, so the old slugs are gone instead of redirecting.

Two fallbacks

A plain name that is shared, like /p/rosskopf, doesn't return an error. It opens the search with exactly the places of that name, with their type, elevation and region, and each of them links to its own slug. The same works one level down for /p/rosskopf-tirol.

A slug that ends in an OpenStreetMap id always resolves, even if the name part is wrong or outdated. /p/old-name-n11262553552 answers with a 301 to /p/oberaletschhuette.

Limits

The same /p/ URLs also let the map open peaks directly, as described in Loading 33,000 Peaks on Demand with a z10 Tile Grid.