Part of the jedee wiki — pages written by an AI (Claude Code), supervised by Johan.
The place map
An interactive web map is a JavaScript widget: a library like Leaflet or MapLibre draws tiled imagery and vector shapes onto a scrollable canvas. That means it does nothing without JavaScript, and its keyboard and screen-reader story is often poor. The durable way to ship one is progressive enhancement — server-render the underlying data as ordinary HTML that stands on its own (a list of places with a link each, or a static map image), then let JavaScript find that markup and grow the live map above it. The HTML is the answer for no-JS visitors and assistive tech; the map is a convenience layered on top.
A recurring trap is coordinate order. GeoJSON — the standard shape for map data — writes each position as [longitude, latitude], x before y. MapLibre follows GeoJSON; Leaflet, and the way people say “lat/long” out loud, put latitude first. A track handed to the wrong one without a swap lands in the wrong hemisphere.
Map tiles come in two kinds. Raster tiles are finished pictures (PNGs) someone else rendered and styled. Vector tiles carry the geometry and names only, and the browser paints them with a style you write — every color, line width and label is yours. Protomaps packages OpenStreetMap as vector tiles in a single file (PMTiles) that any static host can serve, because the browser fetches just the byte ranges it needs. A recorded path is a GeoJSON LineString: one geometry.coordinates array of [lon, lat] points.
In jedee
<place-map> is a custom element, entirely jedee’s own (not Eleventy Excellent stock). It moved from Leaflet to MapLibre on 2026-09-17 so it could draw jedee’s own vector tiles in the site’s colors (see Our own tiles below). MapLibre is bundled straight into the component file by esbuild (about 270 KB compressed, against Leaflet’s 41 KB) and the whole thing is deferred behind is-land, so nothing loads until the browser is idle — the same pattern as The PhotoSwipe lightbox. The inline map drags and zooms with its buttons but never wheel- or pinch-zooms (that would trap the page scroll); a maximize button grows the same map instance into a modal overlay where wheel and pinch turn on, so pan/zoom state is preserved rather than rebuilt.
One element, three modes, chosen in connectedCallback purely by what markup is slotted inside it:
const routeScript = this.querySelector('script[type="application/json"][data-route]');
const placeList = this.querySelector('[data-place-list]');
if (routeScript) this.initRoute(routeScript);
else if (placeList) this.initPlaces(placeList);
else this.initSinglePin();Single pin (photo pages) —
data-lat/data-lonon the element, with a static<a><img>Geoapify map image as the slotted fallback.Places (the activity index) — a slotted
[data-place-list]: since 2026-09-15 the activities table, one<tr data-lat data-lon>per located activity, each dot named and linked from the row’s first link (Tables). The table is the data source and the no-JS / screen-reader path: map markers have poor keyboard and SR handling (Leaflet’s were broken upstream; MapLibre’s dots are painted on a canvas and not focusable at all), so nobody is forced through the map to reach a post. JavaScript reads the rows that carry coordinates and drops one dot each into the box above; rows without coordinates stay in the table, unmapped. Each row’sdata-activitysets a--place-colorinplace-map.css, read once per row for its dot: orienteering orange, hikesgreen-vivid, runsblue-vivid.This mode used to group the list by activity type, with the group headings upgraded into filter toggles and mirrored as chips on the map surface. All of that came out on 2026-08-15, when the page became a single chronological index: the grouping was the only thing standing between the reader and a plain newest-first list of everything. Two lessons stayed behind. First, reserve the map’s space server-side — the box was built and prepended on idle, so the whole page dropped by 16:9-of-the-column a second after paint; rendering an empty
.place-map-livein the markup and havingbuildBox()adopt it takes the shift to zero. Second, beware the double reverse: the collection was already newest-first frombyCategory, and thelocatedfilter reversed it again, so the visible list ran oldest-first for months without anyone noticing.Route (activity pages) — a slotted
<script type="application/json" data-route>holding a GeoJSONLineString. This is the newest mode and the rest of this page is about it.

A recorded route as the third mode
The track for an activity is committed as a sibling file next to the post: <Post Title>.geojson beside <Post Title>.md. It’s extracted from the Strava export by a one-off local script (_local/generated/extract-route.py), which handles both source formats the export ships:
- FIT files store positions as semicircles — a signed integer where a full circle is 2³² — so each coordinate is multiplied by
180 / 2³¹to get degrees. - GPX files are already longitude/latitude in degrees, read with a different parser.
Either way the script downsamples the thousands of recorded points down to a light path, but keeps the exact first and last points untouched so the start and finish markers sit where the activity really began and ended. It writes a LineString with [lon, lat] coordinates, GeoJSON order.
At build time the file is inlined into the page. A filter reads the sibling by swapping the post’s extension:
// src/_config/filters/route-geojson.js
export function routeGeoJSON(inputPath) {
const p = inputPath.replace(/\.md$/, '.geojson');
return fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : '';
}It takes page.inputPath, not page.fileSlug. Eleventy strips a leading date from fileSlug, so a date-prefixed filename (2026-08-02 Gotland dag 2.md) would look for 2026-08-02 Gotland dag 2.geojson under the slug gotland-dag-2 and miss. inputPath is the real path on disk. activity.njk slots the result only when it exists, so an activity with no recorded track renders no map at all:
{% set routeJson = page.inputPath | routeGeoJSON %}
{% if routeJson %}
<place-map @place="this route">
<script type="application/json" data-route>{{ routeJson | safe }}</script>
</place-map>
{% endif %}Drawing it, the GeoJSON goes straight into a MapLibre geojson source — no coordinate swap, since MapLibre reads [lon, lat] natively. The line’s color is read from --color-route-line, so it follows the theme.
The start and finish are the standard orienteering course symbols — a triangle at the start pointing down the first leg, two concentric circles at the finish. They’re built in screen pixels at the fit zoom and then unprojected to lat/lon, so they become geographic shapes in their own line layers: they grow and shrink with the map like the track does, with no per-zoom JavaScript. (The Leaflet version got the same result from an L.polygon and two L.circles; an even earlier one used fixed-size HTML markers that needed a scale() on every zoom.) The circles are 48-point rings, since a MapLibre line layer has no circle shape.
The start triangle’s apex is pinned exactly on the first point and the body trails back along the reverse of the travel direction. The heading is measured to the first track point at least ~25 m out, not the next GPS fix — the opening fixes cluster on the spot and a two-point bearing there is pure noise, so the triangle would point a random way. The shapes are hollow and share the line’s color, the site’s accent orange. Orienteering overprint is purple, and a purple was tried — but the line has to read over several different tile sets, and it vanished on some of them; the accent orange reads on all of them. There’s deliberately no halo. None are interactive, since the map isn’t the screen-reader path here.

On first paint a short intro sequences the pieces: while the tiles load the empty box pulses softly, and once MapLibre first reports idle the canvas fades up ([data-map-loading] in place-map.css), then the start triangle fades in, the line draws itself start-to-finish, and the finish is revealed only once the line reaches it — all gated behind prefers-reduced-motion, so reduced-motion visitors just get the finished map. A canvas map has no SVG path to animate with stroke-dashoffset, so the draw is a line-gradient (which needs lineMetrics: true on the source): a step at the drawn fraction of the line, solid before it and transparent after, moved forward every animation frame. A theme flip mid-draw rebuilds the style, and setPaintProperty throws “Style is not done loading” until it’s back — the loop skips those frames, and keeps the progress in component state so the rebuilt style carries it.
The no-JS answer for a route is the honest one: no map, but the stats and the “View on Strava” link below carry the route. A blank or broken map is never shown — malformed JSON or too few points also just leaves the page mapless with the Strava link standing. Route lines live on the activities archive pages; that page’s overview map is the groups mode of this same component.
Widening the map: the breakout must go on the <is-land>
The route map sits one step wider than the prose column — the .popout breakout — so the track has room to read. Getting the breakout class onto the right element is the trap. <place-map> is a WebC component whose template is <is-land on:idle><place-map webc:root webc:keep>…. Because webc:root is on the inner <place-map>, any attribute on the invocation (class="popout") merges onto that inner element — which is a grid grandchild (the <is-land> wraps it), and breakout classes only work on a direct grid child. So a class on the invocation silently does nothing; the map stays at content width.
The fix routes the class onto the <is-land> itself via a prop: the component takes :class="breakout || ''" on its <is-land>, and activity.njk passes @breakout="popout". Two details that bite: the || '' guard is load-bearing — a bare :class="breakout" throws Cannot read properties of undefined (reading 'toString') at build time for every caller that omits the prop (the places index, the single-pin photo maps), and || false renders a literal class="false" because WebC stringifies a falsy :class rather than dropping it; only || '' cleanly omits the attribute. The breakout also collapses back to content width on narrow screens automatically — that is the .wrapper grid working as designed, not a bug.
A <script> is only hidden by a default, and a default is easy to beat
The route JSON is slotted into the component as an ordinary <script type="application/json" data-route>. Nothing hides it but the browser’s own UA stylesheet, which carries script { display: none } — a real rule in the lowest-priority sheet there is, not a property of the element. Any author rule that reaches the element and sets display to something else puts the script’s text on the page.
That is what happened here. The component’s stylesheet gives whatever follows the map some air:
place-map > :is(.place-map-live, .place-map-static) + * {
display: block;
margin-block-start: var(--space-l);
}The display: block is deliberate: on /activities/ the element after the map is <sortable-table>, an unregistered custom element, so it is display: inline by default and would ignore margin-block-start entirely. On an activity page the element after the map is the route script instead — same selector, same rule — and about 4 kB of coordinates printed under the map in the body text. Narrowed to + :not(script).
Two things worth keeping. The first is that display: block on a + * selector is a wider hazard than it looks: the sibling combinator picks the next element, and <script>, <template>, <style> and <link> are all elements that happen to be hidden only by a default. The second is a small relief — :not(script) takes the specificity of its argument, a type selector, so the fix raised the selector from (0,1,1) to (0,1,2) rather than lowering it, and nothing that had been winning against it started losing.
Our own tiles
Since 2026-09-17 every map opens on jedee’s own basemap: a Protomaps extract of Sweden (sweden-20260916.pmtiles, 4.4 GB, full detail to zoom 15), cut from the free daily world build with pmtiles extract … --bbox=10.5,55.0,24.5,69.2 --maxzoom=15 and hosted on the site’s R2 bucket. It replaced plain OpenStreetMap tiles, whose dark mode was a CSS invert() filter — CARTO’s ready-made dark basemap started demanding an API key in August 2026. Everything the map needs besides the tiles is self-hosted in src/assets/map/: the label fonts (only the Latin, Greek and Cyrillic glyph ranges — MapLibre asks for a range per character block and draws a missing one locally, with a console warning) and the icon sprites.
The colors are CSS custom properties, --map-land, --map-water, --map-park, --map-buildings, --map-road, --map-road-minor, --map-label* and --map-dot-stroke, set in place-map.css from the site’s tokens with dark-mode overrides. On every render the component reads them and passes them to Protomaps’ theme code as a custom “flavor”, so tweaking the map is a CSS edit, and a theme flip just re-renders. Two traps:
- MapLibre can’t parse
color-mix()oroklab(), and a custom property’s computed value is the unresolved text. The component resolves each one by painting it into a 1×1 canvas and reading the pixel back asrgba(). - The properties sit on the map canvas, not on
<place-map>, because the maximize button moves the canvas into an overlay outside the element, where it would stop inheriting them.
The tile file is empty outside Sweden. A single pin, place list or route that reaches past the box opens on Topographic instead (COVERAGE in place-map.js).
The whole style (base layers plus the dots, route and symbols) is rebuilt by one render() and applied with setStyle(style, { diff: true }), which changes only what differs. The very first render must skip diff: diffing against the empty placeholder style before it has loaded logs a warning and rebuilds anyway.
⚠️ MapLibre loads a style on an animation frame, and browsers pause those in a hidden tab. A map on a background tab (or in Claude’s Browser pane while it’s hidden) stays blank with isStyleLoaded() false until the tab is shown — not a bug.
Waiting for the first tiles
Tiles arrive over the network, so between the page’s first paint and a drawn map there is a gap of a second or more. Three parts cover it, and none of them is a spinner:
- The box is filled with
--map-water, the map’s own sea color, and pulses between that and a paler version of it while the canvas is hidden. A loading state that is already one of the map’s colors reads as the map arriving rather than as a placeholder. - The canvas carries
[data-map-loading](set when the map is built, removed on MapLibre’s firstidleevent) and fades in over 500 ms when it goes.idleis the honest signal: it fires when nothing is left to fetch or draw, so the fade starts on a finished picture rather than on a half-drawn one. - A route’s intro waits for the same event, so the line is never drawn across an empty box.
A caption meant for no-JS visitors will flash. The activity index renders its map box server-side to reserve the space (Layout shift), and the caption inside it — Map of my activities — showed for as long as the island took to hydrate. It is now hidden under @media (scripting: enabled), the mirror of the scripting: none rule in The main menu: the text is only for the visitor who will never get a map, and visibility: hidden keeps the box’s height either way. Pre-hydration text is worth a second look in general — it is written for a case that most visitors pass through rather than land in.
Reduced motion removes both the pulse and the fade, and the map simply appears.
One declaration, two themes
The map’s colors are CSS custom properties — --map-land, --map-water, --map-park and seven more — set on .place-map-live and on the canvas, and read back by place-map.js on every render, so a theme flip or a devtools tweak redraws the map without touching JavaScript. They are declared in three blocks: a light one, a [data-theme='dark'] one, and a prefers-color-scheme: dark copy of the second. The dark blocks override only the properties that actually differ.
Every property the dark blocks leave out is served by the light block’s single declaration, in both themes. Raising the light park tint from 8% to 12% also raised the dark one, because dark had never declared --map-park; holding dark at 8% took a new declaration in each dark block. Before changing a themed custom property, check whether the other theme overrides it — one that appears once is shared, and the diff reads the same either way. The mirror of Undefined custom properties, where the reference is the thing that is missing rather than the override.
A base-layer style switch
A control in the bottom-right corner switches between Map (jedee’s tiles) and two fixed raster styles: Satellite (Esri World Imagery) and Topographic (OpenTopoMap, contours and trails). Every mode opens on Map, routes included; before the move, routes opened on Topographic. One tile-source gotcha: Esri’s URL template is {z}/{y}/{x} — row before column, the reverse of the usual order.
The control is a native <select>: a base-layer choice is single-select, and <select> is the accessible native control for that (keyboard and screen reader for free, its option list drawn by the OS). It’s a MapLibre control, so it’s a child of the map canvas and rides into the maximize overlay with no extra code. Switching just sets the base and re-renders. MapLibre’s attribution control credits only the sources that currently have visible layers, so the credit follows the switch on its own (the Leaflet version needed a careful add order to get attribution removal right). In a bottom corner the last-added control sits nearest the top, so the attribution is added first and the switch second.
The inline map keeps the old gesture rules: no wheel or pinch zoom (that would trap the page scroll) except with Ctrl/⌘ held, which is also how a trackpad pinch arrives; rotation and tilt are off everywhere.
The move to MapLibre and jedee’s own tiles: the session of 2026-09-17, verified against place-map.js and place-map.css. Source: _local/design/Plan - GPX route line on the activity map.md (2026-08-11), verified against place-map.js and route-geojson.js. The base-layer switch: _raw/dev-notes/How the place map switches tile styles.md (2026-08-11), verified against place-map.js and place-map.css. The start/finish symbols were reworked from divIcon HTML markers into native vector shapes on 2026-08-16 (commit 30aade8), re-verified against place-map.js and place-map.css. The colors and their theme blocks: the session of 2026-09-18, src/_raw/dev-notes/How the map colors came back from Penpot.md. The route script’s display trap: the session of 2026-09-19, src/_raw/dev-notes/How the popout breakout was pulled back to prose width.md.