sharedKey = new URLSearchParams(location.search).get("route")
fromList = new URLSearchParams(location.search).get("from") === "list"
sharedRoute = routes.features.find((f) => f.properties.key === sharedKey) ?? null
DEFAULT_TYPES = sharedRoute?.properties.type === "out-and-back" ? ["Out-and-back"] : ["Loop"]
DEFAULT_KM = sharedRoute
? [Math.min(PICKER.defaultKm[0], Math.floor(sharedRoute.properties.km)),
Math.max(PICKER.defaultKm[1], Math.ceil(sharedRoute.properties.km))]
: PICKER.defaultKm
viewof types = Inputs.checkbox(["Loop", "Out-and-back"], {
label: "Route type",
value: DEFAULT_TYPES
})
viewof km = rangeSlider({min: PICKER.km[0], max: PICKER.km[1], step: 0.5, value: DEFAULT_KM, label: "Distance"})
viewof again = Inputs.button("Another route")Bikes
Rides from the Park Centre on the trails open to bikes
Rides on every trail in the park that’s open to bikes: all but the hiking-only ones. Like the route of the day, they start and finish at the Park Centre (Cleveland Trail at W 16th Ave) and use streets only to link trails up, at bike distances. Everyone who opens this page on the same day, with the same choices, gets the same ride of the day; Another route shuffles; Routes lists them all. Where a ride crosses a street on a crosswalk, walk your bike.
maplibregl = require("maplibre-gl@5.24.0/dist/maplibre-gl.js")
START = [-123.22248, 49.25918] // Park Centre
EMPTY = ({type: "FeatureCollection", features: []})
labels = FileAttachment("labels.geojson").json()
amenitySites = FileAttachment("amenities.geojson").json()
amenity = import(new URL("amenities.js", document.baseURI).href)function rangeSlider({min, max, step, value, label}) {
const form = html`<form class="range2">
<label>${label}</label>
<div class="range2-track">
<input type="range" name="lo" min=${min} max=${max} step=${step} value=${value[0]} aria-label="Shortest">
<input type="range" name="hi" min=${min} max=${max} step=${step} value=${value[1]} aria-label="Longest">
</div>
<output name="out"></output>
</form>`;
const {lo, hi, out} = form.elements;
const track = form.querySelector(".range2-track");
const pct = (v) => `${(100 * (v - min)) / (max - min)}%`;
const update = () => {
form.value = [+lo.value, +hi.value];
out.value = `${lo.value}–${hi.value} km`;
track.style.setProperty("--lo", pct(lo.value));
track.style.setProperty("--hi", pct(hi.value));
};
form.addEventListener("input", (event) => {
// Thumbs can't pass each other.
if (+lo.value > +hi.value) {
if (event.target === lo) lo.value = hi.value;
else hi.value = lo.value;
}
update();
});
form.addEventListener("submit", (event) => event.preventDefault());
update();
return form;
}
function outline(f, size) {
// Route shape, north up, scaled to fit a square.
const pts = f.geometry.coordinates;
const k = Math.cos((pts[0][1] * Math.PI) / 180);
const xs = pts.map(([lon]) => lon * k), ys = pts.map(([, lat]) => lat);
const [x0, x1, y0, y1] = [Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys)];
const scale = (size - 12) / Math.max(x1 - x0, y1 - y0);
const dx = (size - (x1 - x0) * scale) / 2, dy = (size - (y1 - y0) * scale) / 2;
const P = pts.map((_, i) => [dx + (xs[i] - x0) * scale, size - dy - (ys[i] - y0) * scale]);
const line = P.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");
const i = Math.floor(P.length * 0.08); // an arrow early on shows the direction
const [ax, ay] = P[i], [bx, by] = P[Math.min(i + 3, P.length - 1)];
const angle = (Math.atan2(by - ay, bx - ax) * 180) / Math.PI;
return `<svg class="pc-outline" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
<polyline points="${line}" fill="none" stroke="#0b0b0b" stroke-width="2" stroke-linejoin="round"/>
<path d="M -5 -4 L 4 0 L -5 4 z" fill="#0b0b0b" transform="translate(${bx.toFixed(1)} ${by.toFixed(1)}) rotate(${angle.toFixed(0)})"/>
<circle cx="${P[0][0].toFixed(1)}" cy="${P[0][1].toFixed(1)}" r="4" fill="#fff" stroke="#0b0b0b" stroke-width="2"/>
<text x="${size - 2}" y="10" text-anchor="end" font-size="9" font-weight="700">N↑</text>
</svg>`;
}routes = d3.json(PICKER.file) // FileAttachment only takes a literal name
matching = routes.features.filter(
(f) =>
types.map((t) => t.toLowerCase()).includes(f.properties.type) &&
f.properties.km >= km[0] &&
f.properties.km <= km[1]
)
// Today in Vancouver, as YYYY-MM-DD: the seed for the route of the day.
today = new Date().toLocaleDateString("en-CA", {timeZone: "America/Vancouver"})
// FNV-1a string hash: turns the date into a number, the same on every phone.
function hash(text) {
let h = 2166136261;
for (const c of text) h = Math.imul(h ^ c.charCodeAt(0), 16777619);
return h >>> 0;
}
// mulberry32: random numbers from a seed. Same seed, same sequence, anywhere.
function seeded(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Today's order for the matching routes: a Fisher-Yates shuffle seeded by the
// date. Everyone gets the same order; "Another route" steps along it, so no
// route repeats until you've seen them all.
order = {
const random = seeded(hash(today));
const idx = matching.map((_, i) => i);
for (let i = idx.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[idx[i], idx[j]] = [idx[j], idx[i]];
}
return idx;
}
useShared =
sharedRoute !== null &&
again === 0 &&
types.join() === DEFAULT_TYPES.join() &&
km.join() === DEFAULT_KM.join()
position = matching.length ? again % matching.length : 0
route = useShared
? sharedRoute
: matching.length
? matching[order[position]]
: nullroute
? html`<div class="route-card">
<h3>${route.properties.name}</h3>
<p class="route-stats">
<strong>${route.properties.km.toFixed(1)} km</strong> ${route.properties.type} ·
<strong>↑ ${route.properties.gain_m} m ↓ ${route.properties.loss_m} m</strong> ·
${route.properties.trail_pct}% trail · ${route.properties.street_pct}% street
<span class="route-id">· route ${route.properties.id} ·
${useShared
? html`${fromList ? "from the route list" : "shared with you"} ·
<a href=${PICKER.list}>all routes</a>`
: `${again === 0 ? "route of the day" : "another route"}: ${position + 1} of ${matching.length} matching routes`}</span>
</p>
${sharedKey && !sharedRoute
? html`<p class="route-note">The shared route isn't in the current list
(routes are rebuilt when the trail data changes), so here's today's route.</p>`
: ""}
</div>`
: html`<p><em>No routes match. Widen the distance or tick another route type.</em></p>`map = {
const m = new maplibregl.Map({
container: mapDiv,
style: "https://tiles.openfreemap.org/styles/positron",
center: START,
zoom: 13.5,
attributionControl: {
customAttribution: "Trails © OpenStreetMap contributors (ODbL)"
}
});
m.addControl(new maplibregl.NavigationControl(), "top-right");
await new Promise((resolve) => m.on("load", resolve));
m.addSource("route", {type: "geojson", data: EMPTY});
m.addSource("hover", {type: "geojson", data: EMPTY});
m.addSource("labels", {type: "geojson", data: labels});
m.addSource("route-names", {type: "geojson", data: EMPTY});
const round = {"line-join": "round", "line-cap": "round"};
// Every named trail, faintly, so you can see what the route passes.
m.addLayer({id: "trails", type: "line", source: "labels",
filter: ["==", ["get", "kind"], "trail"], layout: round,
paint: {"line-color": "#6b8f71", "line-opacity": 0.8,
"line-width": ["interpolate", ["linear"], ["zoom"], 12, 0.8, 16, 2]}});
m.addLayer({id: "route-casing", type: "line", source: "route", layout: round,
paint: {"line-color": "#fcfcfb", "line-width": 8}});
m.addLayer({id: "route", type: "line", source: "route", layout: round,
paint: {"line-color": "#2a78d6", "line-width": 4}});
// Arrows along the line show which way to run.
m.addLayer({id: "route-arrows", type: "symbol", source: "route",
layout: {"symbol-placement": "line", "symbol-spacing": 80,
"text-field": "›", "text-font": ["Noto Sans Bold"],
"text-size": 22, "text-keep-upright": false,
"text-allow-overlap": true},
paint: {"text-color": "#0b0b0b"}});
// Names along the lines, for everything the route doesn't use; the route's
// own trails are named on the route itself (next layer, filled in below).
const along = (size, font) => ({
"symbol-placement": "line", "text-field": ["get", "name"], "text-font": [font],
"text-size": size, "text-max-angle": 45, "symbol-spacing": 250
});
const halo = {"text-halo-color": "#fcfcfb", "text-halo-width": 1.5};
m.addLayer({id: "street-labels", type: "symbol", source: "labels", minzoom: 13,
filter: ["==", ["get", "kind"], "street"],
layout: along(10.5, "Noto Sans Regular"),
paint: {"text-color": "#52514e", ...halo}});
m.addLayer({id: "trail-labels", type: "symbol", source: "labels", minzoom: 12.5,
filter: ["==", ["get", "kind"], "trail"],
layout: along(11, "Noto Sans Italic"),
paint: {"text-color": "#3d6b47", ...halo}});
m.addLayer({id: "route-names", type: "symbol", source: "route-names",
layout: {"text-field": ["get", "name"], "text-font": ["Noto Sans Bold"],
"text-size": 12, "text-variable-anchor": ["left", "right", "top", "bottom"],
"text-radial-offset": 0.6},
paint: {"text-color": "#1c5cab", "text-halo-color": "#fcfcfb", "text-halo-width": 2}});
amenity.addToMap(m, maplibregl, amenitySites);
// Where the pointer is on the elevation profile.
m.addLayer({id: "hover", type: "circle", source: "hover",
paint: {"circle-radius": 7, "circle-color": "#eb6834",
"circle-stroke-color": "#fcfcfb", "circle-stroke-width": 2}});
new maplibregl.Marker({color: "#0b0b0b"})
.setLngLat(START)
.setPopup(new maplibregl.Popup().setText("Start and finish: Park Centre"))
.addTo(m);
invalidation.then(() => m.remove());
return m;
}// Draw the chosen route and zoom to it.
{
map.getSource("route").setData(route ?? EMPTY);
// Name the route's stretches on the route: a bold label halfway along each
// named stretch of 300 m or more, from the cue sheet.
const cues = route ? route.properties.cues : [];
const profile = route ? route.properties.profile : []; // [km, m, lon, lat] every 50 m
const nearest = (km) => profile.reduce((a, b) => (Math.abs(b[0] - km) < Math.abs(a[0] - km) ? b : a));
const unnamed = new Set(["unnamed trail", "crossing", "path", "sidewalk", "street"]);
const named = cues.filter(
([label, , km]) => km >= 0.3 && !unnamed.has(label) && !label.startsWith("Cross ")
);
map.getSource("route-names").setData({
type: "FeatureCollection",
features: named
.map(([label, at, km]) => {
const [, , lon, lat] = nearest(at + km / 2);
return {type: "Feature", properties: {name: label},
geometry: {type: "Point", coordinates: [lon, lat]}};
})
});
// Trails named on the route drop their faint label; everything else keeps it
// (either spelling: "Salish Trail" = "Salish").
const keys = [...new Set(named.map(([label]) => label.replace(/ Trail$/, "")))];
const onRoute = ["in", ["get", "key"], ["literal", keys]];
map.setFilter("trail-labels", ["all", ["==", ["get", "kind"], "trail"], ["!", onRoute]]);
map.setFilter("street-labels", ["all", ["==", ["get", "kind"], "street"], ["!", onRoute]]);
if (route) {
const bounds = new maplibregl.LngLatBounds();
route.geometry.coordinates.forEach((c) => bounds.extend(c));
map.fitBounds(bounds, {padding: 40, duration: 500});
}
}profile = route
? route.properties.profile.map(([km, m, lon, lat]) => ({km, m, lon, lat}))
: []
// Toilets and water this route passes (not the ones at the start and finish).
passed = route ? amenity.onProfile(route.properties.passes_by, profile) : []
viewof here = {
if (!route) return html``;
const low = Math.floor((route.properties.low_m - 5) / 10) * 10;
return Plot.plot({
width, // the page column, same as the map
height: 170,
marginLeft: 44,
style: {fontSize: "12px"},
x: {label: "km →", domain: [0, route.properties.km]},
y: {label: "↑ elevation (m)", domain: [low, route.properties.high_m + (passed.length ? 20 : 5)], grid: true},
marks: [
Plot.areaY(profile, {x: "km", y1: low, y2: "m", fill: "#2a78d6", fillOpacity: 0.12}),
Plot.lineY(profile, {x: "km", y: "m", stroke: "#2a78d6", strokeWidth: 2}),
...amenity.profileMarks(Plot, passed),
Plot.ruleX(profile, Plot.pointerX({x: "km", stroke: "#52514e"})),
Plot.dot(profile, Plot.pointerX({x: "km", y: "m", fill: "#eb6834", r: 4})),
Plot.tip(profile, Plot.pointerX({
x: "km", y: "m",
title: (d) => [`${d.km.toFixed(1)} km · ${Math.round(d.m)} m`,
...amenity.passedNear(route.properties.passes_by, d.km)].join(" · ")
}))
]
});
}function gpx(f) {
const points = f.geometry.coordinates
.map(([lon, lat]) => `<trkpt lat="${lat}" lon="${lon}"></trkpt>`)
.join("\n ");
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="psp-trails" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<name>PSP ${f.properties.id}: ${f.properties.name} (${f.properties.km} km)</name>
<trkseg>
${points}
</trkseg>
</trk>
</gpx>`;
}
// Phones and tablets get the share sheet. Desktop browsers either have none
// or refuse (Chrome on a Mac: "Permission denied"), so there we copy or download.
touch = matchMedia("(pointer: coarse)").matches
async function shareSheet(data) {
if (!touch || !navigator.canShare?.(data)) return "unavailable";
try {
await navigator.share(data);
return "shared";
} catch (e) {
return e.name === "AbortError" ? "cancelled" : "failed";
}
}
// Save an image: to the share sheet (Photos, Strava, ...) or as a download.
async function saveImage(blob, name, title) {
const result = await shareSheet({files: [new File([blob], name, {type: "image/png"})], title});
if (result === "shared" || result === "cancelled") return;
const a = Object.assign(document.createElement("a"), {href: URL.createObjectURL(blob), download: name});
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
// Share: the phone's share sheet (messages, WhatsApp, email, Strava, ...).
// The text has a link that opens this exact route; the GPX goes along where
// the phone allows files. Otherwise the text goes to the clipboard.
async function shareRoute(f) {
const p = f.properties;
const link = `${location.origin}${location.pathname}?route=${p.key}`;
const text = [
`${p.name}: ${p.km.toFixed(1)} km ${p.type}, ↑${p.gain_m} m ↓${p.loss_m} m,`,
"from the Park Centre (Cleveland Trail & W 16th Ave)",
link,
"",
...p.directions.map(([at, full]) => `${at.toFixed(1)} km ${full}`)
].join("\n");
const title = `Pacific Spirit run: ${p.name}`;
const file = new File([gpx(f)], `psp-${p.id}.gpx`, {type: "application/gpx+xml"});
let result = await shareSheet({title, text, files: [file]});
if (result === "unavailable") result = await shareSheet({title, text});
if (result === "shared" || result === "cancelled") return result;
await navigator.clipboard.writeText(text);
return "copied";
}
function shareButton(f) {
const label = touch ? "Share" : "Copy link";
const button = html`<button class="btn btn-quarto" type="button"
title="The route's link, stats and directions, to paste into a message">${label}</button>`;
button.onclick = async () => {
const note = {copied: "Copied ✓", failed: "Couldn't copy"}[
await shareRoute(f).catch(() => "failed")
];
if (note) {
button.textContent = note;
setTimeout(() => (button.textContent = label), 2000);
}
};
return button;
}
// Image: the route as a picture for Strava, Instagram or the group chat.
// 1080 x 1350 (4:5 portrait): the map as shown, name, stats, elevation, the
// trails in order, and credits.
async function routeImage(f) {
const p = f.properties;
const W = 1080, H = 1350, pad = 48;
const font = (weight, size) => `${weight} ${size}px system-ui, -apple-system, "Segoe UI", sans-serif`;
const canvas = Object.assign(document.createElement("canvas"), {width: W, height: H});
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#fcfcfb";
ctx.fillRect(0, 0, W, H);
// The map, read straight after it draws (cover-fit into the top of the image).
const snapshot = await new Promise((resolve) => {
map.once("render", () => resolve(map.getCanvas().toDataURL("image/png")));
map.triggerRepaint();
});
const img = new Image();
await new Promise((resolve) => { img.onload = resolve; img.src = snapshot; });
const mapH = 760, scale = Math.max(W / img.width, mapH / img.height);
const [sw, sh] = [W / scale, mapH / scale];
ctx.drawImage(img, (img.width - sw) / 2, (img.height - sh) / 2, sw, sh, 0, 0, W, mapH);
// Name (shrunk to fit), stats and start.
let y = mapH + 70, size = 52;
ctx.fillStyle = "#0b0b0b";
do { ctx.font = font(700, size); size -= 2; } while (ctx.measureText(p.name).width > W - 2 * pad);
ctx.fillText(p.name, pad, y);
ctx.font = font(600, 32);
ctx.fillText(`${p.km.toFixed(1)} km ${p.type} · ↑${p.gain_m} m ↓${p.loss_m} m · ${p.trail_pct}% trail`, pad, y += 52);
ctx.fillStyle = "#52514e";
ctx.font = font(400, 24);
ctx.fillText("Start/finish: Park Centre, Cleveland Trail & W 16th Ave · Pacific Spirit Regional Park", pad, y += 40);
// Elevation line.
const prof = p.profile, top = y + 24, h = 90;
const [lo, hi] = [d3.min(prof, (d) => d[1]), d3.max(prof, (d) => d[1])];
const px = (km) => pad + (km / p.km) * (W - 2 * pad);
const py = (m) => top + h - ((m - lo) / Math.max(hi - lo, 1)) * h;
ctx.beginPath();
prof.forEach(([km, m], i) => (i ? ctx.lineTo(px(km), py(m)) : ctx.moveTo(px(km), py(m))));
ctx.lineTo(px(p.km), top + h); ctx.lineTo(px(0), top + h); ctx.closePath();
ctx.fillStyle = "rgba(42, 120, 214, 0.15)"; ctx.fill();
ctx.beginPath();
prof.forEach(([km, m], i) => (i ? ctx.lineTo(px(km), py(m)) : ctx.moveTo(px(km), py(m))));
ctx.strokeStyle = "#2a78d6"; ctx.lineWidth = 3; ctx.stroke();
// The trails, in running order.
const unnamed = new Set(["unnamed trail", "crossing", "path", "sidewalk", "street"]);
const trails = p.cues.map(([label]) => label.replace(/ Trail$/, ""))
.filter((t, i, all) => !unnamed.has(t) && !t.startsWith("Cross ") && t !== all[i - 1]);
ctx.fillStyle = "#0b0b0b";
ctx.font = font(500, 26);
let line = "", ty = top + h + 56;
for (const word of trails.join(" › ").split(" ")) {
if (ctx.measureText(`${line} ${word}`).width > W - 2 * pad && line) {
ctx.fillText(line, pad, ty); ty += 36; line = word;
} else line = line ? `${line} ${word}` : word;
if (ty > H - 90) break;
}
if (ty <= H - 90) ctx.fillText(line, pad, ty);
// Footer.
ctx.fillStyle = "#52514e";
ctx.font = font(400, 20);
ctx.fillText(`pstrunners.github.io · route ${p.id}`, pad, H - 32);
ctx.textAlign = "right";
ctx.fillText("Map © OpenStreetMap contributors · OpenFreeMap", W - pad, H - 32);
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}
function imageButton(f) {
const button = html`<button class="btn btn-quarto" type="button">Image</button>`;
button.onclick = async () =>
saveImage(await routeImage(f), `psp-${f.properties.id}.png`, f.properties.name);
return button;
}
route
? html`<div class="route-actions">
${DOM.download(
() => new Blob([gpx(route)], {type: "application/gpx+xml"}),
`psp-${route.properties.id}.gpx`,
"Download GPX"
)}
${PICKER.share ? shareButton(route) : ""}
${imageButton(route)}
</div>`
: html``Every bike trail in one ride
The bike version of the Ultra: one ride over every trail open to bikes, from the Park Centre and back, with the same aid stations.
ultra = d3.json(ULTRA.file) // FileAttachment only takes a literal name
stats = ultra.stats
function kmRange([a, b]) {
const [from, to] = [Math.round(a), Math.round(b)];
return from === to ? `km ${from}` : `km ${from}–${to}`;
}
// "start, km 19–21, km 51, finish" for the Park Centre; just the km elsewhere.
function aidPasses(name, stops) {
const kms = stops.map(kmRange);
return name === "Park Centre" ? ["start", ...kms.slice(1, -1), "finish"].join(", ") : kms.join(", ");
}
// Aid station passes (km), the sure ones or the optional ones.
function aidKm(optional) {
return Object.entries(stats.aid)
.filter(([name]) => stats.aid_optional.includes(name) === optional)
.flatMap(([, stops]) => stops.map(([a]) => a));
}
// Labels for the passes mid-run, at the top of the profile.
function aidLabels(optional) {
return aidKm(optional).filter((km) => km > 0 && km < stats.km).map((km) => ({km}));
}
function onTheWay(kind) {
return stats.on_the_way.filter(([, , kinds]) => kinds.includes(kind)).map(kmRange);
}
water = onTheWay("water")
html`<div class="route-card">
<p class="route-stats">
<strong>${stats.km} km</strong> ·
<strong>↑ ${stats.gain_m} m ↓ ${stats.loss_m} m</strong> ·
every trail (${stats.trail_km} of ${stats.park_trail_km} km of ${ULTRA.trails}) ·
${stats.repeat_km} km ${ULTRA.done} twice · ${stats.street_pct}% street
</p>
<p class="route-stats">
<strong>Aid stations</strong>: ${Object.entries(stats.aid)
.map(([name, stops]) => `${stats.aid_optional.includes(name) ? "optional, if there's a second car: " : ""}${name} (${aidPasses(name, stops)})`)
.join("; ")} ·
<strong>Toilets on the way</strong>: ${onTheWay("toilets").join(", ")} ·
<strong>Water</strong>: the Park Centre${water.length ? `, and near ${water.join(", ")}` : " only"}
</p>
<p class="route-id">Short dead-end offshoots are optional: without them it's
${stats.km_without_offshoots} km (they add ${stats.offshoot_km} km, out and back).</p>
</div>`html`<div class="ultra-legend">
<span><i style="background:#2a78d6"></i>first time along</span>
<span><i style="background:#eb6834"></i>${ULTRA.done} a second time</span>
<span><i class="dashed"></i>optional offshoot</span>
<span><b class="km">5</b>km marker</span>
${Object.entries(amenity.AMENITY).map(([kind, text]) =>
html`<span><img src=${ICONS[kind].toDataURL()} alt="">${text.toLowerCase()}</span>`)}
</div>`ultraMap = {
const m = new maplibregl.Map({
container: ultraMapDiv,
style: "https://tiles.openfreemap.org/styles/positron",
center: START,
zoom: 12.5,
attributionControl: {customAttribution: "Trails © OpenStreetMap contributors (ODbL)"}
});
m.addControl(new maplibregl.NavigationControl(), "top-right");
m.addControl(new maplibregl.FullscreenControl(), "top-right");
await new Promise((resolve) => m.on("load", resolve));
m.addSource("labels", {type: "geojson", data: labels});
m.addSource("segments", {type: "geojson", data: ultra.segments});
m.addSource("points", {type: "geojson", data: ultra.points});
m.addSource("hover", {type: "geojson", data: EMPTY});
const round = {"line-join": "round", "line-cap": "round"};
const width = ["interpolate", ["linear"], ["zoom"], 12, 2.5, 16, 5];
const kind = (k) => ["==", ["get", "class"], k];
m.addLayer({id: "trails", type: "line", source: "labels", layout: round,
filter: ["==", ["get", "kind"], "trail"],
paint: {"line-color": "#6b8f71", "line-opacity": 0.6, "line-width": 1}});
// Stretches run twice are drawn beside the first pass, so both show.
m.addLayer({id: "repeat", type: "line", source: "segments", layout: round, filter: kind("repeat"),
paint: {"line-color": "#eb6834", "line-width": width, "line-offset": 3}});
m.addLayer({id: "first", type: "line", source: "segments", layout: round, filter: kind("first"),
paint: {"line-color": "#2a78d6", "line-width": width}});
m.addLayer({id: "optional", type: "line", source: "segments", filter: kind("optional"),
paint: {"line-color": "#4a3aa7", "line-width": width, "line-dasharray": [1, 1]}});
m.addLayer({id: "arrows", type: "symbol", source: "segments",
layout: {"symbol-placement": "line", "symbol-spacing": 120, "text-field": "›",
"text-font": ["Noto Sans Bold"], "text-size": 18,
"text-keep-upright": false, "text-allow-overlap": true,
"text-ignore-placement": true},
paint: {"text-color": "#0b0b0b"}});
const halo = {"text-halo-color": "#fcfcfb", "text-halo-width": 1.5};
const along = (size, font) => ({"symbol-placement": "line", "text-field": ["get", "name"],
"text-font": [font], "text-size": size, "text-max-angle": 45, "symbol-spacing": 250});
m.addLayer({id: "street-labels", type: "symbol", source: "labels", minzoom: 13,
filter: ["==", ["get", "kind"], "street"], layout: along(10.5, "Noto Sans Regular"),
paint: {"text-color": "#52514e", ...halo}});
m.addLayer({id: "trail-labels", type: "symbol", source: "labels", minzoom: 13,
filter: ["==", ["get", "kind"], "trail"], layout: along(11, "Noto Sans Italic"),
paint: {"text-color": "#3d6b47", ...halo}});
// km markers and toilets
m.addLayer({id: "km", type: "circle", source: "points", filter: ["==", ["get", "kind"], "km"],
paint: {"circle-radius": 11, "circle-color": "#fcfcfb",
"circle-stroke-color": "#0b0b0b", "circle-stroke-width": 2}});
m.addLayer({id: "km-labels", type: "symbol", source: "points", filter: ["==", ["get", "kind"], "km"],
layout: {"text-field": ["get", "label"], "text-font": ["Noto Sans Bold"],
"text-size": 11, "text-allow-overlap": true},
paint: {"text-color": "#0b0b0b"}});
// Toilets and water on the way (with the km of each pass, in the popup)
amenity.addToMap(m, maplibregl, {
type: "FeatureCollection",
features: ultra.points.features.filter((f) => f.properties.kind in amenity.AMENITY)
});
m.addLayer({id: "hover", type: "circle", source: "hover",
paint: {"circle-radius": 7, "circle-color": "#eb6834",
"circle-stroke-color": "#fcfcfb", "circle-stroke-width": 2}});
for (const f of ultra.points.features.filter((f) => f.properties.kind === "aid")) {
const main = f.properties.name === "Park Centre"; // also the start and finish
new maplibregl.Marker({color: main ? "#0b0b0b" : "#898781"})
.setLngLat(f.geometry.coordinates)
.setPopup(new maplibregl.Popup().setText(
`${main ? "Start, finish and aid station" : f.properties.optional
? "Optional aid station, if there's a second car" : "Aid station"}: ${f.properties.name}, ${f.properties.label}`))
.addTo(m);
}
const bounds = new maplibregl.LngLatBounds();
ultra.full.forEach((c) => bounds.extend(c));
m.fitBounds(bounds, {padding: 30, duration: 0});
invalidation.then(() => m.remove());
return m;
}ultraProfile = ultra.profile.map(([km, m, lon, lat]) => ({km, m, lon, lat}))
// Toilets and water on the way, as on the map (amenities.js).
ultraPassed = amenity.onProfile(stats.passes_by, ultraProfile)
// What's at this km, for the tooltip.
function whatsHere(km) {
const near = (a, b = a) => a - 0.15 <= km && km <= b + 0.15;
const what = Object.entries(stats.aid)
.filter(([, stops]) => stops.some(([a, b]) => near(a, b)))
.map(([name]) => `${stats.aid_optional.includes(name) ? "optional aid station" : "aid station"} (${name})`);
what.push(...amenity.passedNear(stats.passes_by, km));
return what.length ? ` · ${what.join(", ")}` : "";
}
viewof ultraHere = {
const profile = ultraProfile;
const low = Math.floor((d3.min(profile, (d) => d.m) - 5) / 10) * 10;
return Plot.plot({
width,
height: 190,
marginLeft: 44,
marginTop: 26, // room for the AS labels
style: {fontSize: "12px"},
x: {label: "km →", domain: [0, stats.km]},
y: {label: "↑ elevation (m)", domain: [low, d3.max(profile, (d) => d.m) + 20], grid: true},
marks: [
Plot.areaY(profile, {x: "km", y1: low, y2: "m", fill: "#2a78d6", fillOpacity: 0.12}),
Plot.lineY(profile, {x: "km", y: "m", stroke: "#2a78d6", strokeWidth: 1.5}),
// Aid station passes
// Aid stations: the Park Centre bold ("AS"); the optional one grey and
// dotted ("(AS)"), as it may not be there
Plot.ruleX(aidKm(false), {stroke: "#0b0b0b", strokeWidth: 1.5, strokeDasharray: "5,3"}),
Plot.ruleX(aidKm(true), {stroke: "#898781", strokeDasharray: "2,3"}),
Plot.text(aidLabels(false), {x: "km", text: () => "AS", frameAnchor: "top", dy: -8,
fontSize: 10, fontWeight: 700, fill: "#0b0b0b"}),
Plot.text(aidLabels(true), {x: "km", text: () => "(AS)", frameAnchor: "top", dy: -8,
fontSize: 10, fill: "#898781"}),
...amenity.profileMarks(Plot, ultraPassed),
Plot.ruleX(profile, Plot.pointerX({x: "km", stroke: "#52514e"})),
Plot.dot(profile, Plot.pointerX({x: "km", y: "m", fill: "#eb6834", r: 4})),
Plot.tip(profile, Plot.pointerX({x: "km", y: "m",
title: (d) => `${d.km.toFixed(1)} km · ${Math.round(d.m)} m${whatsHere(d.km)}`}))
]
});
}On the profile, AS marks the passes by the Park Centre aid station and a grey (AS) the optional one at King Edward; the WC and W dots are the toilets and water on the way.
function ultraGpx(name, coordinates) {
const points = coordinates.map(([lon, lat]) => `<trkpt lat="${lat}" lon="${lon}"></trkpt>`);
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="psp-trails" xmlns="http://www.topografix.com/GPX/1/1">
<trk>
<name>${name}</name>
<trkseg>
${points.join("\n ")}
</trkseg>
</trk>
</gpx>`;
}
html`<div class="route-actions">
${DOM.download(
() => new Blob([ultraGpx(`${ULTRA.gpxTitle} (${stats.km} km)`, ultra.full)],
{type: "application/gpx+xml"}),
`${ULTRA.gpxFile}.gpx`,
"Download GPX"
)}
${DOM.download(
() => new Blob([ultraGpx(`${ULTRA.gpxTitle}, no offshoots (${stats.km_without_offshoots} km)`, ultra.main)],
{type: "application/gpx+xml"}),
`${ULTRA.gpxFile}-no-offshoots.gpx`,
"GPX without offshoots"
)}
</div>`