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")Route of the day
From the Park Centre, Cleveland Trail at W 16th Ave
Pick a distance and a route type. Everyone who opens this page on the same day, with the same choices, gets the same route of the day; Another route shuffles. Routes stay on park trails as much as possible and use streets only to link trails up. Routes lists them all.
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``Printable card
For runners who’d rather leave the phone at home: the route’s outline and every turn with the km it comes at. Pick a card size, print, and cut along the dashed lines. The preview is the real printed size; the text shrinks until the whole route fits.
LAYOUTS = [
{n: 2, cols: 1, rows: 2, font: 13, size: "7.9 × 5.2 in"},
{n: 4, cols: 2, rows: 2, font: 10, size: "3.9 × 5.2 in"},
{n: 8, cols: 2, rows: 4, font: 8, size: "3.9 × 2.6 in, palm size"},
{n: 10, cols: 2, rows: 5, font: 8, size: "3.9 × 2.1 in, business card"},
{n: 12, cols: 3, rows: 4, font: 8, size: "2.6 × 2.6 in, sticky note"}
].map((l) => ({...l, w: 7.9 / l.cols, h: 10.4 / l.rows, compact: l.n >= 8}))
// Buttons, not a dropdown: always visible, and no oversized native menu.
viewof layout = Inputs.radio(LAYOUTS, {
label: "Cards per page",
format: (l) => String(l.n),
value: LAYOUTS[2]
})
html`<p class="layout-size">Each card ${layout.size}</p>`function esc(text) {
return String(text).replace(/[&<>"]/g, (c) => ({"&": "&", "<": "<", ">": ">", '"': """})[c]);
}
function cardHtml(f, layout) {
const p = f.properties;
const steps = p.directions
.map(([at, , short]) => `<li><b>${at.toFixed(1)}</b> ${esc(short)}</li>`)
.join("");
const stats = `${p.km.toFixed(1)} km ${esc(p.type)} · ↑${p.gain_m} ↓${p.loss_m} m`;
if (layout.compact) {
// Directions get the full height; outline, start and legend sit at the side.
const columns = layout.n === 10 ? 3 : 2; // short, wide cards: three columns
return `<div class="pc pc-compact">
<div class="pc-head"><span class="pc-name">${esc(p.name)}</span><span class="pc-stats">${stats}</span></div>
<ol class="pc-steps" style="columns: ${columns}">${steps}</ol>
<div class="pc-side">${outline(f, 58)}
<div>○ Park Centre</div>
<div class="pc-legend">←→ turn<br>↖↗ slight<br>T T-junction<br>Y fork<br>↑ straight<br>WC toilets</div>
<div>${esc(p.id)}</div>
</div>
</div>`;
}
return `<div class="pc">
<div class="pc-head">
<div class="pc-name">${esc(p.name)}</div>
<div class="pc-stats">${stats} · ${p.trail_pct}% trail</div>
</div>
<div class="pc-body">
<div class="pc-side">${outline(f, 110)}<div class="pc-start">○ Park Centre<br>Cleveland & W 16th</div></div>
<ol class="pc-steps">${steps}</ol>
</div>
<div class="pc-foot">
<span>← → turn · ↖↗ slight · ↙↘ sharp · T T-junction · Y fork · ↑ straight · WC toilets</span>
<span>${esc(p.id)} · pstrunners.github.io</span>
</div>
</div>`;
}
CARD_CSS = `
.pc { box-sizing: border-box; border: 1px dashed #898781; padding: 0.1in 0.12in;
font: 9pt/1.2 system-ui, -apple-system, "Segoe UI", sans-serif; color: #0b0b0b;
display: flex; flex-direction: column; overflow: hidden; background: #fff; }
.pc-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px;
border-bottom: 1px solid #0b0b0b; padding-bottom: 3px; margin-bottom: 4px; }
.pc-name { font-weight: 700; font-size: 1.15em; }
.pc-stats { white-space: nowrap; }
.pc-body { display: flex; gap: 8px; flex: 1; min-height: 0; }
.pc-side { flex: 0 0 auto; font-size: 0.8em; color: #52514e; }
.pc-steps { margin: 0; padding: 0; list-style: none; columns: 2; column-gap: 0.8em;
flex: 1; min-height: 0; overflow: hidden; }
.pc-steps li { break-inside: avoid; padding: 0.5px 0; }
.pc-steps b { display: inline-block; min-width: 2.1em; font-variant-numeric: tabular-nums; }
.pc-foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 8px;
font-size: 0.72em; color: #52514e; border-top: 1px solid #c3c2b7;
padding-top: 2px; margin-top: 3px; }
.pc-foot span:last-child { white-space: nowrap; }
.pc-compact { padding: 0.07in 0.09in; display: grid; grid-template-columns: 1fr auto;
grid-template-rows: auto 1fr; column-gap: 0.6em; }
.pc-compact .pc-head { grid-column: 1; flex-wrap: wrap; gap: 0 0.6em; margin-bottom: 3px; }
.pc-compact .pc-name { font-size: 1.05em; }
.pc-compact .pc-steps { grid-column: 1; grid-row: 2; }
.pc-compact .pc-side { grid-column: 2; grid-row: 1 / 3; width: 0.62in; display: flex;
flex-direction: column; gap: 3px; font-size: 0.8em; color: #52514e; }
.pc-compact .pc-outline { width: 0.62in; height: 0.62in; }
.pc-compact .pc-legend { line-height: 1.25; }
`
// Shrink the text until the directions fit on the card (no hidden overflow).
function fitCards(root, start) {
const cards = [...root.querySelectorAll(".pc")];
const card = cards[0], steps = card.querySelector(".pc-steps");
const over = () =>
card.scrollHeight > card.clientHeight + 1 ||
steps.scrollHeight > steps.clientHeight + 1 ||
steps.scrollWidth > steps.clientWidth + 1;
let size = start;
const apply = () => cards.forEach((c) => (c.style.fontSize = `${size}pt`));
apply();
while (over() && size > 5) {
size -= 0.25;
apply();
}
return size;
}
function printCards(f, layout) {
const page = `<!doctype html><html><head><meta charset="utf-8">
<title>PSP ${esc(f.properties.id)} cards</title>
<style>${CARD_CSS}
@page { size: letter; margin: 0.3in; }
body { margin: 0; }
.sheet { display: grid; width: 7.9in;
grid-template-columns: repeat(${layout.cols}, ${layout.w}in);
grid-template-rows: repeat(${layout.rows}, ${layout.h}in); }
</style></head>
<body><div class="sheet">${cardHtml(f, layout).repeat(layout.n)}</div>
<script>${fitCards.toString()}
window.onload = () => { fitCards(document, ${layout.font}); window.print(); };
<\/script></body></html>`;
const w = window.open("", "_blank");
w.document.write(page);
w.document.close();
}
// The card as a picture, to keep on the phone: the preview, each word drawn
// where the browser put it, at 4x (about 380 dpi). No dashed cut line.
async function cardImage(card) {
const k = 4, box = card.getBoundingClientRect();
const canvas = Object.assign(document.createElement("canvas"),
{width: Math.round(box.width * k), height: Math.round(box.height * k)});
const ctx = canvas.getContext("2d");
ctx.scale(k, k);
ctx.translate(-box.left, -box.top);
ctx.fillStyle = "#fff";
ctx.fillRect(box.left, box.top, box.width, box.height);
for (const el of card.querySelectorAll("*")) { // the rules under the name and over the key
const cs = getComputedStyle(el), r = el.getBoundingClientRect();
for (const side of ["Top", "Bottom"]) {
const w = parseFloat(cs[`border${side}Width`]);
if (!w || cs[`border${side}Style`] === "none") continue;
ctx.fillStyle = cs[`border${side}Color`];
ctx.fillRect(r.left, side === "Top" ? r.top : r.bottom - w, r.width, w);
}
}
for (const svg of card.querySelectorAll("svg")) { // the outline, drawn at full size
const r = svg.getBoundingClientRect(), img = new Image(), copy = svg.cloneNode(true);
copy.setAttribute("width", r.width * k);
copy.setAttribute("height", r.height * k);
copy.style.fontFamily = getComputedStyle(svg).fontFamily; // an image doesn't inherit it
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(copy))}`;
});
ctx.drawImage(img, r.left, r.top, r.width, r.height);
}
const words = document.createTreeWalker(card, NodeFilter.SHOW_TEXT), range = document.createRange();
ctx.textBaseline = "middle";
for (let node; (node = words.nextNode()); ) {
if (node.parentElement.closest("svg")) continue;
const cs = getComputedStyle(node.parentElement);
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
ctx.fillStyle = cs.color;
for (const m of node.data.matchAll(/\S+/g)) {
range.setStart(node, m.index);
range.setEnd(node, m.index + m[0].length);
const r = range.getBoundingClientRect();
if (r.width && r.bottom <= box.bottom && r.right <= box.right) {
ctx.fillText(m[0], r.left, (r.top + r.bottom) / 2);
}
}
}
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}
function cardImageButton(f) {
const button = html`<button class="btn btn-quarto" type="button"
title="The card as a picture, to keep on your phone">Card image</button>`;
button.onclick = async () => {
const card = cardPreview.querySelector(".pc");
saveImage(await cardImage(card), `psp-${f.properties.id}-card.png`, f.properties.name);
};
return button;
}
cardPreview = {
if (!route) return html``;
const box = html`<div class="card-preview"><style>${CARD_CSS}</style></div>`;
box.insertAdjacentHTML("beforeend", cardHtml(route, layout));
const card = box.querySelector(".pc");
card.style.width = `${layout.w}in`;
card.style.height = `${layout.h}in`;
yield box; // on the page, so the browser can measure it
fitCards(box, layout.font);
}Directions
Cue sheet
Every stretch of the route, as mapped: more literal than the directions.
route
? html`<details class="cue-details"><summary>Show every stretch</summary>
<table class="table table-sm cue-sheet">
<thead><tr><th>At km</th><th>Follow</th><th>For km</th></tr></thead>
<tbody>${route.properties.cues.map(([label, at, len]) =>
html`<tr><td>${at.toFixed(2)}</td><td>${label}</td><td>${len.toFixed(2)}</td></tr>`
)}</tbody>
</table>
</details>`
: html``Routes are generated from OpenStreetMap data and our own edits; elevation is NRCan lidar. Check trail closures and park rules before you go, and stay on marked trails.