<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Gpx on blog.willprogramforfood.com</title><link>https://blog.willprogramforfood.com/tags/gpx/</link><description>Recent content in Gpx on blog.willprogramforfood.com</description><generator>Hugo -- gohugo.io</generator><language>en</language><managingEditor>hipposareevil@protonmail.com (Sam Jackson)</managingEditor><webMaster>hipposareevil@protonmail.com (Sam Jackson)</webMaster><copyright>© 2026 Sam Jackson</copyright><lastBuildDate>Tue, 30 Jun 2026 20:06:55 +1200</lastBuildDate><atom:link href="https://blog.willprogramforfood.com/tags/gpx/index.xml" rel="self" type="application/rss+xml"/><item><title>Mole Hut</title><link>https://blog.willprogramforfood.com/posts/2026/mole-hut/</link><pubDate>Tue, 30 Jun 2026 20:06:55 +1200</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/mole-hut/</guid><description>&lt;p&gt;Overnight trip up to &lt;a href="https://www.doc.govt.nz/parks-and-recreation/places-to-go/nelson-tasman/places/nelson-lakes-national-park/things-to-do/huts/mole-hut/" target="_blank" rel="noreferrer"&gt;Mole Hut&lt;/a&gt; for the night. I was going to stay on Mole tops but it was super super boggy and slow going, so bailed to the hut. Hut was small and cozy with no one else around. Someone left a candle so I had nice lighting.&lt;/p&gt;














&lt;div id="gpxmap-mole-hut-shell" style="margin: 1.5rem 0;"&gt;

 
 &lt;div id="gpxmap-mole-hut-mini" style="
 position: relative; cursor: pointer; border-radius: 8px; overflow: hidden;
 height: 180px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 " onclick="gpxmaps['gpxmap-mole-hut'].expand()"&gt;
 &lt;div id="gpxmap-mole-hut-minimap" style="width:100%; height:100%;"&gt;&lt;/div&gt;
 
 &lt;div style="
 position:absolute; bottom:0; left:0; right:0;
 background: linear-gradient(transparent, rgba(0,0,0,0.55));
 padding: 20px 12px 10px;
 display:flex; justify-content:space-between; align-items:flex-end;
 pointer-events:none;
 "&gt;
 &lt;span id="gpxmap-mole-hut-mini-stats" style="color:#fff; font-size:0.78rem; font-family:monospace; opacity:0.9;"&gt;&lt;/span&gt;
 &lt;span style="color:#fff; font-size:0.78rem; opacity:0.8;"&gt;tap to explore ↗&lt;/span&gt;
 &lt;/div&gt;
 &lt;/div&gt;

 
 &lt;div id="gpxmap-mole-hut-full" style="display:none;"&gt;

&lt;div class="gpxmap-wrap" style="position:relative;"&gt;
 &lt;div id="gpxmap-mole-hut" style="width:100%; height:500px; border-radius:8px; overflow:hidden;"&gt;&lt;/div&gt;

 
 &lt;div id="gpxmap-mole-hut-stats" style="
 display:flex; gap:1.5rem; flex-wrap:wrap; align-items:center;
 padding: 0.6rem 1rem;
 background: var(--bg-color, #1a1a1a);
 color: var(--text-color, #e0e0e0);
 font-size: 0.82rem;
 border-radius: 0 0 8px 8px;
 font-family: monospace;
 "&gt;
 &lt;span id="gpxmap-mole-hut-stats-text"&gt;⟳ loading…&lt;/span&gt;
 &lt;button id="gpxmap-mole-hut-unitbtn" onclick="gpxmaps['gpxmap-mole-hut'].toggleUnits()"
 style="margin-left:auto; padding:2px 8px; font-size:0.75rem; border-radius:4px;
 border:1px solid rgba(255,255,255,0.3); cursor:pointer;
 background:transparent; color:inherit; font-family:monospace;"&gt;
 km / mi
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; right:10px; z-index:10; display:flex; flex-direction:column; gap:6px; align-items:flex-end;"&gt;
 &lt;div style="display:flex; gap:6px; align-items:center;"&gt;
 &lt;select id="gpxmap-mole-hut-speed" title="Flythrough speed"
 style="padding:3px 5px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 &lt;option value="0.5"&gt;0.5×&lt;/option&gt;
 &lt;option value="1" selected&gt;1×&lt;/option&gt;
 &lt;option value="2"&gt;2×&lt;/option&gt;
 &lt;option value="4"&gt;4×&lt;/option&gt;
 &lt;/select&gt;
 &lt;button id="gpxmap-mole-hut-playbtn" onclick="gpxmaps['gpxmap-mole-hut'].togglePlay()"
 style="padding:4px 10px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 ▶ Fly
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mole-hut'].setStyle('outdoor')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Topo
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mole-hut'].setStyle('satellite')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Satellite
 &lt;/button&gt;
 &lt;button id="gpxmap-mole-hut-3dbtn" onclick="gpxmaps['gpxmap-mole-hut'].toggleTerrain()"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 3D ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;button id="gpxmap-mole-hut-sizebtn" onclick="gpxmaps['gpxmap-mole-hut'].cycleSize()"
 style="padding:2px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:rgba(255,255,255,0.9); opacity:0.9; font-family:monospace;"&gt;
 ↙ SM
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; left:10px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:4px;"&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-mole-hut'].rotateby(-20)" title="Rotate left"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↺
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mole-hut'].resetNorth()" title="Reset north"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:bold;line-height:1;"&gt;
 N
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mole-hut'].rotateby(20)" title="Rotate right"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↻
 &lt;/button&gt;
 &lt;/div&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-mole-hut'].tiltby(-15)" title="Tilt flat"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 🔭
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mole-hut'].tiltby(15)" title="Tilt up"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;/div&gt;


&lt;canvas id="gpxmap-mole-hut-elev" height="80"
 style="width:100%; margin-top:-8px; border-radius:0 0 8px 8px; background:var(--bg-color, #111);"&gt;&lt;/canvas&gt;


&lt;div style="text-align:center; margin: 0.5rem 0 1.5rem; opacity:0.35; line-height:1;"&gt;
 &lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40" width="100%" height="40" preserveAspectRatio="none"&gt;
 &lt;path d="M0 40 L0 28 L30 12 L55 24 L90 4 L120 20 L150 8 L175 22 L200 2 L225 18 L255 6 L285 22 L310 10 L340 26 L370 8 L400 20 L400 40 Z"
 fill="currentColor"/&gt;
 &lt;/svg&gt;
&lt;/div&gt;

 &lt;/div&gt;
&lt;/div&gt;



 
 &lt;link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"&gt;
 &lt;script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"&gt;&lt;/script&gt;
 &lt;script&gt;
 window.gpxmaps = {};
 &lt;/script&gt;


&lt;script&gt;
(function() {
 const MAP_ID = "gpxmap-mole-hut";
 const MAPTILER = "DSdF85giIH5Jkcc46q7r ";
 const BASE_STYLE = "outdoor-v2";
 const EXAGGERATION = 1.5 ;
 const GPX_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/mole-hut\/track.gpx";
 const PHOTOS_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/mole-hut\/photos.json";

 
 function parseGPX(xmlText) {
 const parser = new DOMParser();
 const doc = parser.parseFromString(xmlText, "application/xml");
 const ns = "http://www.topografix.com/GPX/1/1";

 const pts = Array.from(doc.getElementsByTagNameNS(ns, "trkpt")).map(pt =&gt; ({
 lon: parseFloat(pt.getAttribute("lon")),
 lat: parseFloat(pt.getAttribute("lat")),
 ele: parseFloat(pt.getElementsByTagNameNS(ns, "ele")[0]?.textContent ?? "0"),
 }));

 if (!pts.length) throw new Error("No trackpoints found in GPX");

 
 let dist = 0, eleGain = 0, eleLoss = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 dist += Math.sqrt(dx*dx + dy*dy);
 const dz = b.ele - a.ele;
 if (dz &gt; 0) eleGain += dz; else eleLoss += -dz;
 }

 const eles = pts.map(p =&gt; p.ele);
 const coord = pts.map(p =&gt; [p.lon, p.lat]);
 const center = coord[Math.floor(coord.length / 2)];

 
 let cumDist = 0;
 pts[0]._distFromStart = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 cumDist += Math.sqrt(dx*dx + dy*dy);
 pts[i]._distFromStart = cumDist;
 }

 return { pts, coord, center, dist, eleGain, eleLoss,
 eleMin: Math.min(...eles), eleMax: Math.max(...eles), eles };
 }

 function drawProfile(canvasId, track, onScrub) {
 const { eles, eleMin, eleMax, pts } = track;
 const canvas = document.getElementById(canvasId);
 if (!canvas) return;
 const ctx = canvas.getContext("2d");
 const DPR = window.devicePixelRatio || 1;
 const pad = 4;
 const range = eleMax - eleMin || 1;

 let currentAnimIdx = null; 

 function resize() {
 canvas.width = canvas.offsetWidth * DPR;
 canvas.height = canvas.offsetHeight * DPR;
 }
 resize();

 function yForEle(e) {
 const H = canvas.height;
 return pad + (1 - (e - eleMin) / range) * (H - pad * 2);
 }

 function draw(scrubX) {
 const W = canvas.width, H = canvas.height;
 ctx.clearRect(0, 0, W, H);

 
 if (currentAnimIdx !== null &amp;&amp; currentAnimIdx &gt; 0) {
 const progX = (currentAnimIdx / (eles.length - 1)) * W;

 
 const progGrad = ctx.createLinearGradient(0, 0, 0, H);
 progGrad.addColorStop(0, "rgba(224,92,42,0.55)");
 progGrad.addColorStop(1, "rgba(224,92,42,0.05)");
 ctx.fillStyle = progGrad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.lineTo(progX, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(224,92,42,0.9)";
 ctx.lineWidth = 2.5 * DPR;
 ctx.beginPath();
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(eles[i])) : ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.stroke();

 
 ctx.strokeStyle = "rgba(224,92,42,0.8)";
 ctx.lineWidth = 1.5 * DPR;
 ctx.setLineDash([3 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(progX, 0);
 ctx.lineTo(progX, H);
 ctx.stroke();
 ctx.setLineDash([]);
 }

 
 const grad = ctx.createLinearGradient(0, 0, 0, H);
 grad.addColorStop(0, "rgba(99,179,130,0.4)");
 grad.addColorStop(1, "rgba(99,179,130,0.02)");
 ctx.fillStyle = grad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(e));
 });
 ctx.lineTo(W, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(99,179,130,0.7)";
 ctx.lineWidth = 2 * DPR;
 ctx.beginPath();
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(e)) : ctx.lineTo(x, yForEle(e));
 });
 ctx.stroke();

 if (scrubX == null) return;

 
 ctx.strokeStyle = "rgba(255,255,255,0.7)";
 ctx.lineWidth = 1 * DPR;
 ctx.setLineDash([4 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(scrubX, 0);
 ctx.lineTo(scrubX, H);
 ctx.stroke();
 ctx.setLineDash([]);

 
 const idx = Math.round((scrubX / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 const cy = yForEle(eles[safeIdx]);
 const ts = 7 * DPR;
 ctx.fillStyle = "#fff";
 ctx.beginPath();
 ctx.moveTo(scrubX, cy - ts);
 ctx.lineTo(scrubX + ts, cy + ts * 0.6);
 ctx.lineTo(scrubX - ts, cy + ts * 0.6);
 ctx.closePath();
 ctx.fill();

 
 const distKm = (pts[safeIdx]._distFromStart / 1000).toFixed(1);
 const label = `${Math.round(eles[safeIdx])} m · ${distKm} km`;
 const fsize = 11 * DPR;
 ctx.font = `${fsize}px monospace`;
 const tw = ctx.measureText(label).width;
 const tx = Math.min(scrubX + 8 * DPR, W - tw - 4 * DPR);
 const ty = Math.max(fsize + 4 * DPR, cy - 8 * DPR);
 ctx.fillStyle = "rgba(0,0,0,0.6)";
 ctx.fillRect(tx - 3 * DPR, ty - fsize, tw + 6 * DPR, fsize + 4 * DPR);
 ctx.fillStyle = "#fff";
 ctx.fillText(label, tx, ty);
 }

 
 draw(null);

 
 function handleMove(clientX) {
 const rect = canvas.getBoundingClientRect();
 const W = canvas.width;
 const x = Math.max(0, Math.min(W, (clientX - rect.left) * DPR));
 const idx = Math.round((x / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 draw(x);
 onScrub(safeIdx);
 }

 function handleLeave() {
 draw(null);
 onScrub(null);
 }

 canvas.addEventListener("mousemove", e =&gt; handleMove(e.clientX));
 canvas.addEventListener("mouseleave", handleLeave);
 canvas.addEventListener("click", e =&gt; {
 const rect = canvas.getBoundingClientRect();
 const x = Math.max(0, Math.min(canvas.width, (e.clientX - rect.left) * DPR));
 const idx = Math.round((x / canvas.width) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 if (window.gpxmaps &amp;&amp; window.gpxmaps[MAP_ID]) {
 window.gpxmaps[MAP_ID].seekTo(safeIdx);
 }
 });
 canvas.addEventListener("touchmove", e =&gt; { e.preventDefault(); handleMove(e.touches[0].clientX); }, { passive: false });
 canvas.addEventListener("touchend", handleLeave);
 canvas.style.cursor = "crosshair";

 
 return {
 setAnimIdx(idx) {
 currentAnimIdx = idx;
 draw(null);
 },
 clearAnim() {
 currentAnimIdx = null;
 draw(null);
 },
 };
 }

 
 let useMetric = true;

 function updateStats(id, dist, eleGain, eleLoss, eleMin, eleMax) {
 const el = document.getElementById(id + "-stats-text");
 if (!el) return;
 const fmt = n =&gt; Math.round(n).toLocaleString();
 if (useMetric) {
 const km = (dist / 1000).toFixed(1);
 el.innerHTML = `
 &lt;span&gt;📍 ${km} km&lt;/span&gt;
 &lt;span&gt;⬆ ${fmt(eleGain)} m gain&lt;/span&gt;
 &lt;span&gt;⬇ ${fmt(eleLoss)} m loss&lt;/span&gt;
 &lt;span&gt;🏔 ${fmt(eleMax)} m high · ${fmt(eleMin)} m low&lt;/span&gt;
 `;
 } else {
 const mi = (dist / 1609.34).toFixed(1);
 const toFt = m =&gt; Math.round(m * 3.28084).toLocaleString();
 el.innerHTML = `
 &lt;span&gt;📍 ${mi} mi&lt;/span&gt;
 &lt;span&gt;⬆ ${toFt(eleGain)} ft gain&lt;/span&gt;
 &lt;span&gt;⬇ ${toFt(eleLoss)} ft loss&lt;/span&gt;
 &lt;span&gt;🏔 ${toFt(eleMax)} ft high · ${toFt(eleMin)} ft low&lt;/span&gt;
 `;
 }
 }

 
 function styleUrl(name) {
 const key = MAPTILER;
 const map = {
 outdoor: `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`,
 satellite: `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
 topo: `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
 };
 return map[name] || map.outdoor;
 }

 function terrainSource(key) {
 return {
 type: "raster-dem",
 url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${key}`,
 tileSize: 256,
 };
 }

 
 async function init() {
 
 const [gpxRes, photosRes] = await Promise.all([
 fetch(GPX_URL),
 fetch(PHOTOS_URL).catch(() =&gt; null),
 ]);

 if (!gpxRes.ok) {
 document.getElementById(MAP_ID + "-stats-text").textContent = "GPX file not found.";
 return;
 }

 const gpxText = await gpxRes.text();
 const track = parseGPX(gpxText);

 let photos = [];
 if (photosRes &amp;&amp; photosRes.ok) {
 photos = await photosRes.json();
 }

 
 const trackStats = { dist: track.dist, eleGain: track.eleGain, eleLoss: track.eleLoss,
 eleMin: track.eleMin, eleMax: track.eleMax };

 updateStats(MAP_ID, track.dist, track.eleGain, track.eleLoss, track.eleMin, track.eleMax);

 
 const miniStats = document.getElementById(MAP_ID + "-mini-stats");
 if (miniStats) {
 const km = (track.dist / 1000).toFixed(1);
 miniStats.textContent = `📍 ${km} km · ⬆ ${Math.round(track.eleGain)} m`;
 }

 
 const lons = track.coord.map(c =&gt; c[0]);
 const lats = track.coord.map(c =&gt; c[1]);
 const bbox = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];

 
 const miniMap = new maplibregl.Map({
 container: MAP_ID + "-minimap",
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 20, maxZoom: 13 },
 interactive: false,
 attributionControl: false,
 });

 miniMap.on("load", () =&gt; {
 miniMap.addSource("mini-track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 miniMap.addLayer({
 id: "mini-track-line", type: "line", source: "mini-track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 2.5, "line-opacity": 0.9 },
 });
 });

 
 let expanded = false;
 let fullMapInited = false;

 function expandMap() {
 if (expanded) return;
 expanded = true;
 document.getElementById(MAP_ID + "-mini").style.display = "none";
 document.getElementById(MAP_ID + "-full").style.display = "block";
 if (!fullMapInited) {
 fullMapInited = true;
 initFullMap();
 } else {
 map.resize();
 }
 }

 function collapseMap() {
 if (!expanded) return;
 expanded = false;
 document.getElementById(MAP_ID + "-full").style.display = "none";
 document.getElementById(MAP_ID + "-mini").style.display = "block";
 miniMap.resize();
 }

 
 let map;

 function initFullMap() {
 map = new maplibregl.Map({
 container: MAP_ID,
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 50, maxZoom: 15 },
 });

 let terrainOn = false;

 function addTerrain() {
 if (!map.getSource("terrain-src")) {
 map.addSource("terrain-src", terrainSource(MAPTILER));
 }
 map.setTerrain({ source: "terrain-src", exaggeration: EXAGGERATION });
 terrainOn = true;
 }

 
 let onScrub = (idx) =&gt; {};
 let segFeatures = []; 
 let profileCtrl = null; 

 map.on("load", () =&gt; {
 
 addTerrain();

 
 const scrubEl = document.createElement("div");
 scrubEl.style.cssText = `
 width: 0; height: 0;
 border-left: 7px solid transparent;
 border-right: 7px solid transparent;
 border-bottom: 13px solid #fff;
 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6));
 display: none;
 `;
 const scrubMarker = new maplibregl.Marker({ element: scrubEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);

 onScrub = function(idx) {
 if (idx === null) {
 scrubEl.style.display = "none";
 } else {
 scrubEl.style.display = "block";
 scrubMarker.setLngLat(track.coord[idx]);
 }
 };

 profileCtrl = drawProfile(MAP_ID + "-elev", track, onScrub);

 
 
 function gradeColor(pct) {
 const g = Math.abs(pct);
 if (g &lt; 8) return "#4caf50"; 
 if (g &lt; 15) return "#ffeb3b"; 
 if (g &lt; 25) return "#ff9800"; 
 return "#f44336"; 
 }

 
 segFeatures = [];
 for (let i = 1; i &lt; track.pts.length; i++) {
 const a = track.pts[i - 1], b = track.pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 const horiz = Math.sqrt(dx * dx + dy * dy);
 const grade = horiz &gt; 0 ? ((b.ele - a.ele) / horiz) * 100 : 0;
 segFeatures.push({
 type: "Feature",
 properties: { color: gradeColor(grade) },
 geometry: { type: "LineString", coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
 });
 }

 map.addSource("track", {
 type: "geojson",
 data: {
 type: "Feature",
 geometry: { type: "LineString", coordinates: track.coord },
 },
 });
 map.addLayer({
 id: "track-line",
 type: "line",
 source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": "#e05c2a",
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 3,
 9, 5,
 12, 4,
 15, 3,
 ],
 "line-opacity": 0.5,
 },
 });

 
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "FeatureCollection", features: [] },
 });
 map.addLayer({
 id: "track-progress-line",
 type: "line",
 source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": ["get", "color"],
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 4,
 9, 7,
 12, 6,
 15, 5,
 ],
 "line-opacity": 1.0,
 },
 });

 
 new maplibregl.Marker({ color: "#2ecc71" })
 .setLngLat(track.coord[0])
 .setPopup(new maplibregl.Popup().setText("Start"))
 .addTo(map);
 new maplibregl.Marker({ color: "#e74c3c" })
 .setLngLat(track.coord[track.coord.length - 1])
 .setPopup(new maplibregl.Popup().setText("End"))
 .addTo(map);

 
 const baseUrl = window.location.href.replace(/\/$/, "");
 photos.forEach(p =&gt; {
 const el = document.createElement("div");
 el.style.cssText = `
 width:28px; height:28px; border-radius:50%;
 background:#fff url('${baseUrl}/gallery/${p.file}') center/cover;
 border:2px solid #fff; box-shadow:0 1px 4px rgba(0,0,0,.4);
 cursor:pointer;
 `;

 const popup = new maplibregl.Popup({ offset: 18 }).setHTML(`
 &lt;a href="${baseUrl}/gallery/${p.file}" target="_blank"&gt;
 &lt;img src="${baseUrl}/gallery/${p.file}"
 style="max-width:200px; max-height:150px; border-radius:4px; display:block;"&gt;
 &lt;/a&gt;
 ${p.alt != null ? `&lt;div style="font-size:0.75rem; text-align:center; margin-top:4px;"&gt;${Math.round(p.alt)} m&lt;/div&gt;` : ""}
 `);

 new maplibregl.Marker({ element: el })
 .setLngLat([p.lon, p.lat])
 .setPopup(popup)
 .addTo(map);
 });

 
 flyEl.style.display = "none";
 flyMarker = new maplibregl.Marker({ element: flyEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);
 });

 
 let animFrame = null;
 let animPlaying = false;
 let lastTime = null;

 let flyMarker = null; 

 
 const flyEl = document.createElement("div");
 flyEl.style.cssText = "display:none; width:32px; height:40px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));";
 flyEl.innerHTML = `&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 40" width="32" height="40"&gt;
 &lt;path d="M16 2 C8 2 2 8 2 16 C2 26 16 38 16 38 C16 38 30 26 30 16 C30 8 24 2 16 2 Z"
 fill="#222" stroke="#fff" stroke-width="1.5"/&gt;
 &lt;circle cx="16" cy="16" r="5" fill="#fff"/&gt;
 &lt;/svg&gt;`;

 
 let animPos = 0; 
 const BASE_PPS = track.coord.length / 60; 

 function interpCoord(pos) {
 const i = Math.floor(pos);
 const t = pos - i;
 const a = track.coord[Math.min(i, track.coord.length - 1)];
 const b = track.coord[Math.min(i + 1, track.coord.length - 1)];
 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
 }

 function updateProgressLine(pos) {
 const idx = Math.min(Math.floor(pos), track.pts.length - 2);
 const tip = interpCoord(pos);
 const src = map.getSource("track-progress");
 if (!src) return;
 
 const features = segFeatures.slice(0, idx);
 
 if (idx &gt;= 0 &amp;&amp; idx &lt; segFeatures.length) {
 const seg = segFeatures[idx];
 features.push({
 ...seg,
 geometry: { type: "LineString", coordinates: [seg.geometry.coordinates[0], tip] },
 });
 }
 src.setData({ type: "FeatureCollection", features });
 }

 function animStep(ts) {
 if (!animPlaying) return;
 if (!lastTime) lastTime = ts;
 const dt = (ts - lastTime) / 1000;
 lastTime = ts;

 const speedEl = document.getElementById(MAP_ID + "-speed");
 const speed = speedEl ? parseFloat(speedEl.value) : 1;
 animPos = Math.min(animPos + BASE_PPS * speed * dt, track.coord.length - 1);

 const pos = interpCoord(animPos);
 if (flyMarker) {
 flyMarker.setLngLat(pos);
 flyEl.style.display = "block";
 }

 updateProgressLine(animPos);
 onScrub(Math.round(animPos));
 if (profileCtrl) profileCtrl.setAnimIdx(Math.round(animPos));

 if (animPos &gt;= track.coord.length - 1) {
 stopFly(true);
 return;
 }

 animFrame = requestAnimationFrame(animStep);
 }

 function stopFly(finished) {
 animPlaying = false;
 lastTime = null;
 cancelAnimationFrame(animFrame);
 if (flyMarker) flyEl.style.display = "none";
 onScrub(null);
 if (profileCtrl) profileCtrl.clearAnim();
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (btn) { btn.textContent = "▶ Fly"; btn.style.background = "#fff"; btn.style.color = ""; }
 if (finished) {
 
 updateProgressLine(track.coord.length - 1);
 }
 }

 
 window.gpxmaps[MAP_ID] = {
 setStyle(name) {
 map.setStyle(styleUrl(name));
 map.once("styledata", () =&gt; {
 addTerrain();
 if (!map.getSource("track")) {
 map.addSource("track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 map.addLayer({
 id: "track-line", type: "line", source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.25 },
 });
 }
 if (!map.getSource("track-progress")) {
 const done = track.coord.slice(0, Math.floor(animPos) + 1);
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: done } },
 });
 map.addLayer({
 id: "track-progress-line", type: "line", source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.9 },
 });
 }
 });
 },
 toggleTerrain() {
 const btn = document.getElementById(MAP_ID + "-3dbtn");
 if (terrainOn) {
 map.setTerrain(null);
 map.easeTo({ pitch: 0, bearing: 0, duration: 600 });
 terrainOn = false;
 if (btn) { btn.style.background = "#fff"; btn.style.color = ""; btn.style.fontWeight = "normal"; }
 } else {
 addTerrain();
 map.easeTo({ pitch: 55, duration: 800 });
 if (btn) { btn.style.background = "#e05c2a"; btn.style.color = "#fff"; btn.style.fontWeight = "bold"; }
 }
 },
 togglePlay() {
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (animPlaying) {
 stopFly(false);
 } else {
 if (!flyMarker) { console.warn("Map not ready yet"); return; }
 
 if (animPos &gt;= track.coord.length - 1) {
 animPos = 0;
 updateProgressLine(0);
 }
 animPlaying = true;
 lastTime = null;
 if (btn) { btn.textContent = "⏸ Pause"; btn.style.background = "#e05c2a"; btn.style.color = "#fff"; }
 animFrame = requestAnimationFrame(animStep);
 }
 },
 seekTo(idx) {
 animPos = idx;
 updateProgressLine(idx);
 onScrub(idx);
 if (profileCtrl) profileCtrl.setAnimIdx(idx);
 if (flyMarker) {
 flyMarker.setLngLat(track.coord[idx]);
 flyEl.style.display = "block";
 }
 },
 toggleUnits() {
 useMetric = !useMetric;
 const btn = document.getElementById(MAP_ID + "-unitbtn");
 if (btn) btn.textContent = useMetric ? "km / mi" : "mi / km";
 updateStats(MAP_ID, trackStats.dist, trackStats.eleGain, trackStats.eleLoss,
 trackStats.eleMin, trackStats.eleMax);
 },
 rotateby(deg) {
 map.easeTo({ bearing: map.getBearing() + deg, duration: 300 });
 },
 tiltby(deg) {
 const next = Math.max(0, Math.min(85, map.getPitch() + deg));
 map.easeTo({ pitch: next, duration: 300 });
 },
 resetNorth() {
 map.easeTo({ bearing: 0, pitch: 0, duration: 500 });
 },
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() {
 const mapEl = document.getElementById(MAP_ID);
 const btn = document.getElementById(MAP_ID + "-sizebtn");
 const sizes = [
 { label: "↙ SM", action: "collapse" },
 { label: "▭ MD", height: "500px" },
 { label: "▬ LG", height: "800px" },
 ];
 
 const fullEl = document.getElementById(MAP_ID + "-full");
 const isCollapsed = fullEl.style.display === "none";
 let current = isCollapsed ? 0
 : mapEl.style.height === "800px" ? 2 : 1;
 const next = (current + 1) % sizes.length;
 const s = sizes[next];
 if (s.action === "collapse") {
 collapseMap();
 } else {
 if (isCollapsed) expandMap();
 mapEl.style.height = s.height;
 map.resize();
 }
 if (btn) btn.textContent = sizes[(next + 1) % sizes.length].label;
 },
 };
 } 

 
 window.gpxmaps[MAP_ID] = {
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() { if (fullMapInited) window.gpxmaps[MAP_ID].cycleSize(); else expandMap(); },
 
 togglePlay() { if (fullMapInited) window.gpxmaps[MAP_ID].togglePlay(); },
 setStyle(n) { if (fullMapInited) window.gpxmaps[MAP_ID].setStyle(n); },
 toggleTerrain(){ if (fullMapInited) window.gpxmaps[MAP_ID].toggleTerrain(); },
 seekTo(i) { if (fullMapInited) window.gpxmaps[MAP_ID].seekTo(i); },
 toggleUnits() { if (fullMapInited) window.gpxmaps[MAP_ID].toggleUnits(); },
 rotateby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].rotateby(d); },
 tiltby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].tiltby(d); },
 resetNorth() { if (fullMapInited) window.gpxmaps[MAP_ID].resetNorth(); },
 };
 }

 
 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", init);
 } else {
 init();
 }
})();
&lt;/script&gt;


&lt;div class="justified-gallery" id="gallery"&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_10.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_10_hu_7e9752d42834bb0b.jpg" alt="gallery/mole_hut_10.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_13.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_13_hu_a5775c5cbd0b2eff.jpg" alt="gallery/mole_hut_13.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_14.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_14_hu_3cb8a7086865d9f8.jpg" alt="gallery/mole_hut_14.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_15.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_15_hu_dad30b49012371d2.jpg" alt="gallery/mole_hut_15.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_16.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_16_hu_45c9fd8fee1b3f2.jpg" alt="gallery/mole_hut_16.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_20.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_20_hu_21f7a2d16c722468.jpg" alt="gallery/mole_hut_20.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_21.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_21_hu_ce2cee496be2a8d2.jpg" alt="gallery/mole_hut_21.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_24.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_24_hu_a21cc8b75d62c4f7.jpg" alt="gallery/mole_hut_24.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_27.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_27_hu_a3f1de7836bfad32.jpg" alt="gallery/mole_hut_27.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_31.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_31_hu_1a6f074d596bb890.jpg" alt="gallery/mole_hut_31.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_33.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_33_hu_b6a7f5a45a95f83d.jpg" alt="gallery/mole_hut_33.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_34.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_34_hu_16d5e4158b6d8951.jpg" alt="gallery/mole_hut_34.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_37.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_37_hu_72c35c88df034459.jpg" alt="gallery/mole_hut_37.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_38.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_38_hu_64cd3c84d6856072.jpg" alt="gallery/mole_hut_38.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_42.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_42_hu_15dedd4a6d91d1c.jpg" alt="gallery/mole_hut_42.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_44.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_44_hu_4ab111e95f28a970.jpg" alt="gallery/mole_hut_44.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_45.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_45_hu_a6a1111ae6e06df4.jpg" alt="gallery/mole_hut_45.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_48.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_48_hu_c380de24b327b681.jpg" alt="gallery/mole_hut_48.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_49.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_49_hu_903d92613dfa42e0.jpg" alt="gallery/mole_hut_49.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_50.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_50_hu_3d361afcfa4f8587.jpg" alt="gallery/mole_hut_50.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_53.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_53_hu_eeb31f88d55852f6.jpg" alt="gallery/mole_hut_53.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_54.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_54_hu_ef6c62bafc5ee2c9.jpg" alt="gallery/mole_hut_54.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_55.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_55_hu_476c4956c97da574.jpg" alt="gallery/mole_hut_55.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_57.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_57_hu_fd23511e76871f42.jpg" alt="gallery/mole_hut_57.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_60.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_60_hu_1315957bcf955046.jpg" alt="gallery/mole_hut_60.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_68.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_68_hu_8bb74e8ab668b00c.jpg" alt="gallery/mole_hut_68.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_69.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mole-hut/gallery/mole_hut_69_hu_a21c21d3bede68a3.jpg" alt="gallery/mole_hut_69.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
&lt;/div&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/mole-hut/feature.jpg"/></item><item><title>Mt. Fyffe Hut with Oscar</title><link>https://blog.willprogramforfood.com/posts/2026/mt-fyffe-hut-with-oscar/</link><pubDate>Sat, 13 Jun 2026 14:09:14 +1200</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/mt-fyffe-hut-with-oscar/</guid><description>Mt. Fyffe</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/mt-fyffe-hut-with-oscar/feature.jpg"/></item><item><title>Tirohanga Hike</title><link>https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/</link><pubDate>Sun, 26 Apr 2026 14:22:46 +1200</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/</guid><description>&lt;p&gt;Quick hike up &lt;a href="https://pictonparadise.co.nz/tirohangatrack/" target="_blank" rel="noreferrer"&gt;Tirohanga&lt;/a&gt; in Picton followed by cocoa and some mini golf and tiny trains.&lt;/p&gt;














&lt;div id="gpxmap-tirohanga-hike-shell" style="margin: 1.5rem 0;"&gt;

 
 &lt;div id="gpxmap-tirohanga-hike-mini" style="
 position: relative; cursor: pointer; border-radius: 8px; overflow: hidden;
 height: 180px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 " onclick="gpxmaps['gpxmap-tirohanga-hike'].expand()"&gt;
 &lt;div id="gpxmap-tirohanga-hike-minimap" style="width:100%; height:100%;"&gt;&lt;/div&gt;
 
 &lt;div style="
 position:absolute; bottom:0; left:0; right:0;
 background: linear-gradient(transparent, rgba(0,0,0,0.55));
 padding: 20px 12px 10px;
 display:flex; justify-content:space-between; align-items:flex-end;
 pointer-events:none;
 "&gt;
 &lt;span id="gpxmap-tirohanga-hike-mini-stats" style="color:#fff; font-size:0.78rem; font-family:monospace; opacity:0.9;"&gt;&lt;/span&gt;
 &lt;span style="color:#fff; font-size:0.78rem; opacity:0.8;"&gt;tap to explore ↗&lt;/span&gt;
 &lt;/div&gt;
 &lt;/div&gt;

 
 &lt;div id="gpxmap-tirohanga-hike-full" style="display:none;"&gt;

&lt;div class="gpxmap-wrap" style="position:relative;"&gt;
 &lt;div id="gpxmap-tirohanga-hike" style="width:100%; height:500px; border-radius:8px; overflow:hidden;"&gt;&lt;/div&gt;

 
 &lt;div id="gpxmap-tirohanga-hike-stats" style="
 display:flex; gap:1.5rem; flex-wrap:wrap; align-items:center;
 padding: 0.6rem 1rem;
 background: var(--bg-color, #1a1a1a);
 color: var(--text-color, #e0e0e0);
 font-size: 0.82rem;
 border-radius: 0 0 8px 8px;
 font-family: monospace;
 "&gt;
 &lt;span id="gpxmap-tirohanga-hike-stats-text"&gt;⟳ loading…&lt;/span&gt;
 &lt;button id="gpxmap-tirohanga-hike-unitbtn" onclick="gpxmaps['gpxmap-tirohanga-hike'].toggleUnits()"
 style="margin-left:auto; padding:2px 8px; font-size:0.75rem; border-radius:4px;
 border:1px solid rgba(255,255,255,0.3); cursor:pointer;
 background:transparent; color:inherit; font-family:monospace;"&gt;
 km / mi
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; right:10px; z-index:10; display:flex; flex-direction:column; gap:6px; align-items:flex-end;"&gt;
 &lt;div style="display:flex; gap:6px; align-items:center;"&gt;
 &lt;select id="gpxmap-tirohanga-hike-speed" title="Flythrough speed"
 style="padding:3px 5px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 &lt;option value="0.5"&gt;0.5×&lt;/option&gt;
 &lt;option value="1" selected&gt;1×&lt;/option&gt;
 &lt;option value="2"&gt;2×&lt;/option&gt;
 &lt;option value="4"&gt;4×&lt;/option&gt;
 &lt;/select&gt;
 &lt;button id="gpxmap-tirohanga-hike-playbtn" onclick="gpxmaps['gpxmap-tirohanga-hike'].togglePlay()"
 style="padding:4px 10px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 ▶ Fly
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-tirohanga-hike'].setStyle('outdoor')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Topo
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-tirohanga-hike'].setStyle('satellite')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Satellite
 &lt;/button&gt;
 &lt;button id="gpxmap-tirohanga-hike-3dbtn" onclick="gpxmaps['gpxmap-tirohanga-hike'].toggleTerrain()"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 3D ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;button id="gpxmap-tirohanga-hike-sizebtn" onclick="gpxmaps['gpxmap-tirohanga-hike'].cycleSize()"
 style="padding:2px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:rgba(255,255,255,0.9); opacity:0.9; font-family:monospace;"&gt;
 ↙ SM
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; left:10px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:4px;"&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-tirohanga-hike'].rotateby(-20)" title="Rotate left"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↺
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-tirohanga-hike'].resetNorth()" title="Reset north"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:bold;line-height:1;"&gt;
 N
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-tirohanga-hike'].rotateby(20)" title="Rotate right"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↻
 &lt;/button&gt;
 &lt;/div&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-tirohanga-hike'].tiltby(-15)" title="Tilt flat"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 🔭
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-tirohanga-hike'].tiltby(15)" title="Tilt up"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;/div&gt;


&lt;canvas id="gpxmap-tirohanga-hike-elev" height="80"
 style="width:100%; margin-top:-8px; border-radius:0 0 8px 8px; background:var(--bg-color, #111);"&gt;&lt;/canvas&gt;


&lt;div style="text-align:center; margin: 0.5rem 0 1.5rem; opacity:0.35; line-height:1;"&gt;
 &lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40" width="100%" height="40" preserveAspectRatio="none"&gt;
 &lt;path d="M0 40 L0 28 L30 12 L55 24 L90 4 L120 20 L150 8 L175 22 L200 2 L225 18 L255 6 L285 22 L310 10 L340 26 L370 8 L400 20 L400 40 Z"
 fill="currentColor"/&gt;
 &lt;/svg&gt;
&lt;/div&gt;

 &lt;/div&gt;
&lt;/div&gt;



 
 &lt;link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"&gt;
 &lt;script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"&gt;&lt;/script&gt;
 &lt;script&gt;
 window.gpxmaps = {};
 &lt;/script&gt;


&lt;script&gt;
(function() {
 const MAP_ID = "gpxmap-tirohanga-hike";
 const MAPTILER = "DSdF85giIH5Jkcc46q7r ";
 const BASE_STYLE = "outdoor-v2";
 const EXAGGERATION = 1.5 ;
 const GPX_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/tirohanga-hike\/track.gpx";
 const PHOTOS_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/tirohanga-hike\/photos.json";

 
 function parseGPX(xmlText) {
 const parser = new DOMParser();
 const doc = parser.parseFromString(xmlText, "application/xml");
 const ns = "http://www.topografix.com/GPX/1/1";

 const pts = Array.from(doc.getElementsByTagNameNS(ns, "trkpt")).map(pt =&gt; ({
 lon: parseFloat(pt.getAttribute("lon")),
 lat: parseFloat(pt.getAttribute("lat")),
 ele: parseFloat(pt.getElementsByTagNameNS(ns, "ele")[0]?.textContent ?? "0"),
 }));

 if (!pts.length) throw new Error("No trackpoints found in GPX");

 
 let dist = 0, eleGain = 0, eleLoss = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 dist += Math.sqrt(dx*dx + dy*dy);
 const dz = b.ele - a.ele;
 if (dz &gt; 0) eleGain += dz; else eleLoss += -dz;
 }

 const eles = pts.map(p =&gt; p.ele);
 const coord = pts.map(p =&gt; [p.lon, p.lat]);
 const center = coord[Math.floor(coord.length / 2)];

 
 let cumDist = 0;
 pts[0]._distFromStart = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 cumDist += Math.sqrt(dx*dx + dy*dy);
 pts[i]._distFromStart = cumDist;
 }

 return { pts, coord, center, dist, eleGain, eleLoss,
 eleMin: Math.min(...eles), eleMax: Math.max(...eles), eles };
 }

 function drawProfile(canvasId, track, onScrub) {
 const { eles, eleMin, eleMax, pts } = track;
 const canvas = document.getElementById(canvasId);
 if (!canvas) return;
 const ctx = canvas.getContext("2d");
 const DPR = window.devicePixelRatio || 1;
 const pad = 4;
 const range = eleMax - eleMin || 1;

 let currentAnimIdx = null; 

 function resize() {
 canvas.width = canvas.offsetWidth * DPR;
 canvas.height = canvas.offsetHeight * DPR;
 }
 resize();

 function yForEle(e) {
 const H = canvas.height;
 return pad + (1 - (e - eleMin) / range) * (H - pad * 2);
 }

 function draw(scrubX) {
 const W = canvas.width, H = canvas.height;
 ctx.clearRect(0, 0, W, H);

 
 if (currentAnimIdx !== null &amp;&amp; currentAnimIdx &gt; 0) {
 const progX = (currentAnimIdx / (eles.length - 1)) * W;

 
 const progGrad = ctx.createLinearGradient(0, 0, 0, H);
 progGrad.addColorStop(0, "rgba(224,92,42,0.55)");
 progGrad.addColorStop(1, "rgba(224,92,42,0.05)");
 ctx.fillStyle = progGrad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.lineTo(progX, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(224,92,42,0.9)";
 ctx.lineWidth = 2.5 * DPR;
 ctx.beginPath();
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(eles[i])) : ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.stroke();

 
 ctx.strokeStyle = "rgba(224,92,42,0.8)";
 ctx.lineWidth = 1.5 * DPR;
 ctx.setLineDash([3 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(progX, 0);
 ctx.lineTo(progX, H);
 ctx.stroke();
 ctx.setLineDash([]);
 }

 
 const grad = ctx.createLinearGradient(0, 0, 0, H);
 grad.addColorStop(0, "rgba(99,179,130,0.4)");
 grad.addColorStop(1, "rgba(99,179,130,0.02)");
 ctx.fillStyle = grad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(e));
 });
 ctx.lineTo(W, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(99,179,130,0.7)";
 ctx.lineWidth = 2 * DPR;
 ctx.beginPath();
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(e)) : ctx.lineTo(x, yForEle(e));
 });
 ctx.stroke();

 if (scrubX == null) return;

 
 ctx.strokeStyle = "rgba(255,255,255,0.7)";
 ctx.lineWidth = 1 * DPR;
 ctx.setLineDash([4 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(scrubX, 0);
 ctx.lineTo(scrubX, H);
 ctx.stroke();
 ctx.setLineDash([]);

 
 const idx = Math.round((scrubX / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 const cy = yForEle(eles[safeIdx]);
 const ts = 7 * DPR;
 ctx.fillStyle = "#fff";
 ctx.beginPath();
 ctx.moveTo(scrubX, cy - ts);
 ctx.lineTo(scrubX + ts, cy + ts * 0.6);
 ctx.lineTo(scrubX - ts, cy + ts * 0.6);
 ctx.closePath();
 ctx.fill();

 
 const distKm = (pts[safeIdx]._distFromStart / 1000).toFixed(1);
 const label = `${Math.round(eles[safeIdx])} m · ${distKm} km`;
 const fsize = 11 * DPR;
 ctx.font = `${fsize}px monospace`;
 const tw = ctx.measureText(label).width;
 const tx = Math.min(scrubX + 8 * DPR, W - tw - 4 * DPR);
 const ty = Math.max(fsize + 4 * DPR, cy - 8 * DPR);
 ctx.fillStyle = "rgba(0,0,0,0.6)";
 ctx.fillRect(tx - 3 * DPR, ty - fsize, tw + 6 * DPR, fsize + 4 * DPR);
 ctx.fillStyle = "#fff";
 ctx.fillText(label, tx, ty);
 }

 
 draw(null);

 
 function handleMove(clientX) {
 const rect = canvas.getBoundingClientRect();
 const W = canvas.width;
 const x = Math.max(0, Math.min(W, (clientX - rect.left) * DPR));
 const idx = Math.round((x / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 draw(x);
 onScrub(safeIdx);
 }

 function handleLeave() {
 draw(null);
 onScrub(null);
 }

 canvas.addEventListener("mousemove", e =&gt; handleMove(e.clientX));
 canvas.addEventListener("mouseleave", handleLeave);
 canvas.addEventListener("click", e =&gt; {
 const rect = canvas.getBoundingClientRect();
 const x = Math.max(0, Math.min(canvas.width, (e.clientX - rect.left) * DPR));
 const idx = Math.round((x / canvas.width) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 if (window.gpxmaps &amp;&amp; window.gpxmaps[MAP_ID]) {
 window.gpxmaps[MAP_ID].seekTo(safeIdx);
 }
 });
 canvas.addEventListener("touchmove", e =&gt; { e.preventDefault(); handleMove(e.touches[0].clientX); }, { passive: false });
 canvas.addEventListener("touchend", handleLeave);
 canvas.style.cursor = "crosshair";

 
 return {
 setAnimIdx(idx) {
 currentAnimIdx = idx;
 draw(null);
 },
 clearAnim() {
 currentAnimIdx = null;
 draw(null);
 },
 };
 }

 
 let useMetric = true;

 function updateStats(id, dist, eleGain, eleLoss, eleMin, eleMax) {
 const el = document.getElementById(id + "-stats-text");
 if (!el) return;
 const fmt = n =&gt; Math.round(n).toLocaleString();
 if (useMetric) {
 const km = (dist / 1000).toFixed(1);
 el.innerHTML = `
 &lt;span&gt;📍 ${km} km&lt;/span&gt;
 &lt;span&gt;⬆ ${fmt(eleGain)} m gain&lt;/span&gt;
 &lt;span&gt;⬇ ${fmt(eleLoss)} m loss&lt;/span&gt;
 &lt;span&gt;🏔 ${fmt(eleMax)} m high · ${fmt(eleMin)} m low&lt;/span&gt;
 `;
 } else {
 const mi = (dist / 1609.34).toFixed(1);
 const toFt = m =&gt; Math.round(m * 3.28084).toLocaleString();
 el.innerHTML = `
 &lt;span&gt;📍 ${mi} mi&lt;/span&gt;
 &lt;span&gt;⬆ ${toFt(eleGain)} ft gain&lt;/span&gt;
 &lt;span&gt;⬇ ${toFt(eleLoss)} ft loss&lt;/span&gt;
 &lt;span&gt;🏔 ${toFt(eleMax)} ft high · ${toFt(eleMin)} ft low&lt;/span&gt;
 `;
 }
 }

 
 function styleUrl(name) {
 const key = MAPTILER;
 const map = {
 outdoor: `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`,
 satellite: `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
 topo: `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
 };
 return map[name] || map.outdoor;
 }

 function terrainSource(key) {
 return {
 type: "raster-dem",
 url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${key}`,
 tileSize: 256,
 };
 }

 
 async function init() {
 
 const [gpxRes, photosRes] = await Promise.all([
 fetch(GPX_URL),
 fetch(PHOTOS_URL).catch(() =&gt; null),
 ]);

 if (!gpxRes.ok) {
 document.getElementById(MAP_ID + "-stats-text").textContent = "GPX file not found.";
 return;
 }

 const gpxText = await gpxRes.text();
 const track = parseGPX(gpxText);

 let photos = [];
 if (photosRes &amp;&amp; photosRes.ok) {
 photos = await photosRes.json();
 }

 
 const trackStats = { dist: track.dist, eleGain: track.eleGain, eleLoss: track.eleLoss,
 eleMin: track.eleMin, eleMax: track.eleMax };

 updateStats(MAP_ID, track.dist, track.eleGain, track.eleLoss, track.eleMin, track.eleMax);

 
 const miniStats = document.getElementById(MAP_ID + "-mini-stats");
 if (miniStats) {
 const km = (track.dist / 1000).toFixed(1);
 miniStats.textContent = `📍 ${km} km · ⬆ ${Math.round(track.eleGain)} m`;
 }

 
 const lons = track.coord.map(c =&gt; c[0]);
 const lats = track.coord.map(c =&gt; c[1]);
 const bbox = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];

 
 const miniMap = new maplibregl.Map({
 container: MAP_ID + "-minimap",
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 20, maxZoom: 13 },
 interactive: false,
 attributionControl: false,
 });

 miniMap.on("load", () =&gt; {
 miniMap.addSource("mini-track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 miniMap.addLayer({
 id: "mini-track-line", type: "line", source: "mini-track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 2.5, "line-opacity": 0.9 },
 });
 });

 
 let expanded = false;
 let fullMapInited = false;

 function expandMap() {
 if (expanded) return;
 expanded = true;
 document.getElementById(MAP_ID + "-mini").style.display = "none";
 document.getElementById(MAP_ID + "-full").style.display = "block";
 if (!fullMapInited) {
 fullMapInited = true;
 initFullMap();
 } else {
 map.resize();
 }
 }

 function collapseMap() {
 if (!expanded) return;
 expanded = false;
 document.getElementById(MAP_ID + "-full").style.display = "none";
 document.getElementById(MAP_ID + "-mini").style.display = "block";
 miniMap.resize();
 }

 
 let map;

 function initFullMap() {
 map = new maplibregl.Map({
 container: MAP_ID,
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 50, maxZoom: 15 },
 });

 let terrainOn = false;

 function addTerrain() {
 if (!map.getSource("terrain-src")) {
 map.addSource("terrain-src", terrainSource(MAPTILER));
 }
 map.setTerrain({ source: "terrain-src", exaggeration: EXAGGERATION });
 terrainOn = true;
 }

 
 let onScrub = (idx) =&gt; {};
 let segFeatures = []; 
 let profileCtrl = null; 

 map.on("load", () =&gt; {
 
 addTerrain();

 
 const scrubEl = document.createElement("div");
 scrubEl.style.cssText = `
 width: 0; height: 0;
 border-left: 7px solid transparent;
 border-right: 7px solid transparent;
 border-bottom: 13px solid #fff;
 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6));
 display: none;
 `;
 const scrubMarker = new maplibregl.Marker({ element: scrubEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);

 onScrub = function(idx) {
 if (idx === null) {
 scrubEl.style.display = "none";
 } else {
 scrubEl.style.display = "block";
 scrubMarker.setLngLat(track.coord[idx]);
 }
 };

 profileCtrl = drawProfile(MAP_ID + "-elev", track, onScrub);

 
 
 function gradeColor(pct) {
 const g = Math.abs(pct);
 if (g &lt; 8) return "#4caf50"; 
 if (g &lt; 15) return "#ffeb3b"; 
 if (g &lt; 25) return "#ff9800"; 
 return "#f44336"; 
 }

 
 segFeatures = [];
 for (let i = 1; i &lt; track.pts.length; i++) {
 const a = track.pts[i - 1], b = track.pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 const horiz = Math.sqrt(dx * dx + dy * dy);
 const grade = horiz &gt; 0 ? ((b.ele - a.ele) / horiz) * 100 : 0;
 segFeatures.push({
 type: "Feature",
 properties: { color: gradeColor(grade) },
 geometry: { type: "LineString", coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
 });
 }

 map.addSource("track", {
 type: "geojson",
 data: {
 type: "Feature",
 geometry: { type: "LineString", coordinates: track.coord },
 },
 });
 map.addLayer({
 id: "track-line",
 type: "line",
 source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": "#e05c2a",
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 3,
 9, 5,
 12, 4,
 15, 3,
 ],
 "line-opacity": 0.5,
 },
 });

 
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "FeatureCollection", features: [] },
 });
 map.addLayer({
 id: "track-progress-line",
 type: "line",
 source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": ["get", "color"],
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 4,
 9, 7,
 12, 6,
 15, 5,
 ],
 "line-opacity": 1.0,
 },
 });

 
 new maplibregl.Marker({ color: "#2ecc71" })
 .setLngLat(track.coord[0])
 .setPopup(new maplibregl.Popup().setText("Start"))
 .addTo(map);
 new maplibregl.Marker({ color: "#e74c3c" })
 .setLngLat(track.coord[track.coord.length - 1])
 .setPopup(new maplibregl.Popup().setText("End"))
 .addTo(map);

 
 const baseUrl = window.location.href.replace(/\/$/, "");
 photos.forEach(p =&gt; {
 const el = document.createElement("div");
 el.style.cssText = `
 width:28px; height:28px; border-radius:50%;
 background:#fff url('${baseUrl}/gallery/${p.file}') center/cover;
 border:2px solid #fff; box-shadow:0 1px 4px rgba(0,0,0,.4);
 cursor:pointer;
 `;

 const popup = new maplibregl.Popup({ offset: 18 }).setHTML(`
 &lt;a href="${baseUrl}/gallery/${p.file}" target="_blank"&gt;
 &lt;img src="${baseUrl}/gallery/${p.file}"
 style="max-width:200px; max-height:150px; border-radius:4px; display:block;"&gt;
 &lt;/a&gt;
 ${p.alt != null ? `&lt;div style="font-size:0.75rem; text-align:center; margin-top:4px;"&gt;${Math.round(p.alt)} m&lt;/div&gt;` : ""}
 `);

 new maplibregl.Marker({ element: el })
 .setLngLat([p.lon, p.lat])
 .setPopup(popup)
 .addTo(map);
 });

 
 flyEl.style.display = "none";
 flyMarker = new maplibregl.Marker({ element: flyEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);
 });

 
 let animFrame = null;
 let animPlaying = false;
 let lastTime = null;

 let flyMarker = null; 

 
 const flyEl = document.createElement("div");
 flyEl.style.cssText = "display:none; width:32px; height:40px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));";
 flyEl.innerHTML = `&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 40" width="32" height="40"&gt;
 &lt;path d="M16 2 C8 2 2 8 2 16 C2 26 16 38 16 38 C16 38 30 26 30 16 C30 8 24 2 16 2 Z"
 fill="#222" stroke="#fff" stroke-width="1.5"/&gt;
 &lt;circle cx="16" cy="16" r="5" fill="#fff"/&gt;
 &lt;/svg&gt;`;

 
 let animPos = 0; 
 const BASE_PPS = track.coord.length / 60; 

 function interpCoord(pos) {
 const i = Math.floor(pos);
 const t = pos - i;
 const a = track.coord[Math.min(i, track.coord.length - 1)];
 const b = track.coord[Math.min(i + 1, track.coord.length - 1)];
 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
 }

 function updateProgressLine(pos) {
 const idx = Math.min(Math.floor(pos), track.pts.length - 2);
 const tip = interpCoord(pos);
 const src = map.getSource("track-progress");
 if (!src) return;
 
 const features = segFeatures.slice(0, idx);
 
 if (idx &gt;= 0 &amp;&amp; idx &lt; segFeatures.length) {
 const seg = segFeatures[idx];
 features.push({
 ...seg,
 geometry: { type: "LineString", coordinates: [seg.geometry.coordinates[0], tip] },
 });
 }
 src.setData({ type: "FeatureCollection", features });
 }

 function animStep(ts) {
 if (!animPlaying) return;
 if (!lastTime) lastTime = ts;
 const dt = (ts - lastTime) / 1000;
 lastTime = ts;

 const speedEl = document.getElementById(MAP_ID + "-speed");
 const speed = speedEl ? parseFloat(speedEl.value) : 1;
 animPos = Math.min(animPos + BASE_PPS * speed * dt, track.coord.length - 1);

 const pos = interpCoord(animPos);
 if (flyMarker) {
 flyMarker.setLngLat(pos);
 flyEl.style.display = "block";
 }

 updateProgressLine(animPos);
 onScrub(Math.round(animPos));
 if (profileCtrl) profileCtrl.setAnimIdx(Math.round(animPos));

 if (animPos &gt;= track.coord.length - 1) {
 stopFly(true);
 return;
 }

 animFrame = requestAnimationFrame(animStep);
 }

 function stopFly(finished) {
 animPlaying = false;
 lastTime = null;
 cancelAnimationFrame(animFrame);
 if (flyMarker) flyEl.style.display = "none";
 onScrub(null);
 if (profileCtrl) profileCtrl.clearAnim();
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (btn) { btn.textContent = "▶ Fly"; btn.style.background = "#fff"; btn.style.color = ""; }
 if (finished) {
 
 updateProgressLine(track.coord.length - 1);
 }
 }

 
 window.gpxmaps[MAP_ID] = {
 setStyle(name) {
 map.setStyle(styleUrl(name));
 map.once("styledata", () =&gt; {
 addTerrain();
 if (!map.getSource("track")) {
 map.addSource("track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 map.addLayer({
 id: "track-line", type: "line", source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.25 },
 });
 }
 if (!map.getSource("track-progress")) {
 const done = track.coord.slice(0, Math.floor(animPos) + 1);
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: done } },
 });
 map.addLayer({
 id: "track-progress-line", type: "line", source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.9 },
 });
 }
 });
 },
 toggleTerrain() {
 const btn = document.getElementById(MAP_ID + "-3dbtn");
 if (terrainOn) {
 map.setTerrain(null);
 map.easeTo({ pitch: 0, bearing: 0, duration: 600 });
 terrainOn = false;
 if (btn) { btn.style.background = "#fff"; btn.style.color = ""; btn.style.fontWeight = "normal"; }
 } else {
 addTerrain();
 map.easeTo({ pitch: 55, duration: 800 });
 if (btn) { btn.style.background = "#e05c2a"; btn.style.color = "#fff"; btn.style.fontWeight = "bold"; }
 }
 },
 togglePlay() {
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (animPlaying) {
 stopFly(false);
 } else {
 if (!flyMarker) { console.warn("Map not ready yet"); return; }
 
 if (animPos &gt;= track.coord.length - 1) {
 animPos = 0;
 updateProgressLine(0);
 }
 animPlaying = true;
 lastTime = null;
 if (btn) { btn.textContent = "⏸ Pause"; btn.style.background = "#e05c2a"; btn.style.color = "#fff"; }
 animFrame = requestAnimationFrame(animStep);
 }
 },
 seekTo(idx) {
 animPos = idx;
 updateProgressLine(idx);
 onScrub(idx);
 if (profileCtrl) profileCtrl.setAnimIdx(idx);
 if (flyMarker) {
 flyMarker.setLngLat(track.coord[idx]);
 flyEl.style.display = "block";
 }
 },
 toggleUnits() {
 useMetric = !useMetric;
 const btn = document.getElementById(MAP_ID + "-unitbtn");
 if (btn) btn.textContent = useMetric ? "km / mi" : "mi / km";
 updateStats(MAP_ID, trackStats.dist, trackStats.eleGain, trackStats.eleLoss,
 trackStats.eleMin, trackStats.eleMax);
 },
 rotateby(deg) {
 map.easeTo({ bearing: map.getBearing() + deg, duration: 300 });
 },
 tiltby(deg) {
 const next = Math.max(0, Math.min(85, map.getPitch() + deg));
 map.easeTo({ pitch: next, duration: 300 });
 },
 resetNorth() {
 map.easeTo({ bearing: 0, pitch: 0, duration: 500 });
 },
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() {
 const mapEl = document.getElementById(MAP_ID);
 const btn = document.getElementById(MAP_ID + "-sizebtn");
 const sizes = [
 { label: "↙ SM", action: "collapse" },
 { label: "▭ MD", height: "500px" },
 { label: "▬ LG", height: "800px" },
 ];
 
 const fullEl = document.getElementById(MAP_ID + "-full");
 const isCollapsed = fullEl.style.display === "none";
 let current = isCollapsed ? 0
 : mapEl.style.height === "800px" ? 2 : 1;
 const next = (current + 1) % sizes.length;
 const s = sizes[next];
 if (s.action === "collapse") {
 collapseMap();
 } else {
 if (isCollapsed) expandMap();
 mapEl.style.height = s.height;
 map.resize();
 }
 if (btn) btn.textContent = sizes[(next + 1) % sizes.length].label;
 },
 };
 } 

 
 window.gpxmaps[MAP_ID] = {
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() { if (fullMapInited) window.gpxmaps[MAP_ID].cycleSize(); else expandMap(); },
 
 togglePlay() { if (fullMapInited) window.gpxmaps[MAP_ID].togglePlay(); },
 setStyle(n) { if (fullMapInited) window.gpxmaps[MAP_ID].setStyle(n); },
 toggleTerrain(){ if (fullMapInited) window.gpxmaps[MAP_ID].toggleTerrain(); },
 seekTo(i) { if (fullMapInited) window.gpxmaps[MAP_ID].seekTo(i); },
 toggleUnits() { if (fullMapInited) window.gpxmaps[MAP_ID].toggleUnits(); },
 rotateby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].rotateby(d); },
 tiltby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].tiltby(d); },
 resetNorth() { if (fullMapInited) window.gpxmaps[MAP_ID].resetNorth(); },
 };
 }

 
 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", init);
 } else {
 init();
 }
})();
&lt;/script&gt;


&lt;div class="justified-gallery" id="gallery"&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_02.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_02_hu_3198c68882f50317.jpg" alt="gallery/tirohanga_02.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_04.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_04_hu_458f6bcee7094ca8.jpg" alt="gallery/tirohanga_04.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_05.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_05_hu_c2f470b0551d865e.jpg" alt="gallery/tirohanga_05.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_06.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_06_hu_90a9409f9dda1f1f.jpg" alt="gallery/tirohanga_06.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_10.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_10_hu_2d4bfe8cfcd4109b.jpg" alt="gallery/tirohanga_10.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_11.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_11_hu_158da71f33431267.jpg" alt="gallery/tirohanga_11.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_13.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/gallery/tirohanga_13_hu_5ca4318f9edd2f21.jpg" alt="gallery/tirohanga_13.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
&lt;/div&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/tirohanga-hike/feature.jpg"/></item><item><title>Mt. Takorika hike</title><link>https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/</link><pubDate>Sun, 19 Apr 2026 14:18:08 +1200</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/</guid><description>&lt;p&gt;Went to Havelock to do &lt;a href="https://www.marlboroughonline.co.nz/marlborough/information/recreation/tramping-and-walking/mount-takorika-tracks/" target="_blank" rel="noreferrer"&gt;Mt. Takorika&lt;/a&gt; hike with Craig and Ethan - Oscar was busy at a birthday party. It was pretty wet and slippy in parts but had a few views through the clouds.&lt;/p&gt;














&lt;div id="gpxmap-mt-takorika-hike-shell" style="margin: 1.5rem 0;"&gt;

 
 &lt;div id="gpxmap-mt-takorika-hike-mini" style="
 position: relative; cursor: pointer; border-radius: 8px; overflow: hidden;
 height: 180px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 " onclick="gpxmaps['gpxmap-mt-takorika-hike'].expand()"&gt;
 &lt;div id="gpxmap-mt-takorika-hike-minimap" style="width:100%; height:100%;"&gt;&lt;/div&gt;
 
 &lt;div style="
 position:absolute; bottom:0; left:0; right:0;
 background: linear-gradient(transparent, rgba(0,0,0,0.55));
 padding: 20px 12px 10px;
 display:flex; justify-content:space-between; align-items:flex-end;
 pointer-events:none;
 "&gt;
 &lt;span id="gpxmap-mt-takorika-hike-mini-stats" style="color:#fff; font-size:0.78rem; font-family:monospace; opacity:0.9;"&gt;&lt;/span&gt;
 &lt;span style="color:#fff; font-size:0.78rem; opacity:0.8;"&gt;tap to explore ↗&lt;/span&gt;
 &lt;/div&gt;
 &lt;/div&gt;

 
 &lt;div id="gpxmap-mt-takorika-hike-full" style="display:none;"&gt;

&lt;div class="gpxmap-wrap" style="position:relative;"&gt;
 &lt;div id="gpxmap-mt-takorika-hike" style="width:100%; height:500px; border-radius:8px; overflow:hidden;"&gt;&lt;/div&gt;

 
 &lt;div id="gpxmap-mt-takorika-hike-stats" style="
 display:flex; gap:1.5rem; flex-wrap:wrap; align-items:center;
 padding: 0.6rem 1rem;
 background: var(--bg-color, #1a1a1a);
 color: var(--text-color, #e0e0e0);
 font-size: 0.82rem;
 border-radius: 0 0 8px 8px;
 font-family: monospace;
 "&gt;
 &lt;span id="gpxmap-mt-takorika-hike-stats-text"&gt;⟳ loading…&lt;/span&gt;
 &lt;button id="gpxmap-mt-takorika-hike-unitbtn" onclick="gpxmaps['gpxmap-mt-takorika-hike'].toggleUnits()"
 style="margin-left:auto; padding:2px 8px; font-size:0.75rem; border-radius:4px;
 border:1px solid rgba(255,255,255,0.3); cursor:pointer;
 background:transparent; color:inherit; font-family:monospace;"&gt;
 km / mi
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; right:10px; z-index:10; display:flex; flex-direction:column; gap:6px; align-items:flex-end;"&gt;
 &lt;div style="display:flex; gap:6px; align-items:center;"&gt;
 &lt;select id="gpxmap-mt-takorika-hike-speed" title="Flythrough speed"
 style="padding:3px 5px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 &lt;option value="0.5"&gt;0.5×&lt;/option&gt;
 &lt;option value="1" selected&gt;1×&lt;/option&gt;
 &lt;option value="2"&gt;2×&lt;/option&gt;
 &lt;option value="4"&gt;4×&lt;/option&gt;
 &lt;/select&gt;
 &lt;button id="gpxmap-mt-takorika-hike-playbtn" onclick="gpxmaps['gpxmap-mt-takorika-hike'].togglePlay()"
 style="padding:4px 10px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 ▶ Fly
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-takorika-hike'].setStyle('outdoor')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Topo
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-takorika-hike'].setStyle('satellite')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Satellite
 &lt;/button&gt;
 &lt;button id="gpxmap-mt-takorika-hike-3dbtn" onclick="gpxmaps['gpxmap-mt-takorika-hike'].toggleTerrain()"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 3D ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;button id="gpxmap-mt-takorika-hike-sizebtn" onclick="gpxmaps['gpxmap-mt-takorika-hike'].cycleSize()"
 style="padding:2px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:rgba(255,255,255,0.9); opacity:0.9; font-family:monospace;"&gt;
 ↙ SM
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; left:10px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:4px;"&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-takorika-hike'].rotateby(-20)" title="Rotate left"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↺
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-takorika-hike'].resetNorth()" title="Reset north"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:bold;line-height:1;"&gt;
 N
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-takorika-hike'].rotateby(20)" title="Rotate right"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↻
 &lt;/button&gt;
 &lt;/div&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-takorika-hike'].tiltby(-15)" title="Tilt flat"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 🔭
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-takorika-hike'].tiltby(15)" title="Tilt up"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;/div&gt;


&lt;canvas id="gpxmap-mt-takorika-hike-elev" height="80"
 style="width:100%; margin-top:-8px; border-radius:0 0 8px 8px; background:var(--bg-color, #111);"&gt;&lt;/canvas&gt;


&lt;div style="text-align:center; margin: 0.5rem 0 1.5rem; opacity:0.35; line-height:1;"&gt;
 &lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40" width="100%" height="40" preserveAspectRatio="none"&gt;
 &lt;path d="M0 40 L0 28 L30 12 L55 24 L90 4 L120 20 L150 8 L175 22 L200 2 L225 18 L255 6 L285 22 L310 10 L340 26 L370 8 L400 20 L400 40 Z"
 fill="currentColor"/&gt;
 &lt;/svg&gt;
&lt;/div&gt;

 &lt;/div&gt;
&lt;/div&gt;



 
 &lt;link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"&gt;
 &lt;script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"&gt;&lt;/script&gt;
 &lt;script&gt;
 window.gpxmaps = {};
 &lt;/script&gt;


&lt;script&gt;
(function() {
 const MAP_ID = "gpxmap-mt-takorika-hike";
 const MAPTILER = "DSdF85giIH5Jkcc46q7r ";
 const BASE_STYLE = "outdoor-v2";
 const EXAGGERATION = 1.5 ;
 const GPX_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/mt-takorika-hike\/track.gpx";
 const PHOTOS_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/mt-takorika-hike\/photos.json";

 
 function parseGPX(xmlText) {
 const parser = new DOMParser();
 const doc = parser.parseFromString(xmlText, "application/xml");
 const ns = "http://www.topografix.com/GPX/1/1";

 const pts = Array.from(doc.getElementsByTagNameNS(ns, "trkpt")).map(pt =&gt; ({
 lon: parseFloat(pt.getAttribute("lon")),
 lat: parseFloat(pt.getAttribute("lat")),
 ele: parseFloat(pt.getElementsByTagNameNS(ns, "ele")[0]?.textContent ?? "0"),
 }));

 if (!pts.length) throw new Error("No trackpoints found in GPX");

 
 let dist = 0, eleGain = 0, eleLoss = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 dist += Math.sqrt(dx*dx + dy*dy);
 const dz = b.ele - a.ele;
 if (dz &gt; 0) eleGain += dz; else eleLoss += -dz;
 }

 const eles = pts.map(p =&gt; p.ele);
 const coord = pts.map(p =&gt; [p.lon, p.lat]);
 const center = coord[Math.floor(coord.length / 2)];

 
 let cumDist = 0;
 pts[0]._distFromStart = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 cumDist += Math.sqrt(dx*dx + dy*dy);
 pts[i]._distFromStart = cumDist;
 }

 return { pts, coord, center, dist, eleGain, eleLoss,
 eleMin: Math.min(...eles), eleMax: Math.max(...eles), eles };
 }

 function drawProfile(canvasId, track, onScrub) {
 const { eles, eleMin, eleMax, pts } = track;
 const canvas = document.getElementById(canvasId);
 if (!canvas) return;
 const ctx = canvas.getContext("2d");
 const DPR = window.devicePixelRatio || 1;
 const pad = 4;
 const range = eleMax - eleMin || 1;

 let currentAnimIdx = null; 

 function resize() {
 canvas.width = canvas.offsetWidth * DPR;
 canvas.height = canvas.offsetHeight * DPR;
 }
 resize();

 function yForEle(e) {
 const H = canvas.height;
 return pad + (1 - (e - eleMin) / range) * (H - pad * 2);
 }

 function draw(scrubX) {
 const W = canvas.width, H = canvas.height;
 ctx.clearRect(0, 0, W, H);

 
 if (currentAnimIdx !== null &amp;&amp; currentAnimIdx &gt; 0) {
 const progX = (currentAnimIdx / (eles.length - 1)) * W;

 
 const progGrad = ctx.createLinearGradient(0, 0, 0, H);
 progGrad.addColorStop(0, "rgba(224,92,42,0.55)");
 progGrad.addColorStop(1, "rgba(224,92,42,0.05)");
 ctx.fillStyle = progGrad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.lineTo(progX, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(224,92,42,0.9)";
 ctx.lineWidth = 2.5 * DPR;
 ctx.beginPath();
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(eles[i])) : ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.stroke();

 
 ctx.strokeStyle = "rgba(224,92,42,0.8)";
 ctx.lineWidth = 1.5 * DPR;
 ctx.setLineDash([3 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(progX, 0);
 ctx.lineTo(progX, H);
 ctx.stroke();
 ctx.setLineDash([]);
 }

 
 const grad = ctx.createLinearGradient(0, 0, 0, H);
 grad.addColorStop(0, "rgba(99,179,130,0.4)");
 grad.addColorStop(1, "rgba(99,179,130,0.02)");
 ctx.fillStyle = grad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(e));
 });
 ctx.lineTo(W, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(99,179,130,0.7)";
 ctx.lineWidth = 2 * DPR;
 ctx.beginPath();
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(e)) : ctx.lineTo(x, yForEle(e));
 });
 ctx.stroke();

 if (scrubX == null) return;

 
 ctx.strokeStyle = "rgba(255,255,255,0.7)";
 ctx.lineWidth = 1 * DPR;
 ctx.setLineDash([4 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(scrubX, 0);
 ctx.lineTo(scrubX, H);
 ctx.stroke();
 ctx.setLineDash([]);

 
 const idx = Math.round((scrubX / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 const cy = yForEle(eles[safeIdx]);
 const ts = 7 * DPR;
 ctx.fillStyle = "#fff";
 ctx.beginPath();
 ctx.moveTo(scrubX, cy - ts);
 ctx.lineTo(scrubX + ts, cy + ts * 0.6);
 ctx.lineTo(scrubX - ts, cy + ts * 0.6);
 ctx.closePath();
 ctx.fill();

 
 const distKm = (pts[safeIdx]._distFromStart / 1000).toFixed(1);
 const label = `${Math.round(eles[safeIdx])} m · ${distKm} km`;
 const fsize = 11 * DPR;
 ctx.font = `${fsize}px monospace`;
 const tw = ctx.measureText(label).width;
 const tx = Math.min(scrubX + 8 * DPR, W - tw - 4 * DPR);
 const ty = Math.max(fsize + 4 * DPR, cy - 8 * DPR);
 ctx.fillStyle = "rgba(0,0,0,0.6)";
 ctx.fillRect(tx - 3 * DPR, ty - fsize, tw + 6 * DPR, fsize + 4 * DPR);
 ctx.fillStyle = "#fff";
 ctx.fillText(label, tx, ty);
 }

 
 draw(null);

 
 function handleMove(clientX) {
 const rect = canvas.getBoundingClientRect();
 const W = canvas.width;
 const x = Math.max(0, Math.min(W, (clientX - rect.left) * DPR));
 const idx = Math.round((x / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 draw(x);
 onScrub(safeIdx);
 }

 function handleLeave() {
 draw(null);
 onScrub(null);
 }

 canvas.addEventListener("mousemove", e =&gt; handleMove(e.clientX));
 canvas.addEventListener("mouseleave", handleLeave);
 canvas.addEventListener("click", e =&gt; {
 const rect = canvas.getBoundingClientRect();
 const x = Math.max(0, Math.min(canvas.width, (e.clientX - rect.left) * DPR));
 const idx = Math.round((x / canvas.width) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 if (window.gpxmaps &amp;&amp; window.gpxmaps[MAP_ID]) {
 window.gpxmaps[MAP_ID].seekTo(safeIdx);
 }
 });
 canvas.addEventListener("touchmove", e =&gt; { e.preventDefault(); handleMove(e.touches[0].clientX); }, { passive: false });
 canvas.addEventListener("touchend", handleLeave);
 canvas.style.cursor = "crosshair";

 
 return {
 setAnimIdx(idx) {
 currentAnimIdx = idx;
 draw(null);
 },
 clearAnim() {
 currentAnimIdx = null;
 draw(null);
 },
 };
 }

 
 let useMetric = true;

 function updateStats(id, dist, eleGain, eleLoss, eleMin, eleMax) {
 const el = document.getElementById(id + "-stats-text");
 if (!el) return;
 const fmt = n =&gt; Math.round(n).toLocaleString();
 if (useMetric) {
 const km = (dist / 1000).toFixed(1);
 el.innerHTML = `
 &lt;span&gt;📍 ${km} km&lt;/span&gt;
 &lt;span&gt;⬆ ${fmt(eleGain)} m gain&lt;/span&gt;
 &lt;span&gt;⬇ ${fmt(eleLoss)} m loss&lt;/span&gt;
 &lt;span&gt;🏔 ${fmt(eleMax)} m high · ${fmt(eleMin)} m low&lt;/span&gt;
 `;
 } else {
 const mi = (dist / 1609.34).toFixed(1);
 const toFt = m =&gt; Math.round(m * 3.28084).toLocaleString();
 el.innerHTML = `
 &lt;span&gt;📍 ${mi} mi&lt;/span&gt;
 &lt;span&gt;⬆ ${toFt(eleGain)} ft gain&lt;/span&gt;
 &lt;span&gt;⬇ ${toFt(eleLoss)} ft loss&lt;/span&gt;
 &lt;span&gt;🏔 ${toFt(eleMax)} ft high · ${toFt(eleMin)} ft low&lt;/span&gt;
 `;
 }
 }

 
 function styleUrl(name) {
 const key = MAPTILER;
 const map = {
 outdoor: `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`,
 satellite: `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
 topo: `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
 };
 return map[name] || map.outdoor;
 }

 function terrainSource(key) {
 return {
 type: "raster-dem",
 url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${key}`,
 tileSize: 256,
 };
 }

 
 async function init() {
 
 const [gpxRes, photosRes] = await Promise.all([
 fetch(GPX_URL),
 fetch(PHOTOS_URL).catch(() =&gt; null),
 ]);

 if (!gpxRes.ok) {
 document.getElementById(MAP_ID + "-stats-text").textContent = "GPX file not found.";
 return;
 }

 const gpxText = await gpxRes.text();
 const track = parseGPX(gpxText);

 let photos = [];
 if (photosRes &amp;&amp; photosRes.ok) {
 photos = await photosRes.json();
 }

 
 const trackStats = { dist: track.dist, eleGain: track.eleGain, eleLoss: track.eleLoss,
 eleMin: track.eleMin, eleMax: track.eleMax };

 updateStats(MAP_ID, track.dist, track.eleGain, track.eleLoss, track.eleMin, track.eleMax);

 
 const miniStats = document.getElementById(MAP_ID + "-mini-stats");
 if (miniStats) {
 const km = (track.dist / 1000).toFixed(1);
 miniStats.textContent = `📍 ${km} km · ⬆ ${Math.round(track.eleGain)} m`;
 }

 
 const lons = track.coord.map(c =&gt; c[0]);
 const lats = track.coord.map(c =&gt; c[1]);
 const bbox = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];

 
 const miniMap = new maplibregl.Map({
 container: MAP_ID + "-minimap",
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 20, maxZoom: 13 },
 interactive: false,
 attributionControl: false,
 });

 miniMap.on("load", () =&gt; {
 miniMap.addSource("mini-track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 miniMap.addLayer({
 id: "mini-track-line", type: "line", source: "mini-track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 2.5, "line-opacity": 0.9 },
 });
 });

 
 let expanded = false;
 let fullMapInited = false;

 function expandMap() {
 if (expanded) return;
 expanded = true;
 document.getElementById(MAP_ID + "-mini").style.display = "none";
 document.getElementById(MAP_ID + "-full").style.display = "block";
 if (!fullMapInited) {
 fullMapInited = true;
 initFullMap();
 } else {
 map.resize();
 }
 }

 function collapseMap() {
 if (!expanded) return;
 expanded = false;
 document.getElementById(MAP_ID + "-full").style.display = "none";
 document.getElementById(MAP_ID + "-mini").style.display = "block";
 miniMap.resize();
 }

 
 let map;

 function initFullMap() {
 map = new maplibregl.Map({
 container: MAP_ID,
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 50, maxZoom: 15 },
 });

 let terrainOn = false;

 function addTerrain() {
 if (!map.getSource("terrain-src")) {
 map.addSource("terrain-src", terrainSource(MAPTILER));
 }
 map.setTerrain({ source: "terrain-src", exaggeration: EXAGGERATION });
 terrainOn = true;
 }

 
 let onScrub = (idx) =&gt; {};
 let segFeatures = []; 
 let profileCtrl = null; 

 map.on("load", () =&gt; {
 
 addTerrain();

 
 const scrubEl = document.createElement("div");
 scrubEl.style.cssText = `
 width: 0; height: 0;
 border-left: 7px solid transparent;
 border-right: 7px solid transparent;
 border-bottom: 13px solid #fff;
 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6));
 display: none;
 `;
 const scrubMarker = new maplibregl.Marker({ element: scrubEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);

 onScrub = function(idx) {
 if (idx === null) {
 scrubEl.style.display = "none";
 } else {
 scrubEl.style.display = "block";
 scrubMarker.setLngLat(track.coord[idx]);
 }
 };

 profileCtrl = drawProfile(MAP_ID + "-elev", track, onScrub);

 
 
 function gradeColor(pct) {
 const g = Math.abs(pct);
 if (g &lt; 8) return "#4caf50"; 
 if (g &lt; 15) return "#ffeb3b"; 
 if (g &lt; 25) return "#ff9800"; 
 return "#f44336"; 
 }

 
 segFeatures = [];
 for (let i = 1; i &lt; track.pts.length; i++) {
 const a = track.pts[i - 1], b = track.pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 const horiz = Math.sqrt(dx * dx + dy * dy);
 const grade = horiz &gt; 0 ? ((b.ele - a.ele) / horiz) * 100 : 0;
 segFeatures.push({
 type: "Feature",
 properties: { color: gradeColor(grade) },
 geometry: { type: "LineString", coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
 });
 }

 map.addSource("track", {
 type: "geojson",
 data: {
 type: "Feature",
 geometry: { type: "LineString", coordinates: track.coord },
 },
 });
 map.addLayer({
 id: "track-line",
 type: "line",
 source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": "#e05c2a",
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 3,
 9, 5,
 12, 4,
 15, 3,
 ],
 "line-opacity": 0.5,
 },
 });

 
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "FeatureCollection", features: [] },
 });
 map.addLayer({
 id: "track-progress-line",
 type: "line",
 source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": ["get", "color"],
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 4,
 9, 7,
 12, 6,
 15, 5,
 ],
 "line-opacity": 1.0,
 },
 });

 
 new maplibregl.Marker({ color: "#2ecc71" })
 .setLngLat(track.coord[0])
 .setPopup(new maplibregl.Popup().setText("Start"))
 .addTo(map);
 new maplibregl.Marker({ color: "#e74c3c" })
 .setLngLat(track.coord[track.coord.length - 1])
 .setPopup(new maplibregl.Popup().setText("End"))
 .addTo(map);

 
 const baseUrl = window.location.href.replace(/\/$/, "");
 photos.forEach(p =&gt; {
 const el = document.createElement("div");
 el.style.cssText = `
 width:28px; height:28px; border-radius:50%;
 background:#fff url('${baseUrl}/gallery/${p.file}') center/cover;
 border:2px solid #fff; box-shadow:0 1px 4px rgba(0,0,0,.4);
 cursor:pointer;
 `;

 const popup = new maplibregl.Popup({ offset: 18 }).setHTML(`
 &lt;a href="${baseUrl}/gallery/${p.file}" target="_blank"&gt;
 &lt;img src="${baseUrl}/gallery/${p.file}"
 style="max-width:200px; max-height:150px; border-radius:4px; display:block;"&gt;
 &lt;/a&gt;
 ${p.alt != null ? `&lt;div style="font-size:0.75rem; text-align:center; margin-top:4px;"&gt;${Math.round(p.alt)} m&lt;/div&gt;` : ""}
 `);

 new maplibregl.Marker({ element: el })
 .setLngLat([p.lon, p.lat])
 .setPopup(popup)
 .addTo(map);
 });

 
 flyEl.style.display = "none";
 flyMarker = new maplibregl.Marker({ element: flyEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);
 });

 
 let animFrame = null;
 let animPlaying = false;
 let lastTime = null;

 let flyMarker = null; 

 
 const flyEl = document.createElement("div");
 flyEl.style.cssText = "display:none; width:32px; height:40px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));";
 flyEl.innerHTML = `&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 40" width="32" height="40"&gt;
 &lt;path d="M16 2 C8 2 2 8 2 16 C2 26 16 38 16 38 C16 38 30 26 30 16 C30 8 24 2 16 2 Z"
 fill="#222" stroke="#fff" stroke-width="1.5"/&gt;
 &lt;circle cx="16" cy="16" r="5" fill="#fff"/&gt;
 &lt;/svg&gt;`;

 
 let animPos = 0; 
 const BASE_PPS = track.coord.length / 60; 

 function interpCoord(pos) {
 const i = Math.floor(pos);
 const t = pos - i;
 const a = track.coord[Math.min(i, track.coord.length - 1)];
 const b = track.coord[Math.min(i + 1, track.coord.length - 1)];
 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
 }

 function updateProgressLine(pos) {
 const idx = Math.min(Math.floor(pos), track.pts.length - 2);
 const tip = interpCoord(pos);
 const src = map.getSource("track-progress");
 if (!src) return;
 
 const features = segFeatures.slice(0, idx);
 
 if (idx &gt;= 0 &amp;&amp; idx &lt; segFeatures.length) {
 const seg = segFeatures[idx];
 features.push({
 ...seg,
 geometry: { type: "LineString", coordinates: [seg.geometry.coordinates[0], tip] },
 });
 }
 src.setData({ type: "FeatureCollection", features });
 }

 function animStep(ts) {
 if (!animPlaying) return;
 if (!lastTime) lastTime = ts;
 const dt = (ts - lastTime) / 1000;
 lastTime = ts;

 const speedEl = document.getElementById(MAP_ID + "-speed");
 const speed = speedEl ? parseFloat(speedEl.value) : 1;
 animPos = Math.min(animPos + BASE_PPS * speed * dt, track.coord.length - 1);

 const pos = interpCoord(animPos);
 if (flyMarker) {
 flyMarker.setLngLat(pos);
 flyEl.style.display = "block";
 }

 updateProgressLine(animPos);
 onScrub(Math.round(animPos));
 if (profileCtrl) profileCtrl.setAnimIdx(Math.round(animPos));

 if (animPos &gt;= track.coord.length - 1) {
 stopFly(true);
 return;
 }

 animFrame = requestAnimationFrame(animStep);
 }

 function stopFly(finished) {
 animPlaying = false;
 lastTime = null;
 cancelAnimationFrame(animFrame);
 if (flyMarker) flyEl.style.display = "none";
 onScrub(null);
 if (profileCtrl) profileCtrl.clearAnim();
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (btn) { btn.textContent = "▶ Fly"; btn.style.background = "#fff"; btn.style.color = ""; }
 if (finished) {
 
 updateProgressLine(track.coord.length - 1);
 }
 }

 
 window.gpxmaps[MAP_ID] = {
 setStyle(name) {
 map.setStyle(styleUrl(name));
 map.once("styledata", () =&gt; {
 addTerrain();
 if (!map.getSource("track")) {
 map.addSource("track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 map.addLayer({
 id: "track-line", type: "line", source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.25 },
 });
 }
 if (!map.getSource("track-progress")) {
 const done = track.coord.slice(0, Math.floor(animPos) + 1);
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: done } },
 });
 map.addLayer({
 id: "track-progress-line", type: "line", source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.9 },
 });
 }
 });
 },
 toggleTerrain() {
 const btn = document.getElementById(MAP_ID + "-3dbtn");
 if (terrainOn) {
 map.setTerrain(null);
 map.easeTo({ pitch: 0, bearing: 0, duration: 600 });
 terrainOn = false;
 if (btn) { btn.style.background = "#fff"; btn.style.color = ""; btn.style.fontWeight = "normal"; }
 } else {
 addTerrain();
 map.easeTo({ pitch: 55, duration: 800 });
 if (btn) { btn.style.background = "#e05c2a"; btn.style.color = "#fff"; btn.style.fontWeight = "bold"; }
 }
 },
 togglePlay() {
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (animPlaying) {
 stopFly(false);
 } else {
 if (!flyMarker) { console.warn("Map not ready yet"); return; }
 
 if (animPos &gt;= track.coord.length - 1) {
 animPos = 0;
 updateProgressLine(0);
 }
 animPlaying = true;
 lastTime = null;
 if (btn) { btn.textContent = "⏸ Pause"; btn.style.background = "#e05c2a"; btn.style.color = "#fff"; }
 animFrame = requestAnimationFrame(animStep);
 }
 },
 seekTo(idx) {
 animPos = idx;
 updateProgressLine(idx);
 onScrub(idx);
 if (profileCtrl) profileCtrl.setAnimIdx(idx);
 if (flyMarker) {
 flyMarker.setLngLat(track.coord[idx]);
 flyEl.style.display = "block";
 }
 },
 toggleUnits() {
 useMetric = !useMetric;
 const btn = document.getElementById(MAP_ID + "-unitbtn");
 if (btn) btn.textContent = useMetric ? "km / mi" : "mi / km";
 updateStats(MAP_ID, trackStats.dist, trackStats.eleGain, trackStats.eleLoss,
 trackStats.eleMin, trackStats.eleMax);
 },
 rotateby(deg) {
 map.easeTo({ bearing: map.getBearing() + deg, duration: 300 });
 },
 tiltby(deg) {
 const next = Math.max(0, Math.min(85, map.getPitch() + deg));
 map.easeTo({ pitch: next, duration: 300 });
 },
 resetNorth() {
 map.easeTo({ bearing: 0, pitch: 0, duration: 500 });
 },
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() {
 const mapEl = document.getElementById(MAP_ID);
 const btn = document.getElementById(MAP_ID + "-sizebtn");
 const sizes = [
 { label: "↙ SM", action: "collapse" },
 { label: "▭ MD", height: "500px" },
 { label: "▬ LG", height: "800px" },
 ];
 
 const fullEl = document.getElementById(MAP_ID + "-full");
 const isCollapsed = fullEl.style.display === "none";
 let current = isCollapsed ? 0
 : mapEl.style.height === "800px" ? 2 : 1;
 const next = (current + 1) % sizes.length;
 const s = sizes[next];
 if (s.action === "collapse") {
 collapseMap();
 } else {
 if (isCollapsed) expandMap();
 mapEl.style.height = s.height;
 map.resize();
 }
 if (btn) btn.textContent = sizes[(next + 1) % sizes.length].label;
 },
 };
 } 

 
 window.gpxmaps[MAP_ID] = {
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() { if (fullMapInited) window.gpxmaps[MAP_ID].cycleSize(); else expandMap(); },
 
 togglePlay() { if (fullMapInited) window.gpxmaps[MAP_ID].togglePlay(); },
 setStyle(n) { if (fullMapInited) window.gpxmaps[MAP_ID].setStyle(n); },
 toggleTerrain(){ if (fullMapInited) window.gpxmaps[MAP_ID].toggleTerrain(); },
 seekTo(i) { if (fullMapInited) window.gpxmaps[MAP_ID].seekTo(i); },
 toggleUnits() { if (fullMapInited) window.gpxmaps[MAP_ID].toggleUnits(); },
 rotateby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].rotateby(d); },
 tiltby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].tiltby(d); },
 resetNorth() { if (fullMapInited) window.gpxmaps[MAP_ID].resetNorth(); },
 };
 }

 
 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", init);
 } else {
 init();
 }
})();
&lt;/script&gt;


&lt;div class="justified-gallery" id="gallery"&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_02.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_02_hu_ff9721ebf82540e1.jpg" alt="gallery/takorika_hike_02.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_03.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_03_hu_742e2837b54e18a2.jpg" alt="gallery/takorika_hike_03.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_04.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_04_hu_7cbef84aafd1eed4.jpg" alt="gallery/takorika_hike_04.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_08.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_08_hu_a61f8cc26ae6e809.jpg" alt="gallery/takorika_hike_08.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_09.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_09_hu_d81a7be60e2f19b6.jpg" alt="gallery/takorika_hike_09.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_10.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_10_hu_8fc0eff5d4fd2a7f.jpg" alt="gallery/takorika_hike_10.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_12.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_12_hu_a76dc8137b569de2.jpg" alt="gallery/takorika_hike_12.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_13.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_13_hu_33d5d1c2e52e4037.jpg" alt="gallery/takorika_hike_13.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_16.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_16_hu_9fa13c656ee10ba1.jpg" alt="gallery/takorika_hike_16.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_18.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_18_hu_d4d65315ea0869d1.jpg" alt="gallery/takorika_hike_18.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_20.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/gallery/takorika_hike_20_hu_20a2e08398e05372.jpg" alt="gallery/takorika_hike_20.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
&lt;/div&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/mt-takorika-hike/feature.jpg"/></item><item><title>Abel Tasman: Bark Bay to Awaroha</title><link>https://blog.willprogramforfood.com/posts/2026/abel-tasman-bark-bay-to-awaroha/</link><pubDate>Tue, 07 Apr 2026 19:48:46 +1200</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/abel-tasman-bark-bay-to-awaroha/</guid><description>&lt;p&gt;The weather forecast was for tons of rain all day so we almost bailed but last minute decided to keep going. This meant to bag shuttle so we loaded up and threw on the rain covers and headed out.&lt;/p&gt;
&lt;p&gt;Along the way our feet, ok my feet, were starting to hurt so I called up the Awaroha Lodge and saw they had one room left. I convinced Oscar we should go there instead of the hut as we could get chippies. This turned out to be a good call as it rained all night and the water crossing from the hut to the shuttle pickup was very high.&lt;/p&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/abel-tasman-bark-bay-to-awaroha/feature.jpg"/></item><item><title>Abel Tasman: Anchorage to Bark Bay</title><link>https://blog.willprogramforfood.com/posts/2026/abel-tasman-anchorage-to-bark-bay/</link><pubDate>Mon, 06 Apr 2026 20:02:55 +1200</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/abel-tasman-anchorage-to-bark-bay/</guid><description>&lt;p&gt;We had an early start in the dark to make our tidal crossing at low tide. Ate breakfast and tea by headlamp and then made our way to the DOC sign to drop our packs for the days Sea Shuttle. Then went to a tidal crossing as the sun came up. Oscar thought it was a bit stinky.&lt;/p&gt;
&lt;p&gt;Walked through Torrent Bay, which has a handful of old and new cabins, and up the hill. We decided we&amp;rsquo;d have to come back and stay for a while there at the Bay, it was super nice.&lt;/p&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/abel-tasman-anchorage-to-bark-bay/feature.jpg"/></item><item><title>Abel Tasman: Marahau to Anchorage</title><link>https://blog.willprogramforfood.com/posts/2026/abel-tasman-marahau-to-anchorage/</link><pubDate>Sun, 05 Apr 2026 15:06:04 +1200</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/abel-tasman-marahau-to-anchorage/</guid><description>&lt;p&gt;We started the &lt;a href="https://www.doc.govt.nz/parks-and-recreation/places-to-go/nelson-tasman/places/abel-tasman-national-park/things-to-do/tracks/abel-tasman-coast-track/" target="_blank" rel="noreferrer"&gt;Abel Tasman Great Walk&lt;/a&gt; from Marahau after staying the night in Motueka. We planned on taking a water taxi back from the end to Kaiteriteri, so we got a lift with the boat folks. We also had them carry our bags to the first hut so we could go with just day packs.&lt;/p&gt;
&lt;p&gt;Oscar shot off on the first part of the tramp through the marsh and into the forest. We stopped a few times to play in the sand and eat. We picked our packs up from a DOC sign and schlepped them to the hut.&lt;/p&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/abel-tasman-marahau-to-anchorage/feature.jpg"/></item><item><title>Mt. Richmond</title><link>https://blog.willprogramforfood.com/posts/2026/mt-richmond/</link><pubDate>Sat, 28 Feb 2026 21:48:05 +1300</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/mt-richmond/</guid><description>&lt;p&gt;Hike up &lt;a href="https://www.doc.govt.nz/parks-and-recreation/places-to-go/marlborough/places/mount-richmond-forest-park/things-to-do/tracks/mt-richmond-routes/" target="_blank" rel="noreferrer"&gt;Mt. Richmond&lt;/a&gt;. Started off nice with switchbacks and smooth trails. Ended on horrible scree and boulders. I might&amp;rsquo;ve had to sit on a few rocks on way down to rest my knees.&lt;/p&gt;














&lt;div id="gpxmap-mt-richmond-shell" style="margin: 1.5rem 0;"&gt;

 
 &lt;div id="gpxmap-mt-richmond-mini" style="
 position: relative; cursor: pointer; border-radius: 8px; overflow: hidden;
 height: 180px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 " onclick="gpxmaps['gpxmap-mt-richmond'].expand()"&gt;
 &lt;div id="gpxmap-mt-richmond-minimap" style="width:100%; height:100%;"&gt;&lt;/div&gt;
 
 &lt;div style="
 position:absolute; bottom:0; left:0; right:0;
 background: linear-gradient(transparent, rgba(0,0,0,0.55));
 padding: 20px 12px 10px;
 display:flex; justify-content:space-between; align-items:flex-end;
 pointer-events:none;
 "&gt;
 &lt;span id="gpxmap-mt-richmond-mini-stats" style="color:#fff; font-size:0.78rem; font-family:monospace; opacity:0.9;"&gt;&lt;/span&gt;
 &lt;span style="color:#fff; font-size:0.78rem; opacity:0.8;"&gt;tap to explore ↗&lt;/span&gt;
 &lt;/div&gt;
 &lt;/div&gt;

 
 &lt;div id="gpxmap-mt-richmond-full" style="display:none;"&gt;

&lt;div class="gpxmap-wrap" style="position:relative;"&gt;
 &lt;div id="gpxmap-mt-richmond" style="width:100%; height:500px; border-radius:8px; overflow:hidden;"&gt;&lt;/div&gt;

 
 &lt;div id="gpxmap-mt-richmond-stats" style="
 display:flex; gap:1.5rem; flex-wrap:wrap; align-items:center;
 padding: 0.6rem 1rem;
 background: var(--bg-color, #1a1a1a);
 color: var(--text-color, #e0e0e0);
 font-size: 0.82rem;
 border-radius: 0 0 8px 8px;
 font-family: monospace;
 "&gt;
 &lt;span id="gpxmap-mt-richmond-stats-text"&gt;⟳ loading…&lt;/span&gt;
 &lt;button id="gpxmap-mt-richmond-unitbtn" onclick="gpxmaps['gpxmap-mt-richmond'].toggleUnits()"
 style="margin-left:auto; padding:2px 8px; font-size:0.75rem; border-radius:4px;
 border:1px solid rgba(255,255,255,0.3); cursor:pointer;
 background:transparent; color:inherit; font-family:monospace;"&gt;
 km / mi
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; right:10px; z-index:10; display:flex; flex-direction:column; gap:6px; align-items:flex-end;"&gt;
 &lt;div style="display:flex; gap:6px; align-items:center;"&gt;
 &lt;select id="gpxmap-mt-richmond-speed" title="Flythrough speed"
 style="padding:3px 5px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 &lt;option value="0.5"&gt;0.5×&lt;/option&gt;
 &lt;option value="1" selected&gt;1×&lt;/option&gt;
 &lt;option value="2"&gt;2×&lt;/option&gt;
 &lt;option value="4"&gt;4×&lt;/option&gt;
 &lt;/select&gt;
 &lt;button id="gpxmap-mt-richmond-playbtn" onclick="gpxmaps['gpxmap-mt-richmond'].togglePlay()"
 style="padding:4px 10px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 ▶ Fly
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-richmond'].setStyle('outdoor')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Topo
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-richmond'].setStyle('satellite')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Satellite
 &lt;/button&gt;
 &lt;button id="gpxmap-mt-richmond-3dbtn" onclick="gpxmaps['gpxmap-mt-richmond'].toggleTerrain()"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 3D ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;button id="gpxmap-mt-richmond-sizebtn" onclick="gpxmaps['gpxmap-mt-richmond'].cycleSize()"
 style="padding:2px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:rgba(255,255,255,0.9); opacity:0.9; font-family:monospace;"&gt;
 ↙ SM
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; left:10px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:4px;"&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-richmond'].rotateby(-20)" title="Rotate left"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↺
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-richmond'].resetNorth()" title="Reset north"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:bold;line-height:1;"&gt;
 N
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-richmond'].rotateby(20)" title="Rotate right"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↻
 &lt;/button&gt;
 &lt;/div&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-richmond'].tiltby(-15)" title="Tilt flat"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 🔭
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-mt-richmond'].tiltby(15)" title="Tilt up"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;/div&gt;


&lt;canvas id="gpxmap-mt-richmond-elev" height="80"
 style="width:100%; margin-top:-8px; border-radius:0 0 8px 8px; background:var(--bg-color, #111);"&gt;&lt;/canvas&gt;


&lt;div style="text-align:center; margin: 0.5rem 0 1.5rem; opacity:0.35; line-height:1;"&gt;
 &lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40" width="100%" height="40" preserveAspectRatio="none"&gt;
 &lt;path d="M0 40 L0 28 L30 12 L55 24 L90 4 L120 20 L150 8 L175 22 L200 2 L225 18 L255 6 L285 22 L310 10 L340 26 L370 8 L400 20 L400 40 Z"
 fill="currentColor"/&gt;
 &lt;/svg&gt;
&lt;/div&gt;

 &lt;/div&gt;
&lt;/div&gt;



 
 &lt;link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"&gt;
 &lt;script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"&gt;&lt;/script&gt;
 &lt;script&gt;
 window.gpxmaps = {};
 &lt;/script&gt;


&lt;script&gt;
(function() {
 const MAP_ID = "gpxmap-mt-richmond";
 const MAPTILER = "DSdF85giIH5Jkcc46q7r ";
 const BASE_STYLE = "outdoor-v2";
 const EXAGGERATION = 1.5 ;
 const GPX_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/mt-richmond\/track.gpx";
 const PHOTOS_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/mt-richmond\/photos.json";

 
 function parseGPX(xmlText) {
 const parser = new DOMParser();
 const doc = parser.parseFromString(xmlText, "application/xml");
 const ns = "http://www.topografix.com/GPX/1/1";

 const pts = Array.from(doc.getElementsByTagNameNS(ns, "trkpt")).map(pt =&gt; ({
 lon: parseFloat(pt.getAttribute("lon")),
 lat: parseFloat(pt.getAttribute("lat")),
 ele: parseFloat(pt.getElementsByTagNameNS(ns, "ele")[0]?.textContent ?? "0"),
 }));

 if (!pts.length) throw new Error("No trackpoints found in GPX");

 
 let dist = 0, eleGain = 0, eleLoss = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 dist += Math.sqrt(dx*dx + dy*dy);
 const dz = b.ele - a.ele;
 if (dz &gt; 0) eleGain += dz; else eleLoss += -dz;
 }

 const eles = pts.map(p =&gt; p.ele);
 const coord = pts.map(p =&gt; [p.lon, p.lat]);
 const center = coord[Math.floor(coord.length / 2)];

 
 let cumDist = 0;
 pts[0]._distFromStart = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 cumDist += Math.sqrt(dx*dx + dy*dy);
 pts[i]._distFromStart = cumDist;
 }

 return { pts, coord, center, dist, eleGain, eleLoss,
 eleMin: Math.min(...eles), eleMax: Math.max(...eles), eles };
 }

 function drawProfile(canvasId, track, onScrub) {
 const { eles, eleMin, eleMax, pts } = track;
 const canvas = document.getElementById(canvasId);
 if (!canvas) return;
 const ctx = canvas.getContext("2d");
 const DPR = window.devicePixelRatio || 1;
 const pad = 4;
 const range = eleMax - eleMin || 1;

 let currentAnimIdx = null; 

 function resize() {
 canvas.width = canvas.offsetWidth * DPR;
 canvas.height = canvas.offsetHeight * DPR;
 }
 resize();

 function yForEle(e) {
 const H = canvas.height;
 return pad + (1 - (e - eleMin) / range) * (H - pad * 2);
 }

 function draw(scrubX) {
 const W = canvas.width, H = canvas.height;
 ctx.clearRect(0, 0, W, H);

 
 if (currentAnimIdx !== null &amp;&amp; currentAnimIdx &gt; 0) {
 const progX = (currentAnimIdx / (eles.length - 1)) * W;

 
 const progGrad = ctx.createLinearGradient(0, 0, 0, H);
 progGrad.addColorStop(0, "rgba(224,92,42,0.55)");
 progGrad.addColorStop(1, "rgba(224,92,42,0.05)");
 ctx.fillStyle = progGrad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.lineTo(progX, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(224,92,42,0.9)";
 ctx.lineWidth = 2.5 * DPR;
 ctx.beginPath();
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(eles[i])) : ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.stroke();

 
 ctx.strokeStyle = "rgba(224,92,42,0.8)";
 ctx.lineWidth = 1.5 * DPR;
 ctx.setLineDash([3 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(progX, 0);
 ctx.lineTo(progX, H);
 ctx.stroke();
 ctx.setLineDash([]);
 }

 
 const grad = ctx.createLinearGradient(0, 0, 0, H);
 grad.addColorStop(0, "rgba(99,179,130,0.4)");
 grad.addColorStop(1, "rgba(99,179,130,0.02)");
 ctx.fillStyle = grad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(e));
 });
 ctx.lineTo(W, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(99,179,130,0.7)";
 ctx.lineWidth = 2 * DPR;
 ctx.beginPath();
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(e)) : ctx.lineTo(x, yForEle(e));
 });
 ctx.stroke();

 if (scrubX == null) return;

 
 ctx.strokeStyle = "rgba(255,255,255,0.7)";
 ctx.lineWidth = 1 * DPR;
 ctx.setLineDash([4 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(scrubX, 0);
 ctx.lineTo(scrubX, H);
 ctx.stroke();
 ctx.setLineDash([]);

 
 const idx = Math.round((scrubX / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 const cy = yForEle(eles[safeIdx]);
 const ts = 7 * DPR;
 ctx.fillStyle = "#fff";
 ctx.beginPath();
 ctx.moveTo(scrubX, cy - ts);
 ctx.lineTo(scrubX + ts, cy + ts * 0.6);
 ctx.lineTo(scrubX - ts, cy + ts * 0.6);
 ctx.closePath();
 ctx.fill();

 
 const distKm = (pts[safeIdx]._distFromStart / 1000).toFixed(1);
 const label = `${Math.round(eles[safeIdx])} m · ${distKm} km`;
 const fsize = 11 * DPR;
 ctx.font = `${fsize}px monospace`;
 const tw = ctx.measureText(label).width;
 const tx = Math.min(scrubX + 8 * DPR, W - tw - 4 * DPR);
 const ty = Math.max(fsize + 4 * DPR, cy - 8 * DPR);
 ctx.fillStyle = "rgba(0,0,0,0.6)";
 ctx.fillRect(tx - 3 * DPR, ty - fsize, tw + 6 * DPR, fsize + 4 * DPR);
 ctx.fillStyle = "#fff";
 ctx.fillText(label, tx, ty);
 }

 
 draw(null);

 
 function handleMove(clientX) {
 const rect = canvas.getBoundingClientRect();
 const W = canvas.width;
 const x = Math.max(0, Math.min(W, (clientX - rect.left) * DPR));
 const idx = Math.round((x / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 draw(x);
 onScrub(safeIdx);
 }

 function handleLeave() {
 draw(null);
 onScrub(null);
 }

 canvas.addEventListener("mousemove", e =&gt; handleMove(e.clientX));
 canvas.addEventListener("mouseleave", handleLeave);
 canvas.addEventListener("click", e =&gt; {
 const rect = canvas.getBoundingClientRect();
 const x = Math.max(0, Math.min(canvas.width, (e.clientX - rect.left) * DPR));
 const idx = Math.round((x / canvas.width) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 if (window.gpxmaps &amp;&amp; window.gpxmaps[MAP_ID]) {
 window.gpxmaps[MAP_ID].seekTo(safeIdx);
 }
 });
 canvas.addEventListener("touchmove", e =&gt; { e.preventDefault(); handleMove(e.touches[0].clientX); }, { passive: false });
 canvas.addEventListener("touchend", handleLeave);
 canvas.style.cursor = "crosshair";

 
 return {
 setAnimIdx(idx) {
 currentAnimIdx = idx;
 draw(null);
 },
 clearAnim() {
 currentAnimIdx = null;
 draw(null);
 },
 };
 }

 
 let useMetric = true;

 function updateStats(id, dist, eleGain, eleLoss, eleMin, eleMax) {
 const el = document.getElementById(id + "-stats-text");
 if (!el) return;
 const fmt = n =&gt; Math.round(n).toLocaleString();
 if (useMetric) {
 const km = (dist / 1000).toFixed(1);
 el.innerHTML = `
 &lt;span&gt;📍 ${km} km&lt;/span&gt;
 &lt;span&gt;⬆ ${fmt(eleGain)} m gain&lt;/span&gt;
 &lt;span&gt;⬇ ${fmt(eleLoss)} m loss&lt;/span&gt;
 &lt;span&gt;🏔 ${fmt(eleMax)} m high · ${fmt(eleMin)} m low&lt;/span&gt;
 `;
 } else {
 const mi = (dist / 1609.34).toFixed(1);
 const toFt = m =&gt; Math.round(m * 3.28084).toLocaleString();
 el.innerHTML = `
 &lt;span&gt;📍 ${mi} mi&lt;/span&gt;
 &lt;span&gt;⬆ ${toFt(eleGain)} ft gain&lt;/span&gt;
 &lt;span&gt;⬇ ${toFt(eleLoss)} ft loss&lt;/span&gt;
 &lt;span&gt;🏔 ${toFt(eleMax)} ft high · ${toFt(eleMin)} ft low&lt;/span&gt;
 `;
 }
 }

 
 function styleUrl(name) {
 const key = MAPTILER;
 const map = {
 outdoor: `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`,
 satellite: `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
 topo: `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
 };
 return map[name] || map.outdoor;
 }

 function terrainSource(key) {
 return {
 type: "raster-dem",
 url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${key}`,
 tileSize: 256,
 };
 }

 
 async function init() {
 
 const [gpxRes, photosRes] = await Promise.all([
 fetch(GPX_URL),
 fetch(PHOTOS_URL).catch(() =&gt; null),
 ]);

 if (!gpxRes.ok) {
 document.getElementById(MAP_ID + "-stats-text").textContent = "GPX file not found.";
 return;
 }

 const gpxText = await gpxRes.text();
 const track = parseGPX(gpxText);

 let photos = [];
 if (photosRes &amp;&amp; photosRes.ok) {
 photos = await photosRes.json();
 }

 
 const trackStats = { dist: track.dist, eleGain: track.eleGain, eleLoss: track.eleLoss,
 eleMin: track.eleMin, eleMax: track.eleMax };

 updateStats(MAP_ID, track.dist, track.eleGain, track.eleLoss, track.eleMin, track.eleMax);

 
 const miniStats = document.getElementById(MAP_ID + "-mini-stats");
 if (miniStats) {
 const km = (track.dist / 1000).toFixed(1);
 miniStats.textContent = `📍 ${km} km · ⬆ ${Math.round(track.eleGain)} m`;
 }

 
 const lons = track.coord.map(c =&gt; c[0]);
 const lats = track.coord.map(c =&gt; c[1]);
 const bbox = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];

 
 const miniMap = new maplibregl.Map({
 container: MAP_ID + "-minimap",
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 20, maxZoom: 13 },
 interactive: false,
 attributionControl: false,
 });

 miniMap.on("load", () =&gt; {
 miniMap.addSource("mini-track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 miniMap.addLayer({
 id: "mini-track-line", type: "line", source: "mini-track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 2.5, "line-opacity": 0.9 },
 });
 });

 
 let expanded = false;
 let fullMapInited = false;

 function expandMap() {
 if (expanded) return;
 expanded = true;
 document.getElementById(MAP_ID + "-mini").style.display = "none";
 document.getElementById(MAP_ID + "-full").style.display = "block";
 if (!fullMapInited) {
 fullMapInited = true;
 initFullMap();
 } else {
 map.resize();
 }
 }

 function collapseMap() {
 if (!expanded) return;
 expanded = false;
 document.getElementById(MAP_ID + "-full").style.display = "none";
 document.getElementById(MAP_ID + "-mini").style.display = "block";
 miniMap.resize();
 }

 
 let map;

 function initFullMap() {
 map = new maplibregl.Map({
 container: MAP_ID,
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 50, maxZoom: 15 },
 });

 let terrainOn = false;

 function addTerrain() {
 if (!map.getSource("terrain-src")) {
 map.addSource("terrain-src", terrainSource(MAPTILER));
 }
 map.setTerrain({ source: "terrain-src", exaggeration: EXAGGERATION });
 terrainOn = true;
 }

 
 let onScrub = (idx) =&gt; {};
 let segFeatures = []; 
 let profileCtrl = null; 

 map.on("load", () =&gt; {
 
 addTerrain();

 
 const scrubEl = document.createElement("div");
 scrubEl.style.cssText = `
 width: 0; height: 0;
 border-left: 7px solid transparent;
 border-right: 7px solid transparent;
 border-bottom: 13px solid #fff;
 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6));
 display: none;
 `;
 const scrubMarker = new maplibregl.Marker({ element: scrubEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);

 onScrub = function(idx) {
 if (idx === null) {
 scrubEl.style.display = "none";
 } else {
 scrubEl.style.display = "block";
 scrubMarker.setLngLat(track.coord[idx]);
 }
 };

 profileCtrl = drawProfile(MAP_ID + "-elev", track, onScrub);

 
 
 function gradeColor(pct) {
 const g = Math.abs(pct);
 if (g &lt; 8) return "#4caf50"; 
 if (g &lt; 15) return "#ffeb3b"; 
 if (g &lt; 25) return "#ff9800"; 
 return "#f44336"; 
 }

 
 segFeatures = [];
 for (let i = 1; i &lt; track.pts.length; i++) {
 const a = track.pts[i - 1], b = track.pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 const horiz = Math.sqrt(dx * dx + dy * dy);
 const grade = horiz &gt; 0 ? ((b.ele - a.ele) / horiz) * 100 : 0;
 segFeatures.push({
 type: "Feature",
 properties: { color: gradeColor(grade) },
 geometry: { type: "LineString", coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
 });
 }

 map.addSource("track", {
 type: "geojson",
 data: {
 type: "Feature",
 geometry: { type: "LineString", coordinates: track.coord },
 },
 });
 map.addLayer({
 id: "track-line",
 type: "line",
 source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": "#e05c2a",
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 3,
 9, 5,
 12, 4,
 15, 3,
 ],
 "line-opacity": 0.5,
 },
 });

 
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "FeatureCollection", features: [] },
 });
 map.addLayer({
 id: "track-progress-line",
 type: "line",
 source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": ["get", "color"],
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 4,
 9, 7,
 12, 6,
 15, 5,
 ],
 "line-opacity": 1.0,
 },
 });

 
 new maplibregl.Marker({ color: "#2ecc71" })
 .setLngLat(track.coord[0])
 .setPopup(new maplibregl.Popup().setText("Start"))
 .addTo(map);
 new maplibregl.Marker({ color: "#e74c3c" })
 .setLngLat(track.coord[track.coord.length - 1])
 .setPopup(new maplibregl.Popup().setText("End"))
 .addTo(map);

 
 const baseUrl = window.location.href.replace(/\/$/, "");
 photos.forEach(p =&gt; {
 const el = document.createElement("div");
 el.style.cssText = `
 width:28px; height:28px; border-radius:50%;
 background:#fff url('${baseUrl}/gallery/${p.file}') center/cover;
 border:2px solid #fff; box-shadow:0 1px 4px rgba(0,0,0,.4);
 cursor:pointer;
 `;

 const popup = new maplibregl.Popup({ offset: 18 }).setHTML(`
 &lt;a href="${baseUrl}/gallery/${p.file}" target="_blank"&gt;
 &lt;img src="${baseUrl}/gallery/${p.file}"
 style="max-width:200px; max-height:150px; border-radius:4px; display:block;"&gt;
 &lt;/a&gt;
 ${p.alt != null ? `&lt;div style="font-size:0.75rem; text-align:center; margin-top:4px;"&gt;${Math.round(p.alt)} m&lt;/div&gt;` : ""}
 `);

 new maplibregl.Marker({ element: el })
 .setLngLat([p.lon, p.lat])
 .setPopup(popup)
 .addTo(map);
 });

 
 flyEl.style.display = "none";
 flyMarker = new maplibregl.Marker({ element: flyEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);
 });

 
 let animFrame = null;
 let animPlaying = false;
 let lastTime = null;

 let flyMarker = null; 

 
 const flyEl = document.createElement("div");
 flyEl.style.cssText = "display:none; width:32px; height:40px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));";
 flyEl.innerHTML = `&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 40" width="32" height="40"&gt;
 &lt;path d="M16 2 C8 2 2 8 2 16 C2 26 16 38 16 38 C16 38 30 26 30 16 C30 8 24 2 16 2 Z"
 fill="#222" stroke="#fff" stroke-width="1.5"/&gt;
 &lt;circle cx="16" cy="16" r="5" fill="#fff"/&gt;
 &lt;/svg&gt;`;

 
 let animPos = 0; 
 const BASE_PPS = track.coord.length / 60; 

 function interpCoord(pos) {
 const i = Math.floor(pos);
 const t = pos - i;
 const a = track.coord[Math.min(i, track.coord.length - 1)];
 const b = track.coord[Math.min(i + 1, track.coord.length - 1)];
 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
 }

 function updateProgressLine(pos) {
 const idx = Math.min(Math.floor(pos), track.pts.length - 2);
 const tip = interpCoord(pos);
 const src = map.getSource("track-progress");
 if (!src) return;
 
 const features = segFeatures.slice(0, idx);
 
 if (idx &gt;= 0 &amp;&amp; idx &lt; segFeatures.length) {
 const seg = segFeatures[idx];
 features.push({
 ...seg,
 geometry: { type: "LineString", coordinates: [seg.geometry.coordinates[0], tip] },
 });
 }
 src.setData({ type: "FeatureCollection", features });
 }

 function animStep(ts) {
 if (!animPlaying) return;
 if (!lastTime) lastTime = ts;
 const dt = (ts - lastTime) / 1000;
 lastTime = ts;

 const speedEl = document.getElementById(MAP_ID + "-speed");
 const speed = speedEl ? parseFloat(speedEl.value) : 1;
 animPos = Math.min(animPos + BASE_PPS * speed * dt, track.coord.length - 1);

 const pos = interpCoord(animPos);
 if (flyMarker) {
 flyMarker.setLngLat(pos);
 flyEl.style.display = "block";
 }

 updateProgressLine(animPos);
 onScrub(Math.round(animPos));
 if (profileCtrl) profileCtrl.setAnimIdx(Math.round(animPos));

 if (animPos &gt;= track.coord.length - 1) {
 stopFly(true);
 return;
 }

 animFrame = requestAnimationFrame(animStep);
 }

 function stopFly(finished) {
 animPlaying = false;
 lastTime = null;
 cancelAnimationFrame(animFrame);
 if (flyMarker) flyEl.style.display = "none";
 onScrub(null);
 if (profileCtrl) profileCtrl.clearAnim();
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (btn) { btn.textContent = "▶ Fly"; btn.style.background = "#fff"; btn.style.color = ""; }
 if (finished) {
 
 updateProgressLine(track.coord.length - 1);
 }
 }

 
 window.gpxmaps[MAP_ID] = {
 setStyle(name) {
 map.setStyle(styleUrl(name));
 map.once("styledata", () =&gt; {
 addTerrain();
 if (!map.getSource("track")) {
 map.addSource("track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 map.addLayer({
 id: "track-line", type: "line", source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.25 },
 });
 }
 if (!map.getSource("track-progress")) {
 const done = track.coord.slice(0, Math.floor(animPos) + 1);
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: done } },
 });
 map.addLayer({
 id: "track-progress-line", type: "line", source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.9 },
 });
 }
 });
 },
 toggleTerrain() {
 const btn = document.getElementById(MAP_ID + "-3dbtn");
 if (terrainOn) {
 map.setTerrain(null);
 map.easeTo({ pitch: 0, bearing: 0, duration: 600 });
 terrainOn = false;
 if (btn) { btn.style.background = "#fff"; btn.style.color = ""; btn.style.fontWeight = "normal"; }
 } else {
 addTerrain();
 map.easeTo({ pitch: 55, duration: 800 });
 if (btn) { btn.style.background = "#e05c2a"; btn.style.color = "#fff"; btn.style.fontWeight = "bold"; }
 }
 },
 togglePlay() {
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (animPlaying) {
 stopFly(false);
 } else {
 if (!flyMarker) { console.warn("Map not ready yet"); return; }
 
 if (animPos &gt;= track.coord.length - 1) {
 animPos = 0;
 updateProgressLine(0);
 }
 animPlaying = true;
 lastTime = null;
 if (btn) { btn.textContent = "⏸ Pause"; btn.style.background = "#e05c2a"; btn.style.color = "#fff"; }
 animFrame = requestAnimationFrame(animStep);
 }
 },
 seekTo(idx) {
 animPos = idx;
 updateProgressLine(idx);
 onScrub(idx);
 if (profileCtrl) profileCtrl.setAnimIdx(idx);
 if (flyMarker) {
 flyMarker.setLngLat(track.coord[idx]);
 flyEl.style.display = "block";
 }
 },
 toggleUnits() {
 useMetric = !useMetric;
 const btn = document.getElementById(MAP_ID + "-unitbtn");
 if (btn) btn.textContent = useMetric ? "km / mi" : "mi / km";
 updateStats(MAP_ID, trackStats.dist, trackStats.eleGain, trackStats.eleLoss,
 trackStats.eleMin, trackStats.eleMax);
 },
 rotateby(deg) {
 map.easeTo({ bearing: map.getBearing() + deg, duration: 300 });
 },
 tiltby(deg) {
 const next = Math.max(0, Math.min(85, map.getPitch() + deg));
 map.easeTo({ pitch: next, duration: 300 });
 },
 resetNorth() {
 map.easeTo({ bearing: 0, pitch: 0, duration: 500 });
 },
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() {
 const mapEl = document.getElementById(MAP_ID);
 const btn = document.getElementById(MAP_ID + "-sizebtn");
 const sizes = [
 { label: "↙ SM", action: "collapse" },
 { label: "▭ MD", height: "500px" },
 { label: "▬ LG", height: "800px" },
 ];
 
 const fullEl = document.getElementById(MAP_ID + "-full");
 const isCollapsed = fullEl.style.display === "none";
 let current = isCollapsed ? 0
 : mapEl.style.height === "800px" ? 2 : 1;
 const next = (current + 1) % sizes.length;
 const s = sizes[next];
 if (s.action === "collapse") {
 collapseMap();
 } else {
 if (isCollapsed) expandMap();
 mapEl.style.height = s.height;
 map.resize();
 }
 if (btn) btn.textContent = sizes[(next + 1) % sizes.length].label;
 },
 };
 } 

 
 window.gpxmaps[MAP_ID] = {
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() { if (fullMapInited) window.gpxmaps[MAP_ID].cycleSize(); else expandMap(); },
 
 togglePlay() { if (fullMapInited) window.gpxmaps[MAP_ID].togglePlay(); },
 setStyle(n) { if (fullMapInited) window.gpxmaps[MAP_ID].setStyle(n); },
 toggleTerrain(){ if (fullMapInited) window.gpxmaps[MAP_ID].toggleTerrain(); },
 seekTo(i) { if (fullMapInited) window.gpxmaps[MAP_ID].seekTo(i); },
 toggleUnits() { if (fullMapInited) window.gpxmaps[MAP_ID].toggleUnits(); },
 rotateby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].rotateby(d); },
 tiltby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].tiltby(d); },
 resetNorth() { if (fullMapInited) window.gpxmaps[MAP_ID].resetNorth(); },
 };
 }

 
 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", init);
 } else {
 init();
 }
})();
&lt;/script&gt;


&lt;div class="justified-gallery" id="gallery"&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_01.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_01_hu_65776029889c8f4e.jpg" alt="gallery/mt_richmond_01.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_02.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_02_hu_6ad1d75200d4bc06.jpg" alt="gallery/mt_richmond_02.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_04.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_04_hu_83c90dae14af5f4e.jpg" alt="gallery/mt_richmond_04.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_08.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_08_hu_13e17126b5d77b80.jpg" alt="gallery/mt_richmond_08.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_09.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_09_hu_2a044b7cf6c040fa.jpg" alt="gallery/mt_richmond_09.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_11.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_11_hu_89c4085b867e71c9.jpg" alt="gallery/mt_richmond_11.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_14.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_14_hu_519268e0f28f7f6f.jpg" alt="gallery/mt_richmond_14.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_15.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_15_hu_cf144e8de3feb8bf.jpg" alt="gallery/mt_richmond_15.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_16.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_16_hu_d5a27dc88c0d0d0d.jpg" alt="gallery/mt_richmond_16.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_19.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_19_hu_c20ecbabfb215547.jpg" alt="gallery/mt_richmond_19.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_20.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_20_hu_bc2ae97b73bb9b2c.jpg" alt="gallery/mt_richmond_20.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_21.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_21_hu_7d1411094a024ae6.jpg" alt="gallery/mt_richmond_21.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_22.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_22_hu_76df6595a54de199.jpg" alt="gallery/mt_richmond_22.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_23.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_23_hu_1ed005abdccc330a.jpg" alt="gallery/mt_richmond_23.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_24.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_24_hu_6252d5808decf925.jpg" alt="gallery/mt_richmond_24.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_25.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_25_hu_804f8b948e9b4aa2.jpg" alt="gallery/mt_richmond_25.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_27.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_27_hu_39788e9171b7d3c0.jpg" alt="gallery/mt_richmond_27.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_28.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_28_hu_f9cd56b00efe5ab0.jpg" alt="gallery/mt_richmond_28.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_31.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_31_hu_72cf5ad0d43e3bf1.jpg" alt="gallery/mt_richmond_31.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_32.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/mt-richmond/gallery/mt_richmond_32_hu_801615841651fe68.jpg" alt="gallery/mt_richmond_32.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
&lt;/div&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/mt-richmond/feature.jpg"/></item><item><title>Via Ferrata with JJ</title><link>https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/</link><pubDate>Sun, 15 Feb 2026 14:30:43 +1300</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/</guid><description>&lt;p&gt;Via Ferrata laps with JJ. Twice on the new one. Got hot after easy lap on first course, so bailed to get treats.&lt;/p&gt;














&lt;div id="gpxmap-via-ferrata-with-jj-shell" style="margin: 1.5rem 0;"&gt;

 
 &lt;div id="gpxmap-via-ferrata-with-jj-mini" style="
 position: relative; cursor: pointer; border-radius: 8px; overflow: hidden;
 height: 180px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 " onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].expand()"&gt;
 &lt;div id="gpxmap-via-ferrata-with-jj-minimap" style="width:100%; height:100%;"&gt;&lt;/div&gt;
 
 &lt;div style="
 position:absolute; bottom:0; left:0; right:0;
 background: linear-gradient(transparent, rgba(0,0,0,0.55));
 padding: 20px 12px 10px;
 display:flex; justify-content:space-between; align-items:flex-end;
 pointer-events:none;
 "&gt;
 &lt;span id="gpxmap-via-ferrata-with-jj-mini-stats" style="color:#fff; font-size:0.78rem; font-family:monospace; opacity:0.9;"&gt;&lt;/span&gt;
 &lt;span style="color:#fff; font-size:0.78rem; opacity:0.8;"&gt;tap to explore ↗&lt;/span&gt;
 &lt;/div&gt;
 &lt;/div&gt;

 
 &lt;div id="gpxmap-via-ferrata-with-jj-full" style="display:none;"&gt;

&lt;div class="gpxmap-wrap" style="position:relative;"&gt;
 &lt;div id="gpxmap-via-ferrata-with-jj" style="width:100%; height:500px; border-radius:8px; overflow:hidden;"&gt;&lt;/div&gt;

 
 &lt;div id="gpxmap-via-ferrata-with-jj-stats" style="
 display:flex; gap:1.5rem; flex-wrap:wrap; align-items:center;
 padding: 0.6rem 1rem;
 background: var(--bg-color, #1a1a1a);
 color: var(--text-color, #e0e0e0);
 font-size: 0.82rem;
 border-radius: 0 0 8px 8px;
 font-family: monospace;
 "&gt;
 &lt;span id="gpxmap-via-ferrata-with-jj-stats-text"&gt;⟳ loading…&lt;/span&gt;
 &lt;button id="gpxmap-via-ferrata-with-jj-unitbtn" onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].toggleUnits()"
 style="margin-left:auto; padding:2px 8px; font-size:0.75rem; border-radius:4px;
 border:1px solid rgba(255,255,255,0.3); cursor:pointer;
 background:transparent; color:inherit; font-family:monospace;"&gt;
 km / mi
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; right:10px; z-index:10; display:flex; flex-direction:column; gap:6px; align-items:flex-end;"&gt;
 &lt;div style="display:flex; gap:6px; align-items:center;"&gt;
 &lt;select id="gpxmap-via-ferrata-with-jj-speed" title="Flythrough speed"
 style="padding:3px 5px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 &lt;option value="0.5"&gt;0.5×&lt;/option&gt;
 &lt;option value="1" selected&gt;1×&lt;/option&gt;
 &lt;option value="2"&gt;2×&lt;/option&gt;
 &lt;option value="4"&gt;4×&lt;/option&gt;
 &lt;/select&gt;
 &lt;button id="gpxmap-via-ferrata-with-jj-playbtn" onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].togglePlay()"
 style="padding:4px 10px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 ▶ Fly
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].setStyle('outdoor')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Topo
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].setStyle('satellite')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Satellite
 &lt;/button&gt;
 &lt;button id="gpxmap-via-ferrata-with-jj-3dbtn" onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].toggleTerrain()"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 3D ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;button id="gpxmap-via-ferrata-with-jj-sizebtn" onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].cycleSize()"
 style="padding:2px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:rgba(255,255,255,0.9); opacity:0.9; font-family:monospace;"&gt;
 ↙ SM
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; left:10px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:4px;"&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].rotateby(-20)" title="Rotate left"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↺
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].resetNorth()" title="Reset north"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:bold;line-height:1;"&gt;
 N
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].rotateby(20)" title="Rotate right"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↻
 &lt;/button&gt;
 &lt;/div&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].tiltby(-15)" title="Tilt flat"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 🔭
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-jj'].tiltby(15)" title="Tilt up"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;/div&gt;


&lt;canvas id="gpxmap-via-ferrata-with-jj-elev" height="80"
 style="width:100%; margin-top:-8px; border-radius:0 0 8px 8px; background:var(--bg-color, #111);"&gt;&lt;/canvas&gt;


&lt;div style="text-align:center; margin: 0.5rem 0 1.5rem; opacity:0.35; line-height:1;"&gt;
 &lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40" width="100%" height="40" preserveAspectRatio="none"&gt;
 &lt;path d="M0 40 L0 28 L30 12 L55 24 L90 4 L120 20 L150 8 L175 22 L200 2 L225 18 L255 6 L285 22 L310 10 L340 26 L370 8 L400 20 L400 40 Z"
 fill="currentColor"/&gt;
 &lt;/svg&gt;
&lt;/div&gt;

 &lt;/div&gt;
&lt;/div&gt;



 
 &lt;link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"&gt;
 &lt;script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"&gt;&lt;/script&gt;
 &lt;script&gt;
 window.gpxmaps = {};
 &lt;/script&gt;


&lt;script&gt;
(function() {
 const MAP_ID = "gpxmap-via-ferrata-with-jj";
 const MAPTILER = "DSdF85giIH5Jkcc46q7r ";
 const BASE_STYLE = "outdoor-v2";
 const EXAGGERATION = 1.5 ;
 const GPX_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/via-ferrata-with-jj\/track.gpx";
 const PHOTOS_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/via-ferrata-with-jj\/photos.json";

 
 function parseGPX(xmlText) {
 const parser = new DOMParser();
 const doc = parser.parseFromString(xmlText, "application/xml");
 const ns = "http://www.topografix.com/GPX/1/1";

 const pts = Array.from(doc.getElementsByTagNameNS(ns, "trkpt")).map(pt =&gt; ({
 lon: parseFloat(pt.getAttribute("lon")),
 lat: parseFloat(pt.getAttribute("lat")),
 ele: parseFloat(pt.getElementsByTagNameNS(ns, "ele")[0]?.textContent ?? "0"),
 }));

 if (!pts.length) throw new Error("No trackpoints found in GPX");

 
 let dist = 0, eleGain = 0, eleLoss = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 dist += Math.sqrt(dx*dx + dy*dy);
 const dz = b.ele - a.ele;
 if (dz &gt; 0) eleGain += dz; else eleLoss += -dz;
 }

 const eles = pts.map(p =&gt; p.ele);
 const coord = pts.map(p =&gt; [p.lon, p.lat]);
 const center = coord[Math.floor(coord.length / 2)];

 
 let cumDist = 0;
 pts[0]._distFromStart = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 cumDist += Math.sqrt(dx*dx + dy*dy);
 pts[i]._distFromStart = cumDist;
 }

 return { pts, coord, center, dist, eleGain, eleLoss,
 eleMin: Math.min(...eles), eleMax: Math.max(...eles), eles };
 }

 function drawProfile(canvasId, track, onScrub) {
 const { eles, eleMin, eleMax, pts } = track;
 const canvas = document.getElementById(canvasId);
 if (!canvas) return;
 const ctx = canvas.getContext("2d");
 const DPR = window.devicePixelRatio || 1;
 const pad = 4;
 const range = eleMax - eleMin || 1;

 let currentAnimIdx = null; 

 function resize() {
 canvas.width = canvas.offsetWidth * DPR;
 canvas.height = canvas.offsetHeight * DPR;
 }
 resize();

 function yForEle(e) {
 const H = canvas.height;
 return pad + (1 - (e - eleMin) / range) * (H - pad * 2);
 }

 function draw(scrubX) {
 const W = canvas.width, H = canvas.height;
 ctx.clearRect(0, 0, W, H);

 
 if (currentAnimIdx !== null &amp;&amp; currentAnimIdx &gt; 0) {
 const progX = (currentAnimIdx / (eles.length - 1)) * W;

 
 const progGrad = ctx.createLinearGradient(0, 0, 0, H);
 progGrad.addColorStop(0, "rgba(224,92,42,0.55)");
 progGrad.addColorStop(1, "rgba(224,92,42,0.05)");
 ctx.fillStyle = progGrad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.lineTo(progX, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(224,92,42,0.9)";
 ctx.lineWidth = 2.5 * DPR;
 ctx.beginPath();
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(eles[i])) : ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.stroke();

 
 ctx.strokeStyle = "rgba(224,92,42,0.8)";
 ctx.lineWidth = 1.5 * DPR;
 ctx.setLineDash([3 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(progX, 0);
 ctx.lineTo(progX, H);
 ctx.stroke();
 ctx.setLineDash([]);
 }

 
 const grad = ctx.createLinearGradient(0, 0, 0, H);
 grad.addColorStop(0, "rgba(99,179,130,0.4)");
 grad.addColorStop(1, "rgba(99,179,130,0.02)");
 ctx.fillStyle = grad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(e));
 });
 ctx.lineTo(W, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(99,179,130,0.7)";
 ctx.lineWidth = 2 * DPR;
 ctx.beginPath();
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(e)) : ctx.lineTo(x, yForEle(e));
 });
 ctx.stroke();

 if (scrubX == null) return;

 
 ctx.strokeStyle = "rgba(255,255,255,0.7)";
 ctx.lineWidth = 1 * DPR;
 ctx.setLineDash([4 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(scrubX, 0);
 ctx.lineTo(scrubX, H);
 ctx.stroke();
 ctx.setLineDash([]);

 
 const idx = Math.round((scrubX / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 const cy = yForEle(eles[safeIdx]);
 const ts = 7 * DPR;
 ctx.fillStyle = "#fff";
 ctx.beginPath();
 ctx.moveTo(scrubX, cy - ts);
 ctx.lineTo(scrubX + ts, cy + ts * 0.6);
 ctx.lineTo(scrubX - ts, cy + ts * 0.6);
 ctx.closePath();
 ctx.fill();

 
 const distKm = (pts[safeIdx]._distFromStart / 1000).toFixed(1);
 const label = `${Math.round(eles[safeIdx])} m · ${distKm} km`;
 const fsize = 11 * DPR;
 ctx.font = `${fsize}px monospace`;
 const tw = ctx.measureText(label).width;
 const tx = Math.min(scrubX + 8 * DPR, W - tw - 4 * DPR);
 const ty = Math.max(fsize + 4 * DPR, cy - 8 * DPR);
 ctx.fillStyle = "rgba(0,0,0,0.6)";
 ctx.fillRect(tx - 3 * DPR, ty - fsize, tw + 6 * DPR, fsize + 4 * DPR);
 ctx.fillStyle = "#fff";
 ctx.fillText(label, tx, ty);
 }

 
 draw(null);

 
 function handleMove(clientX) {
 const rect = canvas.getBoundingClientRect();
 const W = canvas.width;
 const x = Math.max(0, Math.min(W, (clientX - rect.left) * DPR));
 const idx = Math.round((x / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 draw(x);
 onScrub(safeIdx);
 }

 function handleLeave() {
 draw(null);
 onScrub(null);
 }

 canvas.addEventListener("mousemove", e =&gt; handleMove(e.clientX));
 canvas.addEventListener("mouseleave", handleLeave);
 canvas.addEventListener("click", e =&gt; {
 const rect = canvas.getBoundingClientRect();
 const x = Math.max(0, Math.min(canvas.width, (e.clientX - rect.left) * DPR));
 const idx = Math.round((x / canvas.width) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 if (window.gpxmaps &amp;&amp; window.gpxmaps[MAP_ID]) {
 window.gpxmaps[MAP_ID].seekTo(safeIdx);
 }
 });
 canvas.addEventListener("touchmove", e =&gt; { e.preventDefault(); handleMove(e.touches[0].clientX); }, { passive: false });
 canvas.addEventListener("touchend", handleLeave);
 canvas.style.cursor = "crosshair";

 
 return {
 setAnimIdx(idx) {
 currentAnimIdx = idx;
 draw(null);
 },
 clearAnim() {
 currentAnimIdx = null;
 draw(null);
 },
 };
 }

 
 let useMetric = true;

 function updateStats(id, dist, eleGain, eleLoss, eleMin, eleMax) {
 const el = document.getElementById(id + "-stats-text");
 if (!el) return;
 const fmt = n =&gt; Math.round(n).toLocaleString();
 if (useMetric) {
 const km = (dist / 1000).toFixed(1);
 el.innerHTML = `
 &lt;span&gt;📍 ${km} km&lt;/span&gt;
 &lt;span&gt;⬆ ${fmt(eleGain)} m gain&lt;/span&gt;
 &lt;span&gt;⬇ ${fmt(eleLoss)} m loss&lt;/span&gt;
 &lt;span&gt;🏔 ${fmt(eleMax)} m high · ${fmt(eleMin)} m low&lt;/span&gt;
 `;
 } else {
 const mi = (dist / 1609.34).toFixed(1);
 const toFt = m =&gt; Math.round(m * 3.28084).toLocaleString();
 el.innerHTML = `
 &lt;span&gt;📍 ${mi} mi&lt;/span&gt;
 &lt;span&gt;⬆ ${toFt(eleGain)} ft gain&lt;/span&gt;
 &lt;span&gt;⬇ ${toFt(eleLoss)} ft loss&lt;/span&gt;
 &lt;span&gt;🏔 ${toFt(eleMax)} ft high · ${toFt(eleMin)} ft low&lt;/span&gt;
 `;
 }
 }

 
 function styleUrl(name) {
 const key = MAPTILER;
 const map = {
 outdoor: `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`,
 satellite: `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
 topo: `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
 };
 return map[name] || map.outdoor;
 }

 function terrainSource(key) {
 return {
 type: "raster-dem",
 url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${key}`,
 tileSize: 256,
 };
 }

 
 async function init() {
 
 const [gpxRes, photosRes] = await Promise.all([
 fetch(GPX_URL),
 fetch(PHOTOS_URL).catch(() =&gt; null),
 ]);

 if (!gpxRes.ok) {
 document.getElementById(MAP_ID + "-stats-text").textContent = "GPX file not found.";
 return;
 }

 const gpxText = await gpxRes.text();
 const track = parseGPX(gpxText);

 let photos = [];
 if (photosRes &amp;&amp; photosRes.ok) {
 photos = await photosRes.json();
 }

 
 const trackStats = { dist: track.dist, eleGain: track.eleGain, eleLoss: track.eleLoss,
 eleMin: track.eleMin, eleMax: track.eleMax };

 updateStats(MAP_ID, track.dist, track.eleGain, track.eleLoss, track.eleMin, track.eleMax);

 
 const miniStats = document.getElementById(MAP_ID + "-mini-stats");
 if (miniStats) {
 const km = (track.dist / 1000).toFixed(1);
 miniStats.textContent = `📍 ${km} km · ⬆ ${Math.round(track.eleGain)} m`;
 }

 
 const lons = track.coord.map(c =&gt; c[0]);
 const lats = track.coord.map(c =&gt; c[1]);
 const bbox = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];

 
 const miniMap = new maplibregl.Map({
 container: MAP_ID + "-minimap",
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 20, maxZoom: 13 },
 interactive: false,
 attributionControl: false,
 });

 miniMap.on("load", () =&gt; {
 miniMap.addSource("mini-track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 miniMap.addLayer({
 id: "mini-track-line", type: "line", source: "mini-track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 2.5, "line-opacity": 0.9 },
 });
 });

 
 let expanded = false;
 let fullMapInited = false;

 function expandMap() {
 if (expanded) return;
 expanded = true;
 document.getElementById(MAP_ID + "-mini").style.display = "none";
 document.getElementById(MAP_ID + "-full").style.display = "block";
 if (!fullMapInited) {
 fullMapInited = true;
 initFullMap();
 } else {
 map.resize();
 }
 }

 function collapseMap() {
 if (!expanded) return;
 expanded = false;
 document.getElementById(MAP_ID + "-full").style.display = "none";
 document.getElementById(MAP_ID + "-mini").style.display = "block";
 miniMap.resize();
 }

 
 let map;

 function initFullMap() {
 map = new maplibregl.Map({
 container: MAP_ID,
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 50, maxZoom: 15 },
 });

 let terrainOn = false;

 function addTerrain() {
 if (!map.getSource("terrain-src")) {
 map.addSource("terrain-src", terrainSource(MAPTILER));
 }
 map.setTerrain({ source: "terrain-src", exaggeration: EXAGGERATION });
 terrainOn = true;
 }

 
 let onScrub = (idx) =&gt; {};
 let segFeatures = []; 
 let profileCtrl = null; 

 map.on("load", () =&gt; {
 
 addTerrain();

 
 const scrubEl = document.createElement("div");
 scrubEl.style.cssText = `
 width: 0; height: 0;
 border-left: 7px solid transparent;
 border-right: 7px solid transparent;
 border-bottom: 13px solid #fff;
 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6));
 display: none;
 `;
 const scrubMarker = new maplibregl.Marker({ element: scrubEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);

 onScrub = function(idx) {
 if (idx === null) {
 scrubEl.style.display = "none";
 } else {
 scrubEl.style.display = "block";
 scrubMarker.setLngLat(track.coord[idx]);
 }
 };

 profileCtrl = drawProfile(MAP_ID + "-elev", track, onScrub);

 
 
 function gradeColor(pct) {
 const g = Math.abs(pct);
 if (g &lt; 8) return "#4caf50"; 
 if (g &lt; 15) return "#ffeb3b"; 
 if (g &lt; 25) return "#ff9800"; 
 return "#f44336"; 
 }

 
 segFeatures = [];
 for (let i = 1; i &lt; track.pts.length; i++) {
 const a = track.pts[i - 1], b = track.pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 const horiz = Math.sqrt(dx * dx + dy * dy);
 const grade = horiz &gt; 0 ? ((b.ele - a.ele) / horiz) * 100 : 0;
 segFeatures.push({
 type: "Feature",
 properties: { color: gradeColor(grade) },
 geometry: { type: "LineString", coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
 });
 }

 map.addSource("track", {
 type: "geojson",
 data: {
 type: "Feature",
 geometry: { type: "LineString", coordinates: track.coord },
 },
 });
 map.addLayer({
 id: "track-line",
 type: "line",
 source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": "#e05c2a",
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 3,
 9, 5,
 12, 4,
 15, 3,
 ],
 "line-opacity": 0.5,
 },
 });

 
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "FeatureCollection", features: [] },
 });
 map.addLayer({
 id: "track-progress-line",
 type: "line",
 source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": ["get", "color"],
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 4,
 9, 7,
 12, 6,
 15, 5,
 ],
 "line-opacity": 1.0,
 },
 });

 
 new maplibregl.Marker({ color: "#2ecc71" })
 .setLngLat(track.coord[0])
 .setPopup(new maplibregl.Popup().setText("Start"))
 .addTo(map);
 new maplibregl.Marker({ color: "#e74c3c" })
 .setLngLat(track.coord[track.coord.length - 1])
 .setPopup(new maplibregl.Popup().setText("End"))
 .addTo(map);

 
 const baseUrl = window.location.href.replace(/\/$/, "");
 photos.forEach(p =&gt; {
 const el = document.createElement("div");
 el.style.cssText = `
 width:28px; height:28px; border-radius:50%;
 background:#fff url('${baseUrl}/gallery/${p.file}') center/cover;
 border:2px solid #fff; box-shadow:0 1px 4px rgba(0,0,0,.4);
 cursor:pointer;
 `;

 const popup = new maplibregl.Popup({ offset: 18 }).setHTML(`
 &lt;a href="${baseUrl}/gallery/${p.file}" target="_blank"&gt;
 &lt;img src="${baseUrl}/gallery/${p.file}"
 style="max-width:200px; max-height:150px; border-radius:4px; display:block;"&gt;
 &lt;/a&gt;
 ${p.alt != null ? `&lt;div style="font-size:0.75rem; text-align:center; margin-top:4px;"&gt;${Math.round(p.alt)} m&lt;/div&gt;` : ""}
 `);

 new maplibregl.Marker({ element: el })
 .setLngLat([p.lon, p.lat])
 .setPopup(popup)
 .addTo(map);
 });

 
 flyEl.style.display = "none";
 flyMarker = new maplibregl.Marker({ element: flyEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);
 });

 
 let animFrame = null;
 let animPlaying = false;
 let lastTime = null;

 let flyMarker = null; 

 
 const flyEl = document.createElement("div");
 flyEl.style.cssText = "display:none; width:32px; height:40px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));";
 flyEl.innerHTML = `&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 40" width="32" height="40"&gt;
 &lt;path d="M16 2 C8 2 2 8 2 16 C2 26 16 38 16 38 C16 38 30 26 30 16 C30 8 24 2 16 2 Z"
 fill="#222" stroke="#fff" stroke-width="1.5"/&gt;
 &lt;circle cx="16" cy="16" r="5" fill="#fff"/&gt;
 &lt;/svg&gt;`;

 
 let animPos = 0; 
 const BASE_PPS = track.coord.length / 60; 

 function interpCoord(pos) {
 const i = Math.floor(pos);
 const t = pos - i;
 const a = track.coord[Math.min(i, track.coord.length - 1)];
 const b = track.coord[Math.min(i + 1, track.coord.length - 1)];
 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
 }

 function updateProgressLine(pos) {
 const idx = Math.min(Math.floor(pos), track.pts.length - 2);
 const tip = interpCoord(pos);
 const src = map.getSource("track-progress");
 if (!src) return;
 
 const features = segFeatures.slice(0, idx);
 
 if (idx &gt;= 0 &amp;&amp; idx &lt; segFeatures.length) {
 const seg = segFeatures[idx];
 features.push({
 ...seg,
 geometry: { type: "LineString", coordinates: [seg.geometry.coordinates[0], tip] },
 });
 }
 src.setData({ type: "FeatureCollection", features });
 }

 function animStep(ts) {
 if (!animPlaying) return;
 if (!lastTime) lastTime = ts;
 const dt = (ts - lastTime) / 1000;
 lastTime = ts;

 const speedEl = document.getElementById(MAP_ID + "-speed");
 const speed = speedEl ? parseFloat(speedEl.value) : 1;
 animPos = Math.min(animPos + BASE_PPS * speed * dt, track.coord.length - 1);

 const pos = interpCoord(animPos);
 if (flyMarker) {
 flyMarker.setLngLat(pos);
 flyEl.style.display = "block";
 }

 updateProgressLine(animPos);
 onScrub(Math.round(animPos));
 if (profileCtrl) profileCtrl.setAnimIdx(Math.round(animPos));

 if (animPos &gt;= track.coord.length - 1) {
 stopFly(true);
 return;
 }

 animFrame = requestAnimationFrame(animStep);
 }

 function stopFly(finished) {
 animPlaying = false;
 lastTime = null;
 cancelAnimationFrame(animFrame);
 if (flyMarker) flyEl.style.display = "none";
 onScrub(null);
 if (profileCtrl) profileCtrl.clearAnim();
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (btn) { btn.textContent = "▶ Fly"; btn.style.background = "#fff"; btn.style.color = ""; }
 if (finished) {
 
 updateProgressLine(track.coord.length - 1);
 }
 }

 
 window.gpxmaps[MAP_ID] = {
 setStyle(name) {
 map.setStyle(styleUrl(name));
 map.once("styledata", () =&gt; {
 addTerrain();
 if (!map.getSource("track")) {
 map.addSource("track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 map.addLayer({
 id: "track-line", type: "line", source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.25 },
 });
 }
 if (!map.getSource("track-progress")) {
 const done = track.coord.slice(0, Math.floor(animPos) + 1);
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: done } },
 });
 map.addLayer({
 id: "track-progress-line", type: "line", source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.9 },
 });
 }
 });
 },
 toggleTerrain() {
 const btn = document.getElementById(MAP_ID + "-3dbtn");
 if (terrainOn) {
 map.setTerrain(null);
 map.easeTo({ pitch: 0, bearing: 0, duration: 600 });
 terrainOn = false;
 if (btn) { btn.style.background = "#fff"; btn.style.color = ""; btn.style.fontWeight = "normal"; }
 } else {
 addTerrain();
 map.easeTo({ pitch: 55, duration: 800 });
 if (btn) { btn.style.background = "#e05c2a"; btn.style.color = "#fff"; btn.style.fontWeight = "bold"; }
 }
 },
 togglePlay() {
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (animPlaying) {
 stopFly(false);
 } else {
 if (!flyMarker) { console.warn("Map not ready yet"); return; }
 
 if (animPos &gt;= track.coord.length - 1) {
 animPos = 0;
 updateProgressLine(0);
 }
 animPlaying = true;
 lastTime = null;
 if (btn) { btn.textContent = "⏸ Pause"; btn.style.background = "#e05c2a"; btn.style.color = "#fff"; }
 animFrame = requestAnimationFrame(animStep);
 }
 },
 seekTo(idx) {
 animPos = idx;
 updateProgressLine(idx);
 onScrub(idx);
 if (profileCtrl) profileCtrl.setAnimIdx(idx);
 if (flyMarker) {
 flyMarker.setLngLat(track.coord[idx]);
 flyEl.style.display = "block";
 }
 },
 toggleUnits() {
 useMetric = !useMetric;
 const btn = document.getElementById(MAP_ID + "-unitbtn");
 if (btn) btn.textContent = useMetric ? "km / mi" : "mi / km";
 updateStats(MAP_ID, trackStats.dist, trackStats.eleGain, trackStats.eleLoss,
 trackStats.eleMin, trackStats.eleMax);
 },
 rotateby(deg) {
 map.easeTo({ bearing: map.getBearing() + deg, duration: 300 });
 },
 tiltby(deg) {
 const next = Math.max(0, Math.min(85, map.getPitch() + deg));
 map.easeTo({ pitch: next, duration: 300 });
 },
 resetNorth() {
 map.easeTo({ bearing: 0, pitch: 0, duration: 500 });
 },
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() {
 const mapEl = document.getElementById(MAP_ID);
 const btn = document.getElementById(MAP_ID + "-sizebtn");
 const sizes = [
 { label: "↙ SM", action: "collapse" },
 { label: "▭ MD", height: "500px" },
 { label: "▬ LG", height: "800px" },
 ];
 
 const fullEl = document.getElementById(MAP_ID + "-full");
 const isCollapsed = fullEl.style.display === "none";
 let current = isCollapsed ? 0
 : mapEl.style.height === "800px" ? 2 : 1;
 const next = (current + 1) % sizes.length;
 const s = sizes[next];
 if (s.action === "collapse") {
 collapseMap();
 } else {
 if (isCollapsed) expandMap();
 mapEl.style.height = s.height;
 map.resize();
 }
 if (btn) btn.textContent = sizes[(next + 1) % sizes.length].label;
 },
 };
 } 

 
 window.gpxmaps[MAP_ID] = {
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() { if (fullMapInited) window.gpxmaps[MAP_ID].cycleSize(); else expandMap(); },
 
 togglePlay() { if (fullMapInited) window.gpxmaps[MAP_ID].togglePlay(); },
 setStyle(n) { if (fullMapInited) window.gpxmaps[MAP_ID].setStyle(n); },
 toggleTerrain(){ if (fullMapInited) window.gpxmaps[MAP_ID].toggleTerrain(); },
 seekTo(i) { if (fullMapInited) window.gpxmaps[MAP_ID].seekTo(i); },
 toggleUnits() { if (fullMapInited) window.gpxmaps[MAP_ID].toggleUnits(); },
 rotateby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].rotateby(d); },
 tiltby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].tiltby(d); },
 resetNorth() { if (fullMapInited) window.gpxmaps[MAP_ID].resetNorth(); },
 };
 }

 
 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", init);
 } else {
 init();
 }
})();
&lt;/script&gt;


&lt;div class="justified-gallery" id="gallery"&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_02.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_02_hu_8753801c7ef9f70d.jpg" alt="gallery/via_ferrata_jj_02.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_03.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_03_hu_6f3ca5f6e4f2260b.jpg" alt="gallery/via_ferrata_jj_03.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_07.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_07_hu_68f4dfc49b0c3104.jpg" alt="gallery/via_ferrata_jj_07.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_08.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_08_hu_92be01b1d4e743c2.jpg" alt="gallery/via_ferrata_jj_08.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_11.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_11_hu_9f063fe97dfa511d.jpg" alt="gallery/via_ferrata_jj_11.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_15.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_15_hu_6831930e63d0c6d5.jpg" alt="gallery/via_ferrata_jj_15.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_21.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_21_hu_39e03d05521d6163.jpg" alt="gallery/via_ferrata_jj_21.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_22.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_22_hu_18434547ce2e827b.jpg" alt="gallery/via_ferrata_jj_22.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_25.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_25_hu_7cecafd383331d8a.jpg" alt="gallery/via_ferrata_jj_25.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_29.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_29_hu_ac6ddf4ec17ebecb.jpg" alt="gallery/via_ferrata_jj_29.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_45.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_45_hu_c958a9cf4e1d9f19.jpg" alt="gallery/via_ferrata_jj_45.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_53.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/gallery/via_ferrata_jj_53_hu_c545860192579a1d.jpg" alt="gallery/via_ferrata_jj_53.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
&lt;/div&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-jj/feature.jpg"/></item><item><title>Beeby's Hut</title><link>https://blog.willprogramforfood.com/posts/2026/beebys-hut/</link><pubDate>Sat, 31 Jan 2026 20:04:07 +1300</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/beebys-hut/</guid><description>&lt;p&gt;Craig and I went up to &lt;a href="https://nelsontrails.co.nz/beebys-knob/" target="_blank" rel="noreferrer"&gt;Beeby&amp;rsquo;s Hut&lt;/a&gt;. We took a slow walk up and got to the hut in 2 hours. It&amp;rsquo;s in a cool location so maybe we can get the kids to go up there.&lt;/p&gt;














&lt;div id="gpxmap-beebys-hut-shell" style="margin: 1.5rem 0;"&gt;

 
 &lt;div id="gpxmap-beebys-hut-mini" style="
 position: relative; cursor: pointer; border-radius: 8px; overflow: hidden;
 height: 180px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 " onclick="gpxmaps['gpxmap-beebys-hut'].expand()"&gt;
 &lt;div id="gpxmap-beebys-hut-minimap" style="width:100%; height:100%;"&gt;&lt;/div&gt;
 
 &lt;div style="
 position:absolute; bottom:0; left:0; right:0;
 background: linear-gradient(transparent, rgba(0,0,0,0.55));
 padding: 20px 12px 10px;
 display:flex; justify-content:space-between; align-items:flex-end;
 pointer-events:none;
 "&gt;
 &lt;span id="gpxmap-beebys-hut-mini-stats" style="color:#fff; font-size:0.78rem; font-family:monospace; opacity:0.9;"&gt;&lt;/span&gt;
 &lt;span style="color:#fff; font-size:0.78rem; opacity:0.8;"&gt;tap to explore ↗&lt;/span&gt;
 &lt;/div&gt;
 &lt;/div&gt;

 
 &lt;div id="gpxmap-beebys-hut-full" style="display:none;"&gt;

&lt;div class="gpxmap-wrap" style="position:relative;"&gt;
 &lt;div id="gpxmap-beebys-hut" style="width:100%; height:500px; border-radius:8px; overflow:hidden;"&gt;&lt;/div&gt;

 
 &lt;div id="gpxmap-beebys-hut-stats" style="
 display:flex; gap:1.5rem; flex-wrap:wrap; align-items:center;
 padding: 0.6rem 1rem;
 background: var(--bg-color, #1a1a1a);
 color: var(--text-color, #e0e0e0);
 font-size: 0.82rem;
 border-radius: 0 0 8px 8px;
 font-family: monospace;
 "&gt;
 &lt;span id="gpxmap-beebys-hut-stats-text"&gt;⟳ loading…&lt;/span&gt;
 &lt;button id="gpxmap-beebys-hut-unitbtn" onclick="gpxmaps['gpxmap-beebys-hut'].toggleUnits()"
 style="margin-left:auto; padding:2px 8px; font-size:0.75rem; border-radius:4px;
 border:1px solid rgba(255,255,255,0.3); cursor:pointer;
 background:transparent; color:inherit; font-family:monospace;"&gt;
 km / mi
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; right:10px; z-index:10; display:flex; flex-direction:column; gap:6px; align-items:flex-end;"&gt;
 &lt;div style="display:flex; gap:6px; align-items:center;"&gt;
 &lt;select id="gpxmap-beebys-hut-speed" title="Flythrough speed"
 style="padding:3px 5px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 &lt;option value="0.5"&gt;0.5×&lt;/option&gt;
 &lt;option value="1" selected&gt;1×&lt;/option&gt;
 &lt;option value="2"&gt;2×&lt;/option&gt;
 &lt;option value="4"&gt;4×&lt;/option&gt;
 &lt;/select&gt;
 &lt;button id="gpxmap-beebys-hut-playbtn" onclick="gpxmaps['gpxmap-beebys-hut'].togglePlay()"
 style="padding:4px 10px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 ▶ Fly
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-beebys-hut'].setStyle('outdoor')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Topo
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-beebys-hut'].setStyle('satellite')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Satellite
 &lt;/button&gt;
 &lt;button id="gpxmap-beebys-hut-3dbtn" onclick="gpxmaps['gpxmap-beebys-hut'].toggleTerrain()"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 3D ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;button id="gpxmap-beebys-hut-sizebtn" onclick="gpxmaps['gpxmap-beebys-hut'].cycleSize()"
 style="padding:2px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:rgba(255,255,255,0.9); opacity:0.9; font-family:monospace;"&gt;
 ↙ SM
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; left:10px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:4px;"&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-beebys-hut'].rotateby(-20)" title="Rotate left"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↺
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-beebys-hut'].resetNorth()" title="Reset north"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:bold;line-height:1;"&gt;
 N
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-beebys-hut'].rotateby(20)" title="Rotate right"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↻
 &lt;/button&gt;
 &lt;/div&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-beebys-hut'].tiltby(-15)" title="Tilt flat"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 🔭
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-beebys-hut'].tiltby(15)" title="Tilt up"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;/div&gt;


&lt;canvas id="gpxmap-beebys-hut-elev" height="80"
 style="width:100%; margin-top:-8px; border-radius:0 0 8px 8px; background:var(--bg-color, #111);"&gt;&lt;/canvas&gt;


&lt;div style="text-align:center; margin: 0.5rem 0 1.5rem; opacity:0.35; line-height:1;"&gt;
 &lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40" width="100%" height="40" preserveAspectRatio="none"&gt;
 &lt;path d="M0 40 L0 28 L30 12 L55 24 L90 4 L120 20 L150 8 L175 22 L200 2 L225 18 L255 6 L285 22 L310 10 L340 26 L370 8 L400 20 L400 40 Z"
 fill="currentColor"/&gt;
 &lt;/svg&gt;
&lt;/div&gt;

 &lt;/div&gt;
&lt;/div&gt;



 
 &lt;link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"&gt;
 &lt;script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"&gt;&lt;/script&gt;
 &lt;script&gt;
 window.gpxmaps = {};
 &lt;/script&gt;


&lt;script&gt;
(function() {
 const MAP_ID = "gpxmap-beebys-hut";
 const MAPTILER = "DSdF85giIH5Jkcc46q7r ";
 const BASE_STYLE = "outdoor-v2";
 const EXAGGERATION = 1.5 ;
 const GPX_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/beebys-hut\/track.gpx";
 const PHOTOS_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/beebys-hut\/photos.json";

 
 function parseGPX(xmlText) {
 const parser = new DOMParser();
 const doc = parser.parseFromString(xmlText, "application/xml");
 const ns = "http://www.topografix.com/GPX/1/1";

 const pts = Array.from(doc.getElementsByTagNameNS(ns, "trkpt")).map(pt =&gt; ({
 lon: parseFloat(pt.getAttribute("lon")),
 lat: parseFloat(pt.getAttribute("lat")),
 ele: parseFloat(pt.getElementsByTagNameNS(ns, "ele")[0]?.textContent ?? "0"),
 }));

 if (!pts.length) throw new Error("No trackpoints found in GPX");

 
 let dist = 0, eleGain = 0, eleLoss = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 dist += Math.sqrt(dx*dx + dy*dy);
 const dz = b.ele - a.ele;
 if (dz &gt; 0) eleGain += dz; else eleLoss += -dz;
 }

 const eles = pts.map(p =&gt; p.ele);
 const coord = pts.map(p =&gt; [p.lon, p.lat]);
 const center = coord[Math.floor(coord.length / 2)];

 
 let cumDist = 0;
 pts[0]._distFromStart = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 cumDist += Math.sqrt(dx*dx + dy*dy);
 pts[i]._distFromStart = cumDist;
 }

 return { pts, coord, center, dist, eleGain, eleLoss,
 eleMin: Math.min(...eles), eleMax: Math.max(...eles), eles };
 }

 function drawProfile(canvasId, track, onScrub) {
 const { eles, eleMin, eleMax, pts } = track;
 const canvas = document.getElementById(canvasId);
 if (!canvas) return;
 const ctx = canvas.getContext("2d");
 const DPR = window.devicePixelRatio || 1;
 const pad = 4;
 const range = eleMax - eleMin || 1;

 let currentAnimIdx = null; 

 function resize() {
 canvas.width = canvas.offsetWidth * DPR;
 canvas.height = canvas.offsetHeight * DPR;
 }
 resize();

 function yForEle(e) {
 const H = canvas.height;
 return pad + (1 - (e - eleMin) / range) * (H - pad * 2);
 }

 function draw(scrubX) {
 const W = canvas.width, H = canvas.height;
 ctx.clearRect(0, 0, W, H);

 
 if (currentAnimIdx !== null &amp;&amp; currentAnimIdx &gt; 0) {
 const progX = (currentAnimIdx / (eles.length - 1)) * W;

 
 const progGrad = ctx.createLinearGradient(0, 0, 0, H);
 progGrad.addColorStop(0, "rgba(224,92,42,0.55)");
 progGrad.addColorStop(1, "rgba(224,92,42,0.05)");
 ctx.fillStyle = progGrad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.lineTo(progX, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(224,92,42,0.9)";
 ctx.lineWidth = 2.5 * DPR;
 ctx.beginPath();
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(eles[i])) : ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.stroke();

 
 ctx.strokeStyle = "rgba(224,92,42,0.8)";
 ctx.lineWidth = 1.5 * DPR;
 ctx.setLineDash([3 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(progX, 0);
 ctx.lineTo(progX, H);
 ctx.stroke();
 ctx.setLineDash([]);
 }

 
 const grad = ctx.createLinearGradient(0, 0, 0, H);
 grad.addColorStop(0, "rgba(99,179,130,0.4)");
 grad.addColorStop(1, "rgba(99,179,130,0.02)");
 ctx.fillStyle = grad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(e));
 });
 ctx.lineTo(W, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(99,179,130,0.7)";
 ctx.lineWidth = 2 * DPR;
 ctx.beginPath();
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(e)) : ctx.lineTo(x, yForEle(e));
 });
 ctx.stroke();

 if (scrubX == null) return;

 
 ctx.strokeStyle = "rgba(255,255,255,0.7)";
 ctx.lineWidth = 1 * DPR;
 ctx.setLineDash([4 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(scrubX, 0);
 ctx.lineTo(scrubX, H);
 ctx.stroke();
 ctx.setLineDash([]);

 
 const idx = Math.round((scrubX / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 const cy = yForEle(eles[safeIdx]);
 const ts = 7 * DPR;
 ctx.fillStyle = "#fff";
 ctx.beginPath();
 ctx.moveTo(scrubX, cy - ts);
 ctx.lineTo(scrubX + ts, cy + ts * 0.6);
 ctx.lineTo(scrubX - ts, cy + ts * 0.6);
 ctx.closePath();
 ctx.fill();

 
 const distKm = (pts[safeIdx]._distFromStart / 1000).toFixed(1);
 const label = `${Math.round(eles[safeIdx])} m · ${distKm} km`;
 const fsize = 11 * DPR;
 ctx.font = `${fsize}px monospace`;
 const tw = ctx.measureText(label).width;
 const tx = Math.min(scrubX + 8 * DPR, W - tw - 4 * DPR);
 const ty = Math.max(fsize + 4 * DPR, cy - 8 * DPR);
 ctx.fillStyle = "rgba(0,0,0,0.6)";
 ctx.fillRect(tx - 3 * DPR, ty - fsize, tw + 6 * DPR, fsize + 4 * DPR);
 ctx.fillStyle = "#fff";
 ctx.fillText(label, tx, ty);
 }

 
 draw(null);

 
 function handleMove(clientX) {
 const rect = canvas.getBoundingClientRect();
 const W = canvas.width;
 const x = Math.max(0, Math.min(W, (clientX - rect.left) * DPR));
 const idx = Math.round((x / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 draw(x);
 onScrub(safeIdx);
 }

 function handleLeave() {
 draw(null);
 onScrub(null);
 }

 canvas.addEventListener("mousemove", e =&gt; handleMove(e.clientX));
 canvas.addEventListener("mouseleave", handleLeave);
 canvas.addEventListener("click", e =&gt; {
 const rect = canvas.getBoundingClientRect();
 const x = Math.max(0, Math.min(canvas.width, (e.clientX - rect.left) * DPR));
 const idx = Math.round((x / canvas.width) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 if (window.gpxmaps &amp;&amp; window.gpxmaps[MAP_ID]) {
 window.gpxmaps[MAP_ID].seekTo(safeIdx);
 }
 });
 canvas.addEventListener("touchmove", e =&gt; { e.preventDefault(); handleMove(e.touches[0].clientX); }, { passive: false });
 canvas.addEventListener("touchend", handleLeave);
 canvas.style.cursor = "crosshair";

 
 return {
 setAnimIdx(idx) {
 currentAnimIdx = idx;
 draw(null);
 },
 clearAnim() {
 currentAnimIdx = null;
 draw(null);
 },
 };
 }

 
 let useMetric = true;

 function updateStats(id, dist, eleGain, eleLoss, eleMin, eleMax) {
 const el = document.getElementById(id + "-stats-text");
 if (!el) return;
 const fmt = n =&gt; Math.round(n).toLocaleString();
 if (useMetric) {
 const km = (dist / 1000).toFixed(1);
 el.innerHTML = `
 &lt;span&gt;📍 ${km} km&lt;/span&gt;
 &lt;span&gt;⬆ ${fmt(eleGain)} m gain&lt;/span&gt;
 &lt;span&gt;⬇ ${fmt(eleLoss)} m loss&lt;/span&gt;
 &lt;span&gt;🏔 ${fmt(eleMax)} m high · ${fmt(eleMin)} m low&lt;/span&gt;
 `;
 } else {
 const mi = (dist / 1609.34).toFixed(1);
 const toFt = m =&gt; Math.round(m * 3.28084).toLocaleString();
 el.innerHTML = `
 &lt;span&gt;📍 ${mi} mi&lt;/span&gt;
 &lt;span&gt;⬆ ${toFt(eleGain)} ft gain&lt;/span&gt;
 &lt;span&gt;⬇ ${toFt(eleLoss)} ft loss&lt;/span&gt;
 &lt;span&gt;🏔 ${toFt(eleMax)} ft high · ${toFt(eleMin)} ft low&lt;/span&gt;
 `;
 }
 }

 
 function styleUrl(name) {
 const key = MAPTILER;
 const map = {
 outdoor: `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`,
 satellite: `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
 topo: `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
 };
 return map[name] || map.outdoor;
 }

 function terrainSource(key) {
 return {
 type: "raster-dem",
 url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${key}`,
 tileSize: 256,
 };
 }

 
 async function init() {
 
 const [gpxRes, photosRes] = await Promise.all([
 fetch(GPX_URL),
 fetch(PHOTOS_URL).catch(() =&gt; null),
 ]);

 if (!gpxRes.ok) {
 document.getElementById(MAP_ID + "-stats-text").textContent = "GPX file not found.";
 return;
 }

 const gpxText = await gpxRes.text();
 const track = parseGPX(gpxText);

 let photos = [];
 if (photosRes &amp;&amp; photosRes.ok) {
 photos = await photosRes.json();
 }

 
 const trackStats = { dist: track.dist, eleGain: track.eleGain, eleLoss: track.eleLoss,
 eleMin: track.eleMin, eleMax: track.eleMax };

 updateStats(MAP_ID, track.dist, track.eleGain, track.eleLoss, track.eleMin, track.eleMax);

 
 const miniStats = document.getElementById(MAP_ID + "-mini-stats");
 if (miniStats) {
 const km = (track.dist / 1000).toFixed(1);
 miniStats.textContent = `📍 ${km} km · ⬆ ${Math.round(track.eleGain)} m`;
 }

 
 const lons = track.coord.map(c =&gt; c[0]);
 const lats = track.coord.map(c =&gt; c[1]);
 const bbox = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];

 
 const miniMap = new maplibregl.Map({
 container: MAP_ID + "-minimap",
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 20, maxZoom: 13 },
 interactive: false,
 attributionControl: false,
 });

 miniMap.on("load", () =&gt; {
 miniMap.addSource("mini-track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 miniMap.addLayer({
 id: "mini-track-line", type: "line", source: "mini-track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 2.5, "line-opacity": 0.9 },
 });
 });

 
 let expanded = false;
 let fullMapInited = false;

 function expandMap() {
 if (expanded) return;
 expanded = true;
 document.getElementById(MAP_ID + "-mini").style.display = "none";
 document.getElementById(MAP_ID + "-full").style.display = "block";
 if (!fullMapInited) {
 fullMapInited = true;
 initFullMap();
 } else {
 map.resize();
 }
 }

 function collapseMap() {
 if (!expanded) return;
 expanded = false;
 document.getElementById(MAP_ID + "-full").style.display = "none";
 document.getElementById(MAP_ID + "-mini").style.display = "block";
 miniMap.resize();
 }

 
 let map;

 function initFullMap() {
 map = new maplibregl.Map({
 container: MAP_ID,
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 50, maxZoom: 15 },
 });

 let terrainOn = false;

 function addTerrain() {
 if (!map.getSource("terrain-src")) {
 map.addSource("terrain-src", terrainSource(MAPTILER));
 }
 map.setTerrain({ source: "terrain-src", exaggeration: EXAGGERATION });
 terrainOn = true;
 }

 
 let onScrub = (idx) =&gt; {};
 let segFeatures = []; 
 let profileCtrl = null; 

 map.on("load", () =&gt; {
 
 addTerrain();

 
 const scrubEl = document.createElement("div");
 scrubEl.style.cssText = `
 width: 0; height: 0;
 border-left: 7px solid transparent;
 border-right: 7px solid transparent;
 border-bottom: 13px solid #fff;
 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6));
 display: none;
 `;
 const scrubMarker = new maplibregl.Marker({ element: scrubEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);

 onScrub = function(idx) {
 if (idx === null) {
 scrubEl.style.display = "none";
 } else {
 scrubEl.style.display = "block";
 scrubMarker.setLngLat(track.coord[idx]);
 }
 };

 profileCtrl = drawProfile(MAP_ID + "-elev", track, onScrub);

 
 
 function gradeColor(pct) {
 const g = Math.abs(pct);
 if (g &lt; 8) return "#4caf50"; 
 if (g &lt; 15) return "#ffeb3b"; 
 if (g &lt; 25) return "#ff9800"; 
 return "#f44336"; 
 }

 
 segFeatures = [];
 for (let i = 1; i &lt; track.pts.length; i++) {
 const a = track.pts[i - 1], b = track.pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 const horiz = Math.sqrt(dx * dx + dy * dy);
 const grade = horiz &gt; 0 ? ((b.ele - a.ele) / horiz) * 100 : 0;
 segFeatures.push({
 type: "Feature",
 properties: { color: gradeColor(grade) },
 geometry: { type: "LineString", coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
 });
 }

 map.addSource("track", {
 type: "geojson",
 data: {
 type: "Feature",
 geometry: { type: "LineString", coordinates: track.coord },
 },
 });
 map.addLayer({
 id: "track-line",
 type: "line",
 source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": "#e05c2a",
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 3,
 9, 5,
 12, 4,
 15, 3,
 ],
 "line-opacity": 0.5,
 },
 });

 
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "FeatureCollection", features: [] },
 });
 map.addLayer({
 id: "track-progress-line",
 type: "line",
 source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": ["get", "color"],
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 4,
 9, 7,
 12, 6,
 15, 5,
 ],
 "line-opacity": 1.0,
 },
 });

 
 new maplibregl.Marker({ color: "#2ecc71" })
 .setLngLat(track.coord[0])
 .setPopup(new maplibregl.Popup().setText("Start"))
 .addTo(map);
 new maplibregl.Marker({ color: "#e74c3c" })
 .setLngLat(track.coord[track.coord.length - 1])
 .setPopup(new maplibregl.Popup().setText("End"))
 .addTo(map);

 
 const baseUrl = window.location.href.replace(/\/$/, "");
 photos.forEach(p =&gt; {
 const el = document.createElement("div");
 el.style.cssText = `
 width:28px; height:28px; border-radius:50%;
 background:#fff url('${baseUrl}/gallery/${p.file}') center/cover;
 border:2px solid #fff; box-shadow:0 1px 4px rgba(0,0,0,.4);
 cursor:pointer;
 `;

 const popup = new maplibregl.Popup({ offset: 18 }).setHTML(`
 &lt;a href="${baseUrl}/gallery/${p.file}" target="_blank"&gt;
 &lt;img src="${baseUrl}/gallery/${p.file}"
 style="max-width:200px; max-height:150px; border-radius:4px; display:block;"&gt;
 &lt;/a&gt;
 ${p.alt != null ? `&lt;div style="font-size:0.75rem; text-align:center; margin-top:4px;"&gt;${Math.round(p.alt)} m&lt;/div&gt;` : ""}
 `);

 new maplibregl.Marker({ element: el })
 .setLngLat([p.lon, p.lat])
 .setPopup(popup)
 .addTo(map);
 });

 
 flyEl.style.display = "none";
 flyMarker = new maplibregl.Marker({ element: flyEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);
 });

 
 let animFrame = null;
 let animPlaying = false;
 let lastTime = null;

 let flyMarker = null; 

 
 const flyEl = document.createElement("div");
 flyEl.style.cssText = "display:none; width:32px; height:40px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));";
 flyEl.innerHTML = `&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 40" width="32" height="40"&gt;
 &lt;path d="M16 2 C8 2 2 8 2 16 C2 26 16 38 16 38 C16 38 30 26 30 16 C30 8 24 2 16 2 Z"
 fill="#222" stroke="#fff" stroke-width="1.5"/&gt;
 &lt;circle cx="16" cy="16" r="5" fill="#fff"/&gt;
 &lt;/svg&gt;`;

 
 let animPos = 0; 
 const BASE_PPS = track.coord.length / 60; 

 function interpCoord(pos) {
 const i = Math.floor(pos);
 const t = pos - i;
 const a = track.coord[Math.min(i, track.coord.length - 1)];
 const b = track.coord[Math.min(i + 1, track.coord.length - 1)];
 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
 }

 function updateProgressLine(pos) {
 const idx = Math.min(Math.floor(pos), track.pts.length - 2);
 const tip = interpCoord(pos);
 const src = map.getSource("track-progress");
 if (!src) return;
 
 const features = segFeatures.slice(0, idx);
 
 if (idx &gt;= 0 &amp;&amp; idx &lt; segFeatures.length) {
 const seg = segFeatures[idx];
 features.push({
 ...seg,
 geometry: { type: "LineString", coordinates: [seg.geometry.coordinates[0], tip] },
 });
 }
 src.setData({ type: "FeatureCollection", features });
 }

 function animStep(ts) {
 if (!animPlaying) return;
 if (!lastTime) lastTime = ts;
 const dt = (ts - lastTime) / 1000;
 lastTime = ts;

 const speedEl = document.getElementById(MAP_ID + "-speed");
 const speed = speedEl ? parseFloat(speedEl.value) : 1;
 animPos = Math.min(animPos + BASE_PPS * speed * dt, track.coord.length - 1);

 const pos = interpCoord(animPos);
 if (flyMarker) {
 flyMarker.setLngLat(pos);
 flyEl.style.display = "block";
 }

 updateProgressLine(animPos);
 onScrub(Math.round(animPos));
 if (profileCtrl) profileCtrl.setAnimIdx(Math.round(animPos));

 if (animPos &gt;= track.coord.length - 1) {
 stopFly(true);
 return;
 }

 animFrame = requestAnimationFrame(animStep);
 }

 function stopFly(finished) {
 animPlaying = false;
 lastTime = null;
 cancelAnimationFrame(animFrame);
 if (flyMarker) flyEl.style.display = "none";
 onScrub(null);
 if (profileCtrl) profileCtrl.clearAnim();
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (btn) { btn.textContent = "▶ Fly"; btn.style.background = "#fff"; btn.style.color = ""; }
 if (finished) {
 
 updateProgressLine(track.coord.length - 1);
 }
 }

 
 window.gpxmaps[MAP_ID] = {
 setStyle(name) {
 map.setStyle(styleUrl(name));
 map.once("styledata", () =&gt; {
 addTerrain();
 if (!map.getSource("track")) {
 map.addSource("track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 map.addLayer({
 id: "track-line", type: "line", source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.25 },
 });
 }
 if (!map.getSource("track-progress")) {
 const done = track.coord.slice(0, Math.floor(animPos) + 1);
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: done } },
 });
 map.addLayer({
 id: "track-progress-line", type: "line", source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.9 },
 });
 }
 });
 },
 toggleTerrain() {
 const btn = document.getElementById(MAP_ID + "-3dbtn");
 if (terrainOn) {
 map.setTerrain(null);
 map.easeTo({ pitch: 0, bearing: 0, duration: 600 });
 terrainOn = false;
 if (btn) { btn.style.background = "#fff"; btn.style.color = ""; btn.style.fontWeight = "normal"; }
 } else {
 addTerrain();
 map.easeTo({ pitch: 55, duration: 800 });
 if (btn) { btn.style.background = "#e05c2a"; btn.style.color = "#fff"; btn.style.fontWeight = "bold"; }
 }
 },
 togglePlay() {
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (animPlaying) {
 stopFly(false);
 } else {
 if (!flyMarker) { console.warn("Map not ready yet"); return; }
 
 if (animPos &gt;= track.coord.length - 1) {
 animPos = 0;
 updateProgressLine(0);
 }
 animPlaying = true;
 lastTime = null;
 if (btn) { btn.textContent = "⏸ Pause"; btn.style.background = "#e05c2a"; btn.style.color = "#fff"; }
 animFrame = requestAnimationFrame(animStep);
 }
 },
 seekTo(idx) {
 animPos = idx;
 updateProgressLine(idx);
 onScrub(idx);
 if (profileCtrl) profileCtrl.setAnimIdx(idx);
 if (flyMarker) {
 flyMarker.setLngLat(track.coord[idx]);
 flyEl.style.display = "block";
 }
 },
 toggleUnits() {
 useMetric = !useMetric;
 const btn = document.getElementById(MAP_ID + "-unitbtn");
 if (btn) btn.textContent = useMetric ? "km / mi" : "mi / km";
 updateStats(MAP_ID, trackStats.dist, trackStats.eleGain, trackStats.eleLoss,
 trackStats.eleMin, trackStats.eleMax);
 },
 rotateby(deg) {
 map.easeTo({ bearing: map.getBearing() + deg, duration: 300 });
 },
 tiltby(deg) {
 const next = Math.max(0, Math.min(85, map.getPitch() + deg));
 map.easeTo({ pitch: next, duration: 300 });
 },
 resetNorth() {
 map.easeTo({ bearing: 0, pitch: 0, duration: 500 });
 },
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() {
 const mapEl = document.getElementById(MAP_ID);
 const btn = document.getElementById(MAP_ID + "-sizebtn");
 const sizes = [
 { label: "↙ SM", action: "collapse" },
 { label: "▭ MD", height: "500px" },
 { label: "▬ LG", height: "800px" },
 ];
 
 const fullEl = document.getElementById(MAP_ID + "-full");
 const isCollapsed = fullEl.style.display === "none";
 let current = isCollapsed ? 0
 : mapEl.style.height === "800px" ? 2 : 1;
 const next = (current + 1) % sizes.length;
 const s = sizes[next];
 if (s.action === "collapse") {
 collapseMap();
 } else {
 if (isCollapsed) expandMap();
 mapEl.style.height = s.height;
 map.resize();
 }
 if (btn) btn.textContent = sizes[(next + 1) % sizes.length].label;
 },
 };
 } 

 
 window.gpxmaps[MAP_ID] = {
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() { if (fullMapInited) window.gpxmaps[MAP_ID].cycleSize(); else expandMap(); },
 
 togglePlay() { if (fullMapInited) window.gpxmaps[MAP_ID].togglePlay(); },
 setStyle(n) { if (fullMapInited) window.gpxmaps[MAP_ID].setStyle(n); },
 toggleTerrain(){ if (fullMapInited) window.gpxmaps[MAP_ID].toggleTerrain(); },
 seekTo(i) { if (fullMapInited) window.gpxmaps[MAP_ID].seekTo(i); },
 toggleUnits() { if (fullMapInited) window.gpxmaps[MAP_ID].toggleUnits(); },
 rotateby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].rotateby(d); },
 tiltby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].tiltby(d); },
 resetNorth() { if (fullMapInited) window.gpxmaps[MAP_ID].resetNorth(); },
 };
 }

 
 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", init);
 } else {
 init();
 }
})();
&lt;/script&gt;


&lt;div class="justified-gallery" id="gallery"&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-08_36_594.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-08_36_594_hu_62c9500d88c5f0cc.jpg" alt="gallery/26_01_31-08_36_594.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-09_47_262.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-09_47_262_hu_3ea505414199648c.jpg" alt="gallery/26_01_31-09_47_262.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-10_01_098.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-10_01_098_hu_be48a41a1de5122c.jpg" alt="gallery/26_01_31-10_01_098.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-10_49_582.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-10_49_582_hu_f747f481d5a9d247.jpg" alt="gallery/26_01_31-10_49_582.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-11_39_114.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-11_39_114_hu_aa2af44ecde41dce.jpg" alt="gallery/26_01_31-11_39_114.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-11_40_440.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-11_40_440_hu_577d23ffd687c014.jpg" alt="gallery/26_01_31-11_40_440.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-11_41_416.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-11_41_416_hu_e550621a02d4b81b.jpg" alt="gallery/26_01_31-11_41_416.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-11_45_630.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/beebys-hut/gallery/26_01_31-11_45_630_hu_7d2a36161c990a4a.jpg" alt="gallery/26_01_31-11_45_630.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
&lt;/div&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/beebys-hut/feature.jpg"/></item><item><title>Via Ferrata Solo</title><link>https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/</link><pubDate>Thu, 29 Jan 2026 20:02:29 +1300</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/</guid><description>&lt;p&gt;I went to Nelson to visit Ngaire a few times and swung by the Via Ferrata on Takaka Hill and ran some laps.&lt;/p&gt;














&lt;div id="gpxmap-via-ferrata-solo-shell" style="margin: 1.5rem 0;"&gt;

 
 &lt;div id="gpxmap-via-ferrata-solo-mini" style="
 position: relative; cursor: pointer; border-radius: 8px; overflow: hidden;
 height: 180px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 " onclick="gpxmaps['gpxmap-via-ferrata-solo'].expand()"&gt;
 &lt;div id="gpxmap-via-ferrata-solo-minimap" style="width:100%; height:100%;"&gt;&lt;/div&gt;
 
 &lt;div style="
 position:absolute; bottom:0; left:0; right:0;
 background: linear-gradient(transparent, rgba(0,0,0,0.55));
 padding: 20px 12px 10px;
 display:flex; justify-content:space-between; align-items:flex-end;
 pointer-events:none;
 "&gt;
 &lt;span id="gpxmap-via-ferrata-solo-mini-stats" style="color:#fff; font-size:0.78rem; font-family:monospace; opacity:0.9;"&gt;&lt;/span&gt;
 &lt;span style="color:#fff; font-size:0.78rem; opacity:0.8;"&gt;tap to explore ↗&lt;/span&gt;
 &lt;/div&gt;
 &lt;/div&gt;

 
 &lt;div id="gpxmap-via-ferrata-solo-full" style="display:none;"&gt;

&lt;div class="gpxmap-wrap" style="position:relative;"&gt;
 &lt;div id="gpxmap-via-ferrata-solo" style="width:100%; height:500px; border-radius:8px; overflow:hidden;"&gt;&lt;/div&gt;

 
 &lt;div id="gpxmap-via-ferrata-solo-stats" style="
 display:flex; gap:1.5rem; flex-wrap:wrap; align-items:center;
 padding: 0.6rem 1rem;
 background: var(--bg-color, #1a1a1a);
 color: var(--text-color, #e0e0e0);
 font-size: 0.82rem;
 border-radius: 0 0 8px 8px;
 font-family: monospace;
 "&gt;
 &lt;span id="gpxmap-via-ferrata-solo-stats-text"&gt;⟳ loading…&lt;/span&gt;
 &lt;button id="gpxmap-via-ferrata-solo-unitbtn" onclick="gpxmaps['gpxmap-via-ferrata-solo'].toggleUnits()"
 style="margin-left:auto; padding:2px 8px; font-size:0.75rem; border-radius:4px;
 border:1px solid rgba(255,255,255,0.3); cursor:pointer;
 background:transparent; color:inherit; font-family:monospace;"&gt;
 km / mi
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; right:10px; z-index:10; display:flex; flex-direction:column; gap:6px; align-items:flex-end;"&gt;
 &lt;div style="display:flex; gap:6px; align-items:center;"&gt;
 &lt;select id="gpxmap-via-ferrata-solo-speed" title="Flythrough speed"
 style="padding:3px 5px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 &lt;option value="0.5"&gt;0.5×&lt;/option&gt;
 &lt;option value="1" selected&gt;1×&lt;/option&gt;
 &lt;option value="2"&gt;2×&lt;/option&gt;
 &lt;option value="4"&gt;4×&lt;/option&gt;
 &lt;/select&gt;
 &lt;button id="gpxmap-via-ferrata-solo-playbtn" onclick="gpxmaps['gpxmap-via-ferrata-solo'].togglePlay()"
 style="padding:4px 10px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 ▶ Fly
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-solo'].setStyle('outdoor')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Topo
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-solo'].setStyle('satellite')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Satellite
 &lt;/button&gt;
 &lt;button id="gpxmap-via-ferrata-solo-3dbtn" onclick="gpxmaps['gpxmap-via-ferrata-solo'].toggleTerrain()"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 3D ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;button id="gpxmap-via-ferrata-solo-sizebtn" onclick="gpxmaps['gpxmap-via-ferrata-solo'].cycleSize()"
 style="padding:2px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:rgba(255,255,255,0.9); opacity:0.9; font-family:monospace;"&gt;
 ↙ SM
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; left:10px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:4px;"&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-solo'].rotateby(-20)" title="Rotate left"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↺
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-solo'].resetNorth()" title="Reset north"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:bold;line-height:1;"&gt;
 N
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-solo'].rotateby(20)" title="Rotate right"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↻
 &lt;/button&gt;
 &lt;/div&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-solo'].tiltby(-15)" title="Tilt flat"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 🔭
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-solo'].tiltby(15)" title="Tilt up"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;/div&gt;


&lt;canvas id="gpxmap-via-ferrata-solo-elev" height="80"
 style="width:100%; margin-top:-8px; border-radius:0 0 8px 8px; background:var(--bg-color, #111);"&gt;&lt;/canvas&gt;


&lt;div style="text-align:center; margin: 0.5rem 0 1.5rem; opacity:0.35; line-height:1;"&gt;
 &lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40" width="100%" height="40" preserveAspectRatio="none"&gt;
 &lt;path d="M0 40 L0 28 L30 12 L55 24 L90 4 L120 20 L150 8 L175 22 L200 2 L225 18 L255 6 L285 22 L310 10 L340 26 L370 8 L400 20 L400 40 Z"
 fill="currentColor"/&gt;
 &lt;/svg&gt;
&lt;/div&gt;

 &lt;/div&gt;
&lt;/div&gt;



 
 &lt;link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"&gt;
 &lt;script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"&gt;&lt;/script&gt;
 &lt;script&gt;
 window.gpxmaps = {};
 &lt;/script&gt;


&lt;script&gt;
(function() {
 const MAP_ID = "gpxmap-via-ferrata-solo";
 const MAPTILER = "DSdF85giIH5Jkcc46q7r ";
 const BASE_STYLE = "outdoor-v2";
 const EXAGGERATION = 1.5 ;
 const GPX_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/via-ferrata-solo\/track.gpx";
 const PHOTOS_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/via-ferrata-solo\/photos.json";

 
 function parseGPX(xmlText) {
 const parser = new DOMParser();
 const doc = parser.parseFromString(xmlText, "application/xml");
 const ns = "http://www.topografix.com/GPX/1/1";

 const pts = Array.from(doc.getElementsByTagNameNS(ns, "trkpt")).map(pt =&gt; ({
 lon: parseFloat(pt.getAttribute("lon")),
 lat: parseFloat(pt.getAttribute("lat")),
 ele: parseFloat(pt.getElementsByTagNameNS(ns, "ele")[0]?.textContent ?? "0"),
 }));

 if (!pts.length) throw new Error("No trackpoints found in GPX");

 
 let dist = 0, eleGain = 0, eleLoss = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 dist += Math.sqrt(dx*dx + dy*dy);
 const dz = b.ele - a.ele;
 if (dz &gt; 0) eleGain += dz; else eleLoss += -dz;
 }

 const eles = pts.map(p =&gt; p.ele);
 const coord = pts.map(p =&gt; [p.lon, p.lat]);
 const center = coord[Math.floor(coord.length / 2)];

 
 let cumDist = 0;
 pts[0]._distFromStart = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 cumDist += Math.sqrt(dx*dx + dy*dy);
 pts[i]._distFromStart = cumDist;
 }

 return { pts, coord, center, dist, eleGain, eleLoss,
 eleMin: Math.min(...eles), eleMax: Math.max(...eles), eles };
 }

 function drawProfile(canvasId, track, onScrub) {
 const { eles, eleMin, eleMax, pts } = track;
 const canvas = document.getElementById(canvasId);
 if (!canvas) return;
 const ctx = canvas.getContext("2d");
 const DPR = window.devicePixelRatio || 1;
 const pad = 4;
 const range = eleMax - eleMin || 1;

 let currentAnimIdx = null; 

 function resize() {
 canvas.width = canvas.offsetWidth * DPR;
 canvas.height = canvas.offsetHeight * DPR;
 }
 resize();

 function yForEle(e) {
 const H = canvas.height;
 return pad + (1 - (e - eleMin) / range) * (H - pad * 2);
 }

 function draw(scrubX) {
 const W = canvas.width, H = canvas.height;
 ctx.clearRect(0, 0, W, H);

 
 if (currentAnimIdx !== null &amp;&amp; currentAnimIdx &gt; 0) {
 const progX = (currentAnimIdx / (eles.length - 1)) * W;

 
 const progGrad = ctx.createLinearGradient(0, 0, 0, H);
 progGrad.addColorStop(0, "rgba(224,92,42,0.55)");
 progGrad.addColorStop(1, "rgba(224,92,42,0.05)");
 ctx.fillStyle = progGrad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.lineTo(progX, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(224,92,42,0.9)";
 ctx.lineWidth = 2.5 * DPR;
 ctx.beginPath();
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(eles[i])) : ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.stroke();

 
 ctx.strokeStyle = "rgba(224,92,42,0.8)";
 ctx.lineWidth = 1.5 * DPR;
 ctx.setLineDash([3 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(progX, 0);
 ctx.lineTo(progX, H);
 ctx.stroke();
 ctx.setLineDash([]);
 }

 
 const grad = ctx.createLinearGradient(0, 0, 0, H);
 grad.addColorStop(0, "rgba(99,179,130,0.4)");
 grad.addColorStop(1, "rgba(99,179,130,0.02)");
 ctx.fillStyle = grad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(e));
 });
 ctx.lineTo(W, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(99,179,130,0.7)";
 ctx.lineWidth = 2 * DPR;
 ctx.beginPath();
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(e)) : ctx.lineTo(x, yForEle(e));
 });
 ctx.stroke();

 if (scrubX == null) return;

 
 ctx.strokeStyle = "rgba(255,255,255,0.7)";
 ctx.lineWidth = 1 * DPR;
 ctx.setLineDash([4 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(scrubX, 0);
 ctx.lineTo(scrubX, H);
 ctx.stroke();
 ctx.setLineDash([]);

 
 const idx = Math.round((scrubX / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 const cy = yForEle(eles[safeIdx]);
 const ts = 7 * DPR;
 ctx.fillStyle = "#fff";
 ctx.beginPath();
 ctx.moveTo(scrubX, cy - ts);
 ctx.lineTo(scrubX + ts, cy + ts * 0.6);
 ctx.lineTo(scrubX - ts, cy + ts * 0.6);
 ctx.closePath();
 ctx.fill();

 
 const distKm = (pts[safeIdx]._distFromStart / 1000).toFixed(1);
 const label = `${Math.round(eles[safeIdx])} m · ${distKm} km`;
 const fsize = 11 * DPR;
 ctx.font = `${fsize}px monospace`;
 const tw = ctx.measureText(label).width;
 const tx = Math.min(scrubX + 8 * DPR, W - tw - 4 * DPR);
 const ty = Math.max(fsize + 4 * DPR, cy - 8 * DPR);
 ctx.fillStyle = "rgba(0,0,0,0.6)";
 ctx.fillRect(tx - 3 * DPR, ty - fsize, tw + 6 * DPR, fsize + 4 * DPR);
 ctx.fillStyle = "#fff";
 ctx.fillText(label, tx, ty);
 }

 
 draw(null);

 
 function handleMove(clientX) {
 const rect = canvas.getBoundingClientRect();
 const W = canvas.width;
 const x = Math.max(0, Math.min(W, (clientX - rect.left) * DPR));
 const idx = Math.round((x / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 draw(x);
 onScrub(safeIdx);
 }

 function handleLeave() {
 draw(null);
 onScrub(null);
 }

 canvas.addEventListener("mousemove", e =&gt; handleMove(e.clientX));
 canvas.addEventListener("mouseleave", handleLeave);
 canvas.addEventListener("click", e =&gt; {
 const rect = canvas.getBoundingClientRect();
 const x = Math.max(0, Math.min(canvas.width, (e.clientX - rect.left) * DPR));
 const idx = Math.round((x / canvas.width) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 if (window.gpxmaps &amp;&amp; window.gpxmaps[MAP_ID]) {
 window.gpxmaps[MAP_ID].seekTo(safeIdx);
 }
 });
 canvas.addEventListener("touchmove", e =&gt; { e.preventDefault(); handleMove(e.touches[0].clientX); }, { passive: false });
 canvas.addEventListener("touchend", handleLeave);
 canvas.style.cursor = "crosshair";

 
 return {
 setAnimIdx(idx) {
 currentAnimIdx = idx;
 draw(null);
 },
 clearAnim() {
 currentAnimIdx = null;
 draw(null);
 },
 };
 }

 
 let useMetric = true;

 function updateStats(id, dist, eleGain, eleLoss, eleMin, eleMax) {
 const el = document.getElementById(id + "-stats-text");
 if (!el) return;
 const fmt = n =&gt; Math.round(n).toLocaleString();
 if (useMetric) {
 const km = (dist / 1000).toFixed(1);
 el.innerHTML = `
 &lt;span&gt;📍 ${km} km&lt;/span&gt;
 &lt;span&gt;⬆ ${fmt(eleGain)} m gain&lt;/span&gt;
 &lt;span&gt;⬇ ${fmt(eleLoss)} m loss&lt;/span&gt;
 &lt;span&gt;🏔 ${fmt(eleMax)} m high · ${fmt(eleMin)} m low&lt;/span&gt;
 `;
 } else {
 const mi = (dist / 1609.34).toFixed(1);
 const toFt = m =&gt; Math.round(m * 3.28084).toLocaleString();
 el.innerHTML = `
 &lt;span&gt;📍 ${mi} mi&lt;/span&gt;
 &lt;span&gt;⬆ ${toFt(eleGain)} ft gain&lt;/span&gt;
 &lt;span&gt;⬇ ${toFt(eleLoss)} ft loss&lt;/span&gt;
 &lt;span&gt;🏔 ${toFt(eleMax)} ft high · ${toFt(eleMin)} ft low&lt;/span&gt;
 `;
 }
 }

 
 function styleUrl(name) {
 const key = MAPTILER;
 const map = {
 outdoor: `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`,
 satellite: `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
 topo: `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
 };
 return map[name] || map.outdoor;
 }

 function terrainSource(key) {
 return {
 type: "raster-dem",
 url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${key}`,
 tileSize: 256,
 };
 }

 
 async function init() {
 
 const [gpxRes, photosRes] = await Promise.all([
 fetch(GPX_URL),
 fetch(PHOTOS_URL).catch(() =&gt; null),
 ]);

 if (!gpxRes.ok) {
 document.getElementById(MAP_ID + "-stats-text").textContent = "GPX file not found.";
 return;
 }

 const gpxText = await gpxRes.text();
 const track = parseGPX(gpxText);

 let photos = [];
 if (photosRes &amp;&amp; photosRes.ok) {
 photos = await photosRes.json();
 }

 
 const trackStats = { dist: track.dist, eleGain: track.eleGain, eleLoss: track.eleLoss,
 eleMin: track.eleMin, eleMax: track.eleMax };

 updateStats(MAP_ID, track.dist, track.eleGain, track.eleLoss, track.eleMin, track.eleMax);

 
 const miniStats = document.getElementById(MAP_ID + "-mini-stats");
 if (miniStats) {
 const km = (track.dist / 1000).toFixed(1);
 miniStats.textContent = `📍 ${km} km · ⬆ ${Math.round(track.eleGain)} m`;
 }

 
 const lons = track.coord.map(c =&gt; c[0]);
 const lats = track.coord.map(c =&gt; c[1]);
 const bbox = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];

 
 const miniMap = new maplibregl.Map({
 container: MAP_ID + "-minimap",
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 20, maxZoom: 13 },
 interactive: false,
 attributionControl: false,
 });

 miniMap.on("load", () =&gt; {
 miniMap.addSource("mini-track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 miniMap.addLayer({
 id: "mini-track-line", type: "line", source: "mini-track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 2.5, "line-opacity": 0.9 },
 });
 });

 
 let expanded = false;
 let fullMapInited = false;

 function expandMap() {
 if (expanded) return;
 expanded = true;
 document.getElementById(MAP_ID + "-mini").style.display = "none";
 document.getElementById(MAP_ID + "-full").style.display = "block";
 if (!fullMapInited) {
 fullMapInited = true;
 initFullMap();
 } else {
 map.resize();
 }
 }

 function collapseMap() {
 if (!expanded) return;
 expanded = false;
 document.getElementById(MAP_ID + "-full").style.display = "none";
 document.getElementById(MAP_ID + "-mini").style.display = "block";
 miniMap.resize();
 }

 
 let map;

 function initFullMap() {
 map = new maplibregl.Map({
 container: MAP_ID,
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 50, maxZoom: 15 },
 });

 let terrainOn = false;

 function addTerrain() {
 if (!map.getSource("terrain-src")) {
 map.addSource("terrain-src", terrainSource(MAPTILER));
 }
 map.setTerrain({ source: "terrain-src", exaggeration: EXAGGERATION });
 terrainOn = true;
 }

 
 let onScrub = (idx) =&gt; {};
 let segFeatures = []; 
 let profileCtrl = null; 

 map.on("load", () =&gt; {
 
 addTerrain();

 
 const scrubEl = document.createElement("div");
 scrubEl.style.cssText = `
 width: 0; height: 0;
 border-left: 7px solid transparent;
 border-right: 7px solid transparent;
 border-bottom: 13px solid #fff;
 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6));
 display: none;
 `;
 const scrubMarker = new maplibregl.Marker({ element: scrubEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);

 onScrub = function(idx) {
 if (idx === null) {
 scrubEl.style.display = "none";
 } else {
 scrubEl.style.display = "block";
 scrubMarker.setLngLat(track.coord[idx]);
 }
 };

 profileCtrl = drawProfile(MAP_ID + "-elev", track, onScrub);

 
 
 function gradeColor(pct) {
 const g = Math.abs(pct);
 if (g &lt; 8) return "#4caf50"; 
 if (g &lt; 15) return "#ffeb3b"; 
 if (g &lt; 25) return "#ff9800"; 
 return "#f44336"; 
 }

 
 segFeatures = [];
 for (let i = 1; i &lt; track.pts.length; i++) {
 const a = track.pts[i - 1], b = track.pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 const horiz = Math.sqrt(dx * dx + dy * dy);
 const grade = horiz &gt; 0 ? ((b.ele - a.ele) / horiz) * 100 : 0;
 segFeatures.push({
 type: "Feature",
 properties: { color: gradeColor(grade) },
 geometry: { type: "LineString", coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
 });
 }

 map.addSource("track", {
 type: "geojson",
 data: {
 type: "Feature",
 geometry: { type: "LineString", coordinates: track.coord },
 },
 });
 map.addLayer({
 id: "track-line",
 type: "line",
 source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": "#e05c2a",
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 3,
 9, 5,
 12, 4,
 15, 3,
 ],
 "line-opacity": 0.5,
 },
 });

 
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "FeatureCollection", features: [] },
 });
 map.addLayer({
 id: "track-progress-line",
 type: "line",
 source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": ["get", "color"],
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 4,
 9, 7,
 12, 6,
 15, 5,
 ],
 "line-opacity": 1.0,
 },
 });

 
 new maplibregl.Marker({ color: "#2ecc71" })
 .setLngLat(track.coord[0])
 .setPopup(new maplibregl.Popup().setText("Start"))
 .addTo(map);
 new maplibregl.Marker({ color: "#e74c3c" })
 .setLngLat(track.coord[track.coord.length - 1])
 .setPopup(new maplibregl.Popup().setText("End"))
 .addTo(map);

 
 const baseUrl = window.location.href.replace(/\/$/, "");
 photos.forEach(p =&gt; {
 const el = document.createElement("div");
 el.style.cssText = `
 width:28px; height:28px; border-radius:50%;
 background:#fff url('${baseUrl}/gallery/${p.file}') center/cover;
 border:2px solid #fff; box-shadow:0 1px 4px rgba(0,0,0,.4);
 cursor:pointer;
 `;

 const popup = new maplibregl.Popup({ offset: 18 }).setHTML(`
 &lt;a href="${baseUrl}/gallery/${p.file}" target="_blank"&gt;
 &lt;img src="${baseUrl}/gallery/${p.file}"
 style="max-width:200px; max-height:150px; border-radius:4px; display:block;"&gt;
 &lt;/a&gt;
 ${p.alt != null ? `&lt;div style="font-size:0.75rem; text-align:center; margin-top:4px;"&gt;${Math.round(p.alt)} m&lt;/div&gt;` : ""}
 `);

 new maplibregl.Marker({ element: el })
 .setLngLat([p.lon, p.lat])
 .setPopup(popup)
 .addTo(map);
 });

 
 flyEl.style.display = "none";
 flyMarker = new maplibregl.Marker({ element: flyEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);
 });

 
 let animFrame = null;
 let animPlaying = false;
 let lastTime = null;

 let flyMarker = null; 

 
 const flyEl = document.createElement("div");
 flyEl.style.cssText = "display:none; width:32px; height:40px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));";
 flyEl.innerHTML = `&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 40" width="32" height="40"&gt;
 &lt;path d="M16 2 C8 2 2 8 2 16 C2 26 16 38 16 38 C16 38 30 26 30 16 C30 8 24 2 16 2 Z"
 fill="#222" stroke="#fff" stroke-width="1.5"/&gt;
 &lt;circle cx="16" cy="16" r="5" fill="#fff"/&gt;
 &lt;/svg&gt;`;

 
 let animPos = 0; 
 const BASE_PPS = track.coord.length / 60; 

 function interpCoord(pos) {
 const i = Math.floor(pos);
 const t = pos - i;
 const a = track.coord[Math.min(i, track.coord.length - 1)];
 const b = track.coord[Math.min(i + 1, track.coord.length - 1)];
 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
 }

 function updateProgressLine(pos) {
 const idx = Math.min(Math.floor(pos), track.pts.length - 2);
 const tip = interpCoord(pos);
 const src = map.getSource("track-progress");
 if (!src) return;
 
 const features = segFeatures.slice(0, idx);
 
 if (idx &gt;= 0 &amp;&amp; idx &lt; segFeatures.length) {
 const seg = segFeatures[idx];
 features.push({
 ...seg,
 geometry: { type: "LineString", coordinates: [seg.geometry.coordinates[0], tip] },
 });
 }
 src.setData({ type: "FeatureCollection", features });
 }

 function animStep(ts) {
 if (!animPlaying) return;
 if (!lastTime) lastTime = ts;
 const dt = (ts - lastTime) / 1000;
 lastTime = ts;

 const speedEl = document.getElementById(MAP_ID + "-speed");
 const speed = speedEl ? parseFloat(speedEl.value) : 1;
 animPos = Math.min(animPos + BASE_PPS * speed * dt, track.coord.length - 1);

 const pos = interpCoord(animPos);
 if (flyMarker) {
 flyMarker.setLngLat(pos);
 flyEl.style.display = "block";
 }

 updateProgressLine(animPos);
 onScrub(Math.round(animPos));
 if (profileCtrl) profileCtrl.setAnimIdx(Math.round(animPos));

 if (animPos &gt;= track.coord.length - 1) {
 stopFly(true);
 return;
 }

 animFrame = requestAnimationFrame(animStep);
 }

 function stopFly(finished) {
 animPlaying = false;
 lastTime = null;
 cancelAnimationFrame(animFrame);
 if (flyMarker) flyEl.style.display = "none";
 onScrub(null);
 if (profileCtrl) profileCtrl.clearAnim();
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (btn) { btn.textContent = "▶ Fly"; btn.style.background = "#fff"; btn.style.color = ""; }
 if (finished) {
 
 updateProgressLine(track.coord.length - 1);
 }
 }

 
 window.gpxmaps[MAP_ID] = {
 setStyle(name) {
 map.setStyle(styleUrl(name));
 map.once("styledata", () =&gt; {
 addTerrain();
 if (!map.getSource("track")) {
 map.addSource("track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 map.addLayer({
 id: "track-line", type: "line", source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.25 },
 });
 }
 if (!map.getSource("track-progress")) {
 const done = track.coord.slice(0, Math.floor(animPos) + 1);
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: done } },
 });
 map.addLayer({
 id: "track-progress-line", type: "line", source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.9 },
 });
 }
 });
 },
 toggleTerrain() {
 const btn = document.getElementById(MAP_ID + "-3dbtn");
 if (terrainOn) {
 map.setTerrain(null);
 map.easeTo({ pitch: 0, bearing: 0, duration: 600 });
 terrainOn = false;
 if (btn) { btn.style.background = "#fff"; btn.style.color = ""; btn.style.fontWeight = "normal"; }
 } else {
 addTerrain();
 map.easeTo({ pitch: 55, duration: 800 });
 if (btn) { btn.style.background = "#e05c2a"; btn.style.color = "#fff"; btn.style.fontWeight = "bold"; }
 }
 },
 togglePlay() {
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (animPlaying) {
 stopFly(false);
 } else {
 if (!flyMarker) { console.warn("Map not ready yet"); return; }
 
 if (animPos &gt;= track.coord.length - 1) {
 animPos = 0;
 updateProgressLine(0);
 }
 animPlaying = true;
 lastTime = null;
 if (btn) { btn.textContent = "⏸ Pause"; btn.style.background = "#e05c2a"; btn.style.color = "#fff"; }
 animFrame = requestAnimationFrame(animStep);
 }
 },
 seekTo(idx) {
 animPos = idx;
 updateProgressLine(idx);
 onScrub(idx);
 if (profileCtrl) profileCtrl.setAnimIdx(idx);
 if (flyMarker) {
 flyMarker.setLngLat(track.coord[idx]);
 flyEl.style.display = "block";
 }
 },
 toggleUnits() {
 useMetric = !useMetric;
 const btn = document.getElementById(MAP_ID + "-unitbtn");
 if (btn) btn.textContent = useMetric ? "km / mi" : "mi / km";
 updateStats(MAP_ID, trackStats.dist, trackStats.eleGain, trackStats.eleLoss,
 trackStats.eleMin, trackStats.eleMax);
 },
 rotateby(deg) {
 map.easeTo({ bearing: map.getBearing() + deg, duration: 300 });
 },
 tiltby(deg) {
 const next = Math.max(0, Math.min(85, map.getPitch() + deg));
 map.easeTo({ pitch: next, duration: 300 });
 },
 resetNorth() {
 map.easeTo({ bearing: 0, pitch: 0, duration: 500 });
 },
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() {
 const mapEl = document.getElementById(MAP_ID);
 const btn = document.getElementById(MAP_ID + "-sizebtn");
 const sizes = [
 { label: "↙ SM", action: "collapse" },
 { label: "▭ MD", height: "500px" },
 { label: "▬ LG", height: "800px" },
 ];
 
 const fullEl = document.getElementById(MAP_ID + "-full");
 const isCollapsed = fullEl.style.display === "none";
 let current = isCollapsed ? 0
 : mapEl.style.height === "800px" ? 2 : 1;
 const next = (current + 1) % sizes.length;
 const s = sizes[next];
 if (s.action === "collapse") {
 collapseMap();
 } else {
 if (isCollapsed) expandMap();
 mapEl.style.height = s.height;
 map.resize();
 }
 if (btn) btn.textContent = sizes[(next + 1) % sizes.length].label;
 },
 };
 } 

 
 window.gpxmaps[MAP_ID] = {
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() { if (fullMapInited) window.gpxmaps[MAP_ID].cycleSize(); else expandMap(); },
 
 togglePlay() { if (fullMapInited) window.gpxmaps[MAP_ID].togglePlay(); },
 setStyle(n) { if (fullMapInited) window.gpxmaps[MAP_ID].setStyle(n); },
 toggleTerrain(){ if (fullMapInited) window.gpxmaps[MAP_ID].toggleTerrain(); },
 seekTo(i) { if (fullMapInited) window.gpxmaps[MAP_ID].seekTo(i); },
 toggleUnits() { if (fullMapInited) window.gpxmaps[MAP_ID].toggleUnits(); },
 rotateby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].rotateby(d); },
 tiltby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].tiltby(d); },
 resetNorth() { if (fullMapInited) window.gpxmaps[MAP_ID].resetNorth(); },
 };
 }

 
 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", init);
 } else {
 init();
 }
})();
&lt;/script&gt;


&lt;div class="justified-gallery" id="gallery"&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_19-12_01_505.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_19-12_01_505_hu_d0fea0955974be06.jpg" alt="gallery/26_01_19-12_01_505.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_19-12_13_204.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_19-12_13_204_hu_76b8302c4d5ba4c6.jpg" alt="gallery/26_01_19-12_13_204.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_19-12_35_805.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_19-12_35_805_hu_5ea491084eb42b1d.jpg" alt="gallery/26_01_19-12_35_805.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_19-12_36_388.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_19-12_36_388_hu_5f71161ec4ea898d.jpg" alt="gallery/26_01_19-12_36_388.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-11_56_579.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-11_56_579_hu_a6d781f778360e96.jpg" alt="gallery/26_01_29-11_56_579.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-12_03_292.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-12_03_292_hu_2869e5bda5ee6f3c.jpg" alt="gallery/26_01_29-12_03_292.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-12_04_768.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-12_04_768_hu_e209752ae97a179b.jpg" alt="gallery/26_01_29-12_04_768.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-12_06_847.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-12_06_847_hu_d3c790695ee35487.jpg" alt="gallery/26_01_29-12_06_847.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-12_18_957.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/gallery/26_01_29-12_18_957_hu_d898527219ff653b.jpg" alt="gallery/26_01_29-12_18_957.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
&lt;/div&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/via-ferrata-solo/feature.jpg"/></item><item><title>Via Ferrata with Craig</title><link>https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/</link><pubDate>Fri, 09 Jan 2026 19:04:03 +1300</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/</guid><description>&lt;p&gt;Via Ferrata with Craig. They have 2 courses now at &lt;a href="https://viaferrata.org.nz/" target="_blank" rel="noreferrer"&gt;Takaka Hill&lt;/a&gt;. Super dope.&lt;/p&gt;














&lt;div id="gpxmap-via-ferrata-with-craig-shell" style="margin: 1.5rem 0;"&gt;

 
 &lt;div id="gpxmap-via-ferrata-with-craig-mini" style="
 position: relative; cursor: pointer; border-radius: 8px; overflow: hidden;
 height: 180px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
 " onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].expand()"&gt;
 &lt;div id="gpxmap-via-ferrata-with-craig-minimap" style="width:100%; height:100%;"&gt;&lt;/div&gt;
 
 &lt;div style="
 position:absolute; bottom:0; left:0; right:0;
 background: linear-gradient(transparent, rgba(0,0,0,0.55));
 padding: 20px 12px 10px;
 display:flex; justify-content:space-between; align-items:flex-end;
 pointer-events:none;
 "&gt;
 &lt;span id="gpxmap-via-ferrata-with-craig-mini-stats" style="color:#fff; font-size:0.78rem; font-family:monospace; opacity:0.9;"&gt;&lt;/span&gt;
 &lt;span style="color:#fff; font-size:0.78rem; opacity:0.8;"&gt;tap to explore ↗&lt;/span&gt;
 &lt;/div&gt;
 &lt;/div&gt;

 
 &lt;div id="gpxmap-via-ferrata-with-craig-full" style="display:none;"&gt;

&lt;div class="gpxmap-wrap" style="position:relative;"&gt;
 &lt;div id="gpxmap-via-ferrata-with-craig" style="width:100%; height:500px; border-radius:8px; overflow:hidden;"&gt;&lt;/div&gt;

 
 &lt;div id="gpxmap-via-ferrata-with-craig-stats" style="
 display:flex; gap:1.5rem; flex-wrap:wrap; align-items:center;
 padding: 0.6rem 1rem;
 background: var(--bg-color, #1a1a1a);
 color: var(--text-color, #e0e0e0);
 font-size: 0.82rem;
 border-radius: 0 0 8px 8px;
 font-family: monospace;
 "&gt;
 &lt;span id="gpxmap-via-ferrata-with-craig-stats-text"&gt;⟳ loading…&lt;/span&gt;
 &lt;button id="gpxmap-via-ferrata-with-craig-unitbtn" onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].toggleUnits()"
 style="margin-left:auto; padding:2px 8px; font-size:0.75rem; border-radius:4px;
 border:1px solid rgba(255,255,255,0.3); cursor:pointer;
 background:transparent; color:inherit; font-family:monospace;"&gt;
 km / mi
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; right:10px; z-index:10; display:flex; flex-direction:column; gap:6px; align-items:flex-end;"&gt;
 &lt;div style="display:flex; gap:6px; align-items:center;"&gt;
 &lt;select id="gpxmap-via-ferrata-with-craig-speed" title="Flythrough speed"
 style="padding:3px 5px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 &lt;option value="0.5"&gt;0.5×&lt;/option&gt;
 &lt;option value="1" selected&gt;1×&lt;/option&gt;
 &lt;option value="2"&gt;2×&lt;/option&gt;
 &lt;option value="4"&gt;4×&lt;/option&gt;
 &lt;/select&gt;
 &lt;button id="gpxmap-via-ferrata-with-craig-playbtn" onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].togglePlay()"
 style="padding:4px 10px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 ▶ Fly
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].setStyle('outdoor')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Topo
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].setStyle('satellite')"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 Satellite
 &lt;/button&gt;
 &lt;button id="gpxmap-via-ferrata-with-craig-3dbtn" onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].toggleTerrain()"
 style="padding:4px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:#fff; opacity:0.9;"&gt;
 3D ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;button id="gpxmap-via-ferrata-with-craig-sizebtn" onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].cycleSize()"
 style="padding:2px 8px; font-size:0.75rem; border-radius:4px; border:none; cursor:pointer; background:rgba(255,255,255,0.9); opacity:0.9; font-family:monospace;"&gt;
 ↙ SM
 &lt;/button&gt;
 &lt;/div&gt;

 
 &lt;div style="position:absolute; top:10px; left:10px; z-index:10; display:flex; flex-direction:column; align-items:center; gap:4px;"&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].rotateby(-20)" title="Rotate left"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↺
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].resetNorth()" title="Reset north"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.75rem;font-weight:bold;line-height:1;"&gt;
 N
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].rotateby(20)" title="Rotate right"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:1rem;line-height:1;"&gt;
 ↻
 &lt;/button&gt;
 &lt;/div&gt;
 
 &lt;div style="display:flex; gap:4px;"&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].tiltby(-15)" title="Tilt flat"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 🔭
 &lt;/button&gt;
 &lt;button onclick="gpxmaps['gpxmap-via-ferrata-with-craig'].tiltby(15)" title="Tilt up"
 style="width:32px;height:32px;border-radius:50%;border:none;cursor:pointer;background:rgba(255,255,255,0.9);font-size:0.8rem;line-height:1;"&gt;
 ⛰
 &lt;/button&gt;
 &lt;/div&gt;
 &lt;/div&gt;


&lt;canvas id="gpxmap-via-ferrata-with-craig-elev" height="80"
 style="width:100%; margin-top:-8px; border-radius:0 0 8px 8px; background:var(--bg-color, #111);"&gt;&lt;/canvas&gt;


&lt;div style="text-align:center; margin: 0.5rem 0 1.5rem; opacity:0.35; line-height:1;"&gt;
 &lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 40" width="100%" height="40" preserveAspectRatio="none"&gt;
 &lt;path d="M0 40 L0 28 L30 12 L55 24 L90 4 L120 20 L150 8 L175 22 L200 2 L225 18 L255 6 L285 22 L310 10 L340 26 L370 8 L400 20 L400 40 Z"
 fill="currentColor"/&gt;
 &lt;/svg&gt;
&lt;/div&gt;

 &lt;/div&gt;
&lt;/div&gt;



 
 &lt;link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"&gt;
 &lt;script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"&gt;&lt;/script&gt;
 &lt;script&gt;
 window.gpxmaps = {};
 &lt;/script&gt;


&lt;script&gt;
(function() {
 const MAP_ID = "gpxmap-via-ferrata-with-craig";
 const MAPTILER = "DSdF85giIH5Jkcc46q7r ";
 const BASE_STYLE = "outdoor-v2";
 const EXAGGERATION = 1.5 ;
 const GPX_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/via-ferrata-with-craig\/track.gpx";
 const PHOTOS_URL = "https:\/\/blog.willprogramforfood.com\/posts\/2026\/via-ferrata-with-craig\/photos.json";

 
 function parseGPX(xmlText) {
 const parser = new DOMParser();
 const doc = parser.parseFromString(xmlText, "application/xml");
 const ns = "http://www.topografix.com/GPX/1/1";

 const pts = Array.from(doc.getElementsByTagNameNS(ns, "trkpt")).map(pt =&gt; ({
 lon: parseFloat(pt.getAttribute("lon")),
 lat: parseFloat(pt.getAttribute("lat")),
 ele: parseFloat(pt.getElementsByTagNameNS(ns, "ele")[0]?.textContent ?? "0"),
 }));

 if (!pts.length) throw new Error("No trackpoints found in GPX");

 
 let dist = 0, eleGain = 0, eleLoss = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 dist += Math.sqrt(dx*dx + dy*dy);
 const dz = b.ele - a.ele;
 if (dz &gt; 0) eleGain += dz; else eleLoss += -dz;
 }

 const eles = pts.map(p =&gt; p.ele);
 const coord = pts.map(p =&gt; [p.lon, p.lat]);
 const center = coord[Math.floor(coord.length / 2)];

 
 let cumDist = 0;
 pts[0]._distFromStart = 0;
 for (let i = 1; i &lt; pts.length; i++) {
 const a = pts[i-1], b = pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 cumDist += Math.sqrt(dx*dx + dy*dy);
 pts[i]._distFromStart = cumDist;
 }

 return { pts, coord, center, dist, eleGain, eleLoss,
 eleMin: Math.min(...eles), eleMax: Math.max(...eles), eles };
 }

 function drawProfile(canvasId, track, onScrub) {
 const { eles, eleMin, eleMax, pts } = track;
 const canvas = document.getElementById(canvasId);
 if (!canvas) return;
 const ctx = canvas.getContext("2d");
 const DPR = window.devicePixelRatio || 1;
 const pad = 4;
 const range = eleMax - eleMin || 1;

 let currentAnimIdx = null; 

 function resize() {
 canvas.width = canvas.offsetWidth * DPR;
 canvas.height = canvas.offsetHeight * DPR;
 }
 resize();

 function yForEle(e) {
 const H = canvas.height;
 return pad + (1 - (e - eleMin) / range) * (H - pad * 2);
 }

 function draw(scrubX) {
 const W = canvas.width, H = canvas.height;
 ctx.clearRect(0, 0, W, H);

 
 if (currentAnimIdx !== null &amp;&amp; currentAnimIdx &gt; 0) {
 const progX = (currentAnimIdx / (eles.length - 1)) * W;

 
 const progGrad = ctx.createLinearGradient(0, 0, 0, H);
 progGrad.addColorStop(0, "rgba(224,92,42,0.55)");
 progGrad.addColorStop(1, "rgba(224,92,42,0.05)");
 ctx.fillStyle = progGrad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.lineTo(progX, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(224,92,42,0.9)";
 ctx.lineWidth = 2.5 * DPR;
 ctx.beginPath();
 for (let i = 0; i &lt;= Math.min(currentAnimIdx, eles.length - 1); i++) {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(eles[i])) : ctx.lineTo(x, yForEle(eles[i]));
 }
 ctx.stroke();

 
 ctx.strokeStyle = "rgba(224,92,42,0.8)";
 ctx.lineWidth = 1.5 * DPR;
 ctx.setLineDash([3 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(progX, 0);
 ctx.lineTo(progX, H);
 ctx.stroke();
 ctx.setLineDash([]);
 }

 
 const grad = ctx.createLinearGradient(0, 0, 0, H);
 grad.addColorStop(0, "rgba(99,179,130,0.4)");
 grad.addColorStop(1, "rgba(99,179,130,0.02)");
 ctx.fillStyle = grad;
 ctx.beginPath();
 ctx.moveTo(0, H);
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 ctx.lineTo(x, yForEle(e));
 });
 ctx.lineTo(W, H);
 ctx.closePath();
 ctx.fill();

 
 ctx.strokeStyle = "rgba(99,179,130,0.7)";
 ctx.lineWidth = 2 * DPR;
 ctx.beginPath();
 eles.forEach((e, i) =&gt; {
 const x = (i / (eles.length - 1)) * W;
 i === 0 ? ctx.moveTo(x, yForEle(e)) : ctx.lineTo(x, yForEle(e));
 });
 ctx.stroke();

 if (scrubX == null) return;

 
 ctx.strokeStyle = "rgba(255,255,255,0.7)";
 ctx.lineWidth = 1 * DPR;
 ctx.setLineDash([4 * DPR, 3 * DPR]);
 ctx.beginPath();
 ctx.moveTo(scrubX, 0);
 ctx.lineTo(scrubX, H);
 ctx.stroke();
 ctx.setLineDash([]);

 
 const idx = Math.round((scrubX / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 const cy = yForEle(eles[safeIdx]);
 const ts = 7 * DPR;
 ctx.fillStyle = "#fff";
 ctx.beginPath();
 ctx.moveTo(scrubX, cy - ts);
 ctx.lineTo(scrubX + ts, cy + ts * 0.6);
 ctx.lineTo(scrubX - ts, cy + ts * 0.6);
 ctx.closePath();
 ctx.fill();

 
 const distKm = (pts[safeIdx]._distFromStart / 1000).toFixed(1);
 const label = `${Math.round(eles[safeIdx])} m · ${distKm} km`;
 const fsize = 11 * DPR;
 ctx.font = `${fsize}px monospace`;
 const tw = ctx.measureText(label).width;
 const tx = Math.min(scrubX + 8 * DPR, W - tw - 4 * DPR);
 const ty = Math.max(fsize + 4 * DPR, cy - 8 * DPR);
 ctx.fillStyle = "rgba(0,0,0,0.6)";
 ctx.fillRect(tx - 3 * DPR, ty - fsize, tw + 6 * DPR, fsize + 4 * DPR);
 ctx.fillStyle = "#fff";
 ctx.fillText(label, tx, ty);
 }

 
 draw(null);

 
 function handleMove(clientX) {
 const rect = canvas.getBoundingClientRect();
 const W = canvas.width;
 const x = Math.max(0, Math.min(W, (clientX - rect.left) * DPR));
 const idx = Math.round((x / W) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 draw(x);
 onScrub(safeIdx);
 }

 function handleLeave() {
 draw(null);
 onScrub(null);
 }

 canvas.addEventListener("mousemove", e =&gt; handleMove(e.clientX));
 canvas.addEventListener("mouseleave", handleLeave);
 canvas.addEventListener("click", e =&gt; {
 const rect = canvas.getBoundingClientRect();
 const x = Math.max(0, Math.min(canvas.width, (e.clientX - rect.left) * DPR));
 const idx = Math.round((x / canvas.width) * (eles.length - 1));
 const safeIdx = Math.max(0, Math.min(eles.length - 1, idx));
 if (window.gpxmaps &amp;&amp; window.gpxmaps[MAP_ID]) {
 window.gpxmaps[MAP_ID].seekTo(safeIdx);
 }
 });
 canvas.addEventListener("touchmove", e =&gt; { e.preventDefault(); handleMove(e.touches[0].clientX); }, { passive: false });
 canvas.addEventListener("touchend", handleLeave);
 canvas.style.cursor = "crosshair";

 
 return {
 setAnimIdx(idx) {
 currentAnimIdx = idx;
 draw(null);
 },
 clearAnim() {
 currentAnimIdx = null;
 draw(null);
 },
 };
 }

 
 let useMetric = true;

 function updateStats(id, dist, eleGain, eleLoss, eleMin, eleMax) {
 const el = document.getElementById(id + "-stats-text");
 if (!el) return;
 const fmt = n =&gt; Math.round(n).toLocaleString();
 if (useMetric) {
 const km = (dist / 1000).toFixed(1);
 el.innerHTML = `
 &lt;span&gt;📍 ${km} km&lt;/span&gt;
 &lt;span&gt;⬆ ${fmt(eleGain)} m gain&lt;/span&gt;
 &lt;span&gt;⬇ ${fmt(eleLoss)} m loss&lt;/span&gt;
 &lt;span&gt;🏔 ${fmt(eleMax)} m high · ${fmt(eleMin)} m low&lt;/span&gt;
 `;
 } else {
 const mi = (dist / 1609.34).toFixed(1);
 const toFt = m =&gt; Math.round(m * 3.28084).toLocaleString();
 el.innerHTML = `
 &lt;span&gt;📍 ${mi} mi&lt;/span&gt;
 &lt;span&gt;⬆ ${toFt(eleGain)} ft gain&lt;/span&gt;
 &lt;span&gt;⬇ ${toFt(eleLoss)} ft loss&lt;/span&gt;
 &lt;span&gt;🏔 ${toFt(eleMax)} ft high · ${toFt(eleMin)} ft low&lt;/span&gt;
 `;
 }
 }

 
 function styleUrl(name) {
 const key = MAPTILER;
 const map = {
 outdoor: `https://api.maptiler.com/maps/outdoor-v2/style.json?key=${key}`,
 satellite: `https://api.maptiler.com/maps/satellite/style.json?key=${key}`,
 topo: `https://api.maptiler.com/maps/topo-v2/style.json?key=${key}`,
 };
 return map[name] || map.outdoor;
 }

 function terrainSource(key) {
 return {
 type: "raster-dem",
 url: `https://api.maptiler.com/tiles/terrain-rgb-v2/tiles.json?key=${key}`,
 tileSize: 256,
 };
 }

 
 async function init() {
 
 const [gpxRes, photosRes] = await Promise.all([
 fetch(GPX_URL),
 fetch(PHOTOS_URL).catch(() =&gt; null),
 ]);

 if (!gpxRes.ok) {
 document.getElementById(MAP_ID + "-stats-text").textContent = "GPX file not found.";
 return;
 }

 const gpxText = await gpxRes.text();
 const track = parseGPX(gpxText);

 let photos = [];
 if (photosRes &amp;&amp; photosRes.ok) {
 photos = await photosRes.json();
 }

 
 const trackStats = { dist: track.dist, eleGain: track.eleGain, eleLoss: track.eleLoss,
 eleMin: track.eleMin, eleMax: track.eleMax };

 updateStats(MAP_ID, track.dist, track.eleGain, track.eleLoss, track.eleMin, track.eleMax);

 
 const miniStats = document.getElementById(MAP_ID + "-mini-stats");
 if (miniStats) {
 const km = (track.dist / 1000).toFixed(1);
 miniStats.textContent = `📍 ${km} km · ⬆ ${Math.round(track.eleGain)} m`;
 }

 
 const lons = track.coord.map(c =&gt; c[0]);
 const lats = track.coord.map(c =&gt; c[1]);
 const bbox = [Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)];

 
 const miniMap = new maplibregl.Map({
 container: MAP_ID + "-minimap",
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 20, maxZoom: 13 },
 interactive: false,
 attributionControl: false,
 });

 miniMap.on("load", () =&gt; {
 miniMap.addSource("mini-track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 miniMap.addLayer({
 id: "mini-track-line", type: "line", source: "mini-track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 2.5, "line-opacity": 0.9 },
 });
 });

 
 let expanded = false;
 let fullMapInited = false;

 function expandMap() {
 if (expanded) return;
 expanded = true;
 document.getElementById(MAP_ID + "-mini").style.display = "none";
 document.getElementById(MAP_ID + "-full").style.display = "block";
 if (!fullMapInited) {
 fullMapInited = true;
 initFullMap();
 } else {
 map.resize();
 }
 }

 function collapseMap() {
 if (!expanded) return;
 expanded = false;
 document.getElementById(MAP_ID + "-full").style.display = "none";
 document.getElementById(MAP_ID + "-mini").style.display = "block";
 miniMap.resize();
 }

 
 let map;

 function initFullMap() {
 map = new maplibregl.Map({
 container: MAP_ID,
 style: styleUrl("outdoor"),
 bounds: bbox,
 fitBoundsOptions: { padding: 50, maxZoom: 15 },
 });

 let terrainOn = false;

 function addTerrain() {
 if (!map.getSource("terrain-src")) {
 map.addSource("terrain-src", terrainSource(MAPTILER));
 }
 map.setTerrain({ source: "terrain-src", exaggeration: EXAGGERATION });
 terrainOn = true;
 }

 
 let onScrub = (idx) =&gt; {};
 let segFeatures = []; 
 let profileCtrl = null; 

 map.on("load", () =&gt; {
 
 addTerrain();

 
 const scrubEl = document.createElement("div");
 scrubEl.style.cssText = `
 width: 0; height: 0;
 border-left: 7px solid transparent;
 border-right: 7px solid transparent;
 border-bottom: 13px solid #fff;
 filter: drop-shadow(0 1px 3px rgba(0,0,0,0.6));
 display: none;
 `;
 const scrubMarker = new maplibregl.Marker({ element: scrubEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);

 onScrub = function(idx) {
 if (idx === null) {
 scrubEl.style.display = "none";
 } else {
 scrubEl.style.display = "block";
 scrubMarker.setLngLat(track.coord[idx]);
 }
 };

 profileCtrl = drawProfile(MAP_ID + "-elev", track, onScrub);

 
 
 function gradeColor(pct) {
 const g = Math.abs(pct);
 if (g &lt; 8) return "#4caf50"; 
 if (g &lt; 15) return "#ffeb3b"; 
 if (g &lt; 25) return "#ff9800"; 
 return "#f44336"; 
 }

 
 segFeatures = [];
 for (let i = 1; i &lt; track.pts.length; i++) {
 const a = track.pts[i - 1], b = track.pts[i];
 const dx = (b.lon - a.lon) * Math.cos((a.lat + b.lat) * Math.PI / 360) * 111320;
 const dy = (b.lat - a.lat) * 111320;
 const horiz = Math.sqrt(dx * dx + dy * dy);
 const grade = horiz &gt; 0 ? ((b.ele - a.ele) / horiz) * 100 : 0;
 segFeatures.push({
 type: "Feature",
 properties: { color: gradeColor(grade) },
 geometry: { type: "LineString", coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
 });
 }

 map.addSource("track", {
 type: "geojson",
 data: {
 type: "Feature",
 geometry: { type: "LineString", coordinates: track.coord },
 },
 });
 map.addLayer({
 id: "track-line",
 type: "line",
 source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": "#e05c2a",
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 3,
 9, 5,
 12, 4,
 15, 3,
 ],
 "line-opacity": 0.5,
 },
 });

 
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "FeatureCollection", features: [] },
 });
 map.addLayer({
 id: "track-progress-line",
 type: "line",
 source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: {
 "line-color": ["get", "color"],
 "line-width": ["interpolate", ["linear"], ["zoom"],
 6, 4,
 9, 7,
 12, 6,
 15, 5,
 ],
 "line-opacity": 1.0,
 },
 });

 
 new maplibregl.Marker({ color: "#2ecc71" })
 .setLngLat(track.coord[0])
 .setPopup(new maplibregl.Popup().setText("Start"))
 .addTo(map);
 new maplibregl.Marker({ color: "#e74c3c" })
 .setLngLat(track.coord[track.coord.length - 1])
 .setPopup(new maplibregl.Popup().setText("End"))
 .addTo(map);

 
 const baseUrl = window.location.href.replace(/\/$/, "");
 photos.forEach(p =&gt; {
 const el = document.createElement("div");
 el.style.cssText = `
 width:28px; height:28px; border-radius:50%;
 background:#fff url('${baseUrl}/gallery/${p.file}') center/cover;
 border:2px solid #fff; box-shadow:0 1px 4px rgba(0,0,0,.4);
 cursor:pointer;
 `;

 const popup = new maplibregl.Popup({ offset: 18 }).setHTML(`
 &lt;a href="${baseUrl}/gallery/${p.file}" target="_blank"&gt;
 &lt;img src="${baseUrl}/gallery/${p.file}"
 style="max-width:200px; max-height:150px; border-radius:4px; display:block;"&gt;
 &lt;/a&gt;
 ${p.alt != null ? `&lt;div style="font-size:0.75rem; text-align:center; margin-top:4px;"&gt;${Math.round(p.alt)} m&lt;/div&gt;` : ""}
 `);

 new maplibregl.Marker({ element: el })
 .setLngLat([p.lon, p.lat])
 .setPopup(popup)
 .addTo(map);
 });

 
 flyEl.style.display = "none";
 flyMarker = new maplibregl.Marker({ element: flyEl, anchor: "bottom" })
 .setLngLat(track.coord[0])
 .addTo(map);
 });

 
 let animFrame = null;
 let animPlaying = false;
 let lastTime = null;

 let flyMarker = null; 

 
 const flyEl = document.createElement("div");
 flyEl.style.cssText = "display:none; width:32px; height:40px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));";
 flyEl.innerHTML = `&lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 40" width="32" height="40"&gt;
 &lt;path d="M16 2 C8 2 2 8 2 16 C2 26 16 38 16 38 C16 38 30 26 30 16 C30 8 24 2 16 2 Z"
 fill="#222" stroke="#fff" stroke-width="1.5"/&gt;
 &lt;circle cx="16" cy="16" r="5" fill="#fff"/&gt;
 &lt;/svg&gt;`;

 
 let animPos = 0; 
 const BASE_PPS = track.coord.length / 60; 

 function interpCoord(pos) {
 const i = Math.floor(pos);
 const t = pos - i;
 const a = track.coord[Math.min(i, track.coord.length - 1)];
 const b = track.coord[Math.min(i + 1, track.coord.length - 1)];
 return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
 }

 function updateProgressLine(pos) {
 const idx = Math.min(Math.floor(pos), track.pts.length - 2);
 const tip = interpCoord(pos);
 const src = map.getSource("track-progress");
 if (!src) return;
 
 const features = segFeatures.slice(0, idx);
 
 if (idx &gt;= 0 &amp;&amp; idx &lt; segFeatures.length) {
 const seg = segFeatures[idx];
 features.push({
 ...seg,
 geometry: { type: "LineString", coordinates: [seg.geometry.coordinates[0], tip] },
 });
 }
 src.setData({ type: "FeatureCollection", features });
 }

 function animStep(ts) {
 if (!animPlaying) return;
 if (!lastTime) lastTime = ts;
 const dt = (ts - lastTime) / 1000;
 lastTime = ts;

 const speedEl = document.getElementById(MAP_ID + "-speed");
 const speed = speedEl ? parseFloat(speedEl.value) : 1;
 animPos = Math.min(animPos + BASE_PPS * speed * dt, track.coord.length - 1);

 const pos = interpCoord(animPos);
 if (flyMarker) {
 flyMarker.setLngLat(pos);
 flyEl.style.display = "block";
 }

 updateProgressLine(animPos);
 onScrub(Math.round(animPos));
 if (profileCtrl) profileCtrl.setAnimIdx(Math.round(animPos));

 if (animPos &gt;= track.coord.length - 1) {
 stopFly(true);
 return;
 }

 animFrame = requestAnimationFrame(animStep);
 }

 function stopFly(finished) {
 animPlaying = false;
 lastTime = null;
 cancelAnimationFrame(animFrame);
 if (flyMarker) flyEl.style.display = "none";
 onScrub(null);
 if (profileCtrl) profileCtrl.clearAnim();
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (btn) { btn.textContent = "▶ Fly"; btn.style.background = "#fff"; btn.style.color = ""; }
 if (finished) {
 
 updateProgressLine(track.coord.length - 1);
 }
 }

 
 window.gpxmaps[MAP_ID] = {
 setStyle(name) {
 map.setStyle(styleUrl(name));
 map.once("styledata", () =&gt; {
 addTerrain();
 if (!map.getSource("track")) {
 map.addSource("track", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: track.coord } },
 });
 map.addLayer({
 id: "track-line", type: "line", source: "track",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.25 },
 });
 }
 if (!map.getSource("track-progress")) {
 const done = track.coord.slice(0, Math.floor(animPos) + 1);
 map.addSource("track-progress", {
 type: "geojson",
 data: { type: "Feature", geometry: { type: "LineString", coordinates: done } },
 });
 map.addLayer({
 id: "track-progress-line", type: "line", source: "track-progress",
 layout: { "line-join": "round", "line-cap": "round" },
 paint: { "line-color": "#e05c2a", "line-width": 3, "line-opacity": 0.9 },
 });
 }
 });
 },
 toggleTerrain() {
 const btn = document.getElementById(MAP_ID + "-3dbtn");
 if (terrainOn) {
 map.setTerrain(null);
 map.easeTo({ pitch: 0, bearing: 0, duration: 600 });
 terrainOn = false;
 if (btn) { btn.style.background = "#fff"; btn.style.color = ""; btn.style.fontWeight = "normal"; }
 } else {
 addTerrain();
 map.easeTo({ pitch: 55, duration: 800 });
 if (btn) { btn.style.background = "#e05c2a"; btn.style.color = "#fff"; btn.style.fontWeight = "bold"; }
 }
 },
 togglePlay() {
 const btn = document.getElementById(MAP_ID + "-playbtn");
 if (animPlaying) {
 stopFly(false);
 } else {
 if (!flyMarker) { console.warn("Map not ready yet"); return; }
 
 if (animPos &gt;= track.coord.length - 1) {
 animPos = 0;
 updateProgressLine(0);
 }
 animPlaying = true;
 lastTime = null;
 if (btn) { btn.textContent = "⏸ Pause"; btn.style.background = "#e05c2a"; btn.style.color = "#fff"; }
 animFrame = requestAnimationFrame(animStep);
 }
 },
 seekTo(idx) {
 animPos = idx;
 updateProgressLine(idx);
 onScrub(idx);
 if (profileCtrl) profileCtrl.setAnimIdx(idx);
 if (flyMarker) {
 flyMarker.setLngLat(track.coord[idx]);
 flyEl.style.display = "block";
 }
 },
 toggleUnits() {
 useMetric = !useMetric;
 const btn = document.getElementById(MAP_ID + "-unitbtn");
 if (btn) btn.textContent = useMetric ? "km / mi" : "mi / km";
 updateStats(MAP_ID, trackStats.dist, trackStats.eleGain, trackStats.eleLoss,
 trackStats.eleMin, trackStats.eleMax);
 },
 rotateby(deg) {
 map.easeTo({ bearing: map.getBearing() + deg, duration: 300 });
 },
 tiltby(deg) {
 const next = Math.max(0, Math.min(85, map.getPitch() + deg));
 map.easeTo({ pitch: next, duration: 300 });
 },
 resetNorth() {
 map.easeTo({ bearing: 0, pitch: 0, duration: 500 });
 },
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() {
 const mapEl = document.getElementById(MAP_ID);
 const btn = document.getElementById(MAP_ID + "-sizebtn");
 const sizes = [
 { label: "↙ SM", action: "collapse" },
 { label: "▭ MD", height: "500px" },
 { label: "▬ LG", height: "800px" },
 ];
 
 const fullEl = document.getElementById(MAP_ID + "-full");
 const isCollapsed = fullEl.style.display === "none";
 let current = isCollapsed ? 0
 : mapEl.style.height === "800px" ? 2 : 1;
 const next = (current + 1) % sizes.length;
 const s = sizes[next];
 if (s.action === "collapse") {
 collapseMap();
 } else {
 if (isCollapsed) expandMap();
 mapEl.style.height = s.height;
 map.resize();
 }
 if (btn) btn.textContent = sizes[(next + 1) % sizes.length].label;
 },
 };
 } 

 
 window.gpxmaps[MAP_ID] = {
 expand: expandMap,
 collapse: collapseMap,
 cycleSize() { if (fullMapInited) window.gpxmaps[MAP_ID].cycleSize(); else expandMap(); },
 
 togglePlay() { if (fullMapInited) window.gpxmaps[MAP_ID].togglePlay(); },
 setStyle(n) { if (fullMapInited) window.gpxmaps[MAP_ID].setStyle(n); },
 toggleTerrain(){ if (fullMapInited) window.gpxmaps[MAP_ID].toggleTerrain(); },
 seekTo(i) { if (fullMapInited) window.gpxmaps[MAP_ID].seekTo(i); },
 toggleUnits() { if (fullMapInited) window.gpxmaps[MAP_ID].toggleUnits(); },
 rotateby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].rotateby(d); },
 tiltby(d) { if (fullMapInited) window.gpxmaps[MAP_ID].tiltby(d); },
 resetNorth() { if (fullMapInited) window.gpxmaps[MAP_ID].resetNorth(); },
 };
 }

 
 if (document.readyState === "loading") {
 document.addEventListener("DOMContentLoaded", init);
 } else {
 init();
 }
})();
&lt;/script&gt;


&lt;div class="justified-gallery" id="gallery"&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_01.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_01_hu_57a7f19cb0d57f4b.jpg" alt="gallery/via_ferrata_craig_01.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_06.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_06_hu_28ddf26a7b9717.jpg" alt="gallery/via_ferrata_craig_06.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_09.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_09_hu_cb6ab720c10006dd.jpg" alt="gallery/via_ferrata_craig_09.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_11.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_11_hu_1ef42dcd0ecf4e1b.jpg" alt="gallery/via_ferrata_craig_11.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_18.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_18_hu_acc91b96e01c6bb7.jpg" alt="gallery/via_ferrata_craig_18.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_20.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_20_hu_b33b8de9be7e89cc.jpg" alt="gallery/via_ferrata_craig_20.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_22.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_22_hu_d267da816f85a4e2.jpg" alt="gallery/via_ferrata_craig_22.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_23.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_23_hu_4b3fd7c00c7da873.jpg" alt="gallery/via_ferrata_craig_23.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_26.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_26_hu_62da9f36559e246.jpg" alt="gallery/via_ferrata_craig_26.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_31.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_31_hu_bf5d7170f27e7a61.jpg" alt="gallery/via_ferrata_craig_31.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_36.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_36_hu_9a6f79eafe69763b.jpg" alt="gallery/via_ferrata_craig_36.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_39.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_39_hu_b6241528360a6a30.jpg" alt="gallery/via_ferrata_craig_39.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_46.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_46_hu_deb3acf65a3b94ed.jpg" alt="gallery/via_ferrata_craig_46.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
 &lt;a href="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_50.jpg" class="glightbox" data-gallery="gallery"&gt;
 
 &lt;img src="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/gallery/via_ferrata_craig_50_hu_5d3b1036c29353f1.jpg" alt="gallery/via_ferrata_craig_50.jpg" class="nozoom" &gt;
 &lt;/a&gt;
 
&lt;/div&gt;</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2026/via-ferrata-with-craig/feature.jpg"/></item><item><title>Mt. Taranaki</title><link>https://blog.willprogramforfood.com/posts/2025/mt-taranaki/</link><pubDate>Sun, 16 Nov 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/mt-taranaki/</guid><description>I went over to New Plymouth to climb Mt. Taranaki.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/mt-taranaki/feature.jpg"/></item><item><title>Mt. Robertson with JJ</title><link>https://blog.willprogramforfood.com/posts/2025/mt-robertson-jj/</link><pubDate>Sat, 21 Jun 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/mt-robertson-jj/</guid><description>Hike up Mt. Robertson/Tokomaru in the cold with JJ.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/mt-robertson-jj/feature.jpg"/></item><item><title>Mt. Patriarch</title><link>https://blog.willprogramforfood.com/posts/2025/mt-patriarch/</link><pubDate>Sun, 15 Jun 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/mt-patriarch/</guid><description>Small hike, long drive, up to Mt. Patriarch.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/mt-patriarch/feature.jpg"/></item><item><title>Hike up to Parachute Rocks</title><link>https://blog.willprogramforfood.com/posts/2025/parachute-rocks/</link><pubDate>Sat, 07 Jun 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/parachute-rocks/</guid><description>Went up to St. Arnaud to hike above Parachute Rocks.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/parachute-rocks/feature.jpg"/></item><item><title>Black Jack hike</title><link>https://blog.willprogramforfood.com/posts/2025/black-jack/</link><pubDate>Sun, 11 May 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/black-jack/</guid><description>Small hike out of Whites Bay/Pukatea in the forest. More fun was had at the end playing at the beach.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/black-jack/feature.jpg"/></item><item><title>Mt. Vernon with friends</title><link>https://blog.willprogramforfood.com/posts/2025/mt-vernon/</link><pubDate>Sun, 04 May 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/mt-vernon/</guid><description>Went up Mt. Vernon with Oscar and his buddies.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/mt-vernon/feature.jpg"/></item><item><title>Angelus Hut hike</title><link>https://blog.willprogramforfood.com/posts/2025/angelus-hut/</link><pubDate>Sun, 27 Apr 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/angelus-hut/</guid><description>Craig and I got up early and went up to St. Arnaud to hike to Angelus Hut.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/angelus-hut/feature.jpg"/></item><item><title>Shipwreck Hike</title><link>https://blog.willprogramforfood.com/posts/2025/shipwreck-hike/</link><pubDate>Fri, 18 Apr 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/shipwreck-hike/</guid><description>Made it to the shipwreck out at the lagoon with Ethan&amp;rsquo;s family.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/shipwreck-hike/feature.jpg"/></item><item><title>Mt Arthur</title><link>https://blog.willprogramforfood.com/posts/2025/mt-arthur/</link><pubDate>Tue, 25 Feb 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/mt-arthur/</guid><description>Hike with Craig up Mt. Arthur.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/mt-arthur/feature.jpg"/></item><item><title>Via Ferrata</title><link>https://blog.willprogramforfood.com/posts/2025/via-ferrata/</link><pubDate>Sat, 22 Feb 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/via-ferrata/</guid><description>I went with JJ to the Via Ferrata Takaka Hill near Motueka. It was super awesome and we did two laps as it took about 20-30 min a lap and we drove 3 hours.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/via-ferrata/feature.jpg"/></item><item><title>Mt. Sunday and Mt. Riley</title><link>https://blog.willprogramforfood.com/posts/2025/mt-sunday-and-mt-riley/</link><pubDate>Sun, 09 Feb 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/mt-sunday-and-mt-riley/</guid><description>Craig and I hiked up Mt. Sunday and Mt.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/mt-sunday-and-mt-riley/feature.jpg"/></item><item><title>Mt Roberston</title><link>https://blog.willprogramforfood.com/posts/2025/mt-roberston/</link><pubDate>Wed, 22 Jan 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/mt-roberston/</guid><description>Redemption on Mt. Robertson with Craig.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/mt-roberston/feature.jpg"/></item><item><title>Mt Fishtail</title><link>https://blog.willprogramforfood.com/posts/2025/mt-fishtail/</link><pubDate>Thu, 16 Jan 2025 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2025/mt-fishtail/</guid><description>Hike up Fishtail with Craig. Was super nauseated from the gila monster venom, had to stop quite frew times and even threw up near the top.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2025/mt-fishtail/feature.jpg"/></item><item><title>The Ned</title><link>https://blog.willprogramforfood.com/posts/2024/the-ned/</link><pubDate>Sun, 15 Dec 2024 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2024/the-ned/</guid><description>Hiked the Ned with the tramping club and Craig. It was uber hot out and I got some level of heat exhaustion and had my heartrate pegged above 160, resting about 40 higher than normal.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2024/the-ned/feature.jpg"/></item><item><title>Mt. Fyfee</title><link>https://blog.willprogramforfood.com/posts/2024/mt-fyfee/</link><pubDate>Sat, 09 Nov 2024 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2024/mt-fyfee/</guid><description>Solo camp on Mt. Fyfee hut near Kaikoura.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2024/mt-fyfee/feature.jpg"/></item><item><title>Hike up Mt. Robertson</title><link>https://blog.willprogramforfood.com/posts/2024/hike-up-mt-robertson/</link><pubDate>Sun, 28 Jan 2024 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2024/hike-up-mt-robertson/</guid><description>Mt. Robertson, first real solo hike outside of the Wither Hills.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2024/hike-up-mt-robertson/feature.jpg"/></item><item><title>Hike to Speargrass Hut</title><link>https://blog.willprogramforfood.com/posts/2024/hike-to-speargrass-hut/</link><pubDate>Sat, 13 Jan 2024 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2024/hike-to-speargrass-hut/</guid><description>Our second overnight tramp was to Speargrass Hut. It was a longer way in than last time but Oscar made it through pretty easily.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2024/hike-to-speargrass-hut/feature.jpg"/></item><item><title>Mt Aspiring</title><link>https://blog.willprogramforfood.com/posts/2023/mt-aspiring/</link><pubDate>Fri, 15 Dec 2023 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2023/mt-aspiring/</guid><description>I went down to Wanaka to do a guided climb of Mt. Aspiring.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2023/mt-aspiring/feature.jpg"/></item><item><title>Bushline Tramp</title><link>https://blog.willprogramforfood.com/posts/2023/bushline-tramp/</link><pubDate>Sat, 18 Nov 2023 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2023/bushline-tramp/</guid><description>We went on our first overnight tramp to Bushline hut. It was a little hard going in, but then Oscar had a great time playing around with some other kids up there.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2023/bushline-tramp/feature.jpg"/></item><item><title>Mt Rimu</title><link>https://blog.willprogramforfood.com/posts/2023/mt-rimu/</link><pubDate>Sun, 08 Oct 2023 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2023/mt-rimu/</guid><description>Tramp up [Mt Rimu)(https://www.topomap.co.nz/NZTopoMap/nz41388/Mount-Rimu/) with the club.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2023/mt-rimu/feature.jpg"/></item><item><title>Rahota</title><link>https://blog.willprogramforfood.com/posts/2023/rahota/</link><pubDate>Sun, 03 Sep 2023 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2023/rahota/</guid><description>Another Sunday hike with the tramping club.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2023/rahota/feature.jpg"/></item><item><title>Mt. Impy and Stronvar</title><link>https://blog.willprogramforfood.com/posts/2023/mt-impy-and-stronvar/</link><pubDate>Sun, 13 Aug 2023 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2023/mt-impy-and-stronvar/</guid><description>I joined the Marlborough Tramping Club and went up Mt. Impy and Stronvar.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2023/mt-impy-and-stronvar/feature.jpg"/></item><item><title>Oscar hike to fort</title><link>https://blog.willprogramforfood.com/posts/2023/oscar-hike-to-fort/</link><pubDate>Fri, 14 Jul 2023 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2023/oscar-hike-to-fort/</guid><description>Hike up to the trees with hidden forts up in the hills.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2023/oscar-hike-to-fort/feature.jpg"/></item><item><title>Bachelor Skiing</title><link>https://blog.willprogramforfood.com/posts/2022/bachelor-skiing/</link><pubDate>Thu, 15 Dec 2022 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2022/bachelor-skiing/</guid><description>Skiing and overnight with Trey at Bachelor.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2022/bachelor-skiing/feature.jpg"/></item><item><title>South Sister Solo</title><link>https://blog.willprogramforfood.com/posts/2022/south-sister-solo/</link><pubDate>Thu, 08 Sep 2022 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2022/south-sister-solo/</guid><description>Quick trip over to climb South Sister. Great weather and conditions on way up to having summit to myself for an hour.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2022/south-sister-solo/feature.jpg"/></item><item><title>Mt. Adams Solo 2022</title><link>https://blog.willprogramforfood.com/posts/2022/mt-adams-solo-2022/</link><pubDate>Wed, 29 Jun 2022 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2022/mt-adams-solo-2022/</guid><description>Plan was to camp on the summit but pack was demoralizingly heavy and the postholing was real. Camped at Picnic Counter and hung out in tent until morning for summit push.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2022/mt-adams-solo-2022/feature.jpg"/></item><item><title>Deschutes River Gravel Ride</title><link>https://blog.willprogramforfood.com/posts/2021/deschutes-river-gravel-ride/</link><pubDate>Sun, 12 Sep 2021 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2021/deschutes-river-gravel-ride/</guid><description>Took my skinny tires up to Deschutes River for an easy gravel bikepacking ride. Easy ride up the gravel road to a camp site near the river.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2021/deschutes-river-gravel-ride/feature.jpg"/></item><item><title>Mt Adams with Trey &amp; Dan</title><link>https://blog.willprogramforfood.com/posts/2021/mt-adams-with-trey-dan/</link><pubDate>Sat, 19 Jun 2021 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2021/mt-adams-with-trey-dan/</guid><description>Trey and I had plan to camp on summit of Mt. Adams, like South Sister last month.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2021/mt-adams-with-trey-dan/feature.jpg"/></item><item><title>South Sister with Trey</title><link>https://blog.willprogramforfood.com/posts/2021/south-sister-with-trey/</link><pubDate>Sat, 29 May 2021 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2021/south-sister-with-trey/</guid><description>Trey and I trudged up South Sister to bivy on the summit. We got there way past dark and it was chilly as I saved 6oz by leaving the tent fly in the car.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2021/south-sister-with-trey/feature.jpg"/></item><item><title>Mt. Hood Hogsback 2021</title><link>https://blog.willprogramforfood.com/posts/2021/mt-hood-hogsback-2021/</link><pubDate>Sat, 15 May 2021 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2021/mt-hood-hogsback-2021/</guid><description>Slow climb up Mt. Hood with zero training longer that 30 minutes.</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2021/mt-hood-hogsback-2021/feature.jpg"/></item><item><title>South Sister Ski</title><link>https://blog.willprogramforfood.com/posts/2013/south-sister-2/</link><pubDate>Wed, 01 May 2013 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2013/south-sister-2/</guid><description>Ski in</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2013/south-sister-2/feature.jpg"/></item><item><title>Mt. Jefferson bike tour mountaineering</title><link>https://blog.willprogramforfood.com/posts/2012/mt-jefferson-bike-tour-mountaineering/</link><pubDate>Mon, 23 Jul 2012 00:00:00 +0000</pubDate><author>hipposareevil@protonmail.com (Sam Jackson)</author><guid>https://blog.willprogramforfood.com/posts/2012/mt-jefferson-bike-tour-mountaineering/</guid><description>Approach</description><media:content xmlns:media="http://search.yahoo.com/mrss/" url="https://blog.willprogramforfood.com/posts/2012/mt-jefferson-bike-tour-mountaineering/feature.jpg"/></item></channel></rss>