LIBRARIES = ({
"Runs": {file: "routes.geojson", page: "rotd.html", km: [4, 12]},
"Bike rides": {file: "bike_routes.geojson", page: "bikes.html", km: [8, 24]}
})
viewof library = Inputs.radio(Object.keys(LIBRARIES), {
label: "Show",
value: new URLSearchParams(location.search).has("bikes") ? "Bike rides" : "Runs"
})
viewof types = Inputs.checkbox(["Loop", "Out-and-back"], {label: "Route type", value: ["Loop", "Out-and-back"]})
viewof km = rangeSlider({min: LIBRARIES[library].km[0], max: LIBRARIES[library].km[1], step: 0.5,
value: LIBRARIES[library].km, label: "Distance"})
viewof search = Inputs.text({label: "Search", placeholder: "Trail name or route number"})
viewof sortBy = Inputs.radio(["Distance", "Climb", "Trail"], {label: "Sort by", value: "Distance"})Routes
Every route in the library, by route number
All the routes the route of the day picks from, and the bike rides from the Bikes page. Narrow them down, then pick one to see its map, elevation profile, GPX and the rest.
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(LIBRARIES[library].file)
shown = {
const words = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
const key = {Distance: (p) => p.km, Climb: (p) => -p.gain_m, Trail: (p) => -p.trail_pct}[sortBy];
return routes.features
.filter(({properties: p}) =>
types.map((t) => t.toLowerCase()).includes(p.type) &&
p.km >= km[0] && p.km <= km[1] &&
words.every((w) => `${p.id} ${p.name}`.toLowerCase().includes(w)))
.sort((a, b) => key(a.properties) - key(b.properties) || a.properties.km - b.properties.km);
}
// A card per route, linking to its page; the outline is thinned to ~80 points.
function routeCard(f) {
const p = f.properties, coords = f.geometry.coordinates;
const every = Math.max(1, Math.floor(coords.length / 80));
const thin = {...f, geometry: {...f.geometry,
coordinates: coords.filter((_, i) => i % every === 0 || i === coords.length - 1)}};
const card = html`<a class="route-item" href=${`${LIBRARIES[library].page}?route=${p.key}&from=list`}>
<span class="route-thumb"></span>
<span class="route-text">
<span class="route-item-name"><b>${p.id}</b> ${p.name}</span>
<span class="route-item-stats">${p.km.toFixed(1)} km ${p.type} · ↑${p.gain_m} m · ${p.trail_pct}% trail</span>
</span>
</a>`;
card.querySelector(".route-thumb").innerHTML = outline(thin, 64);
return card;
}
html`<p class="route-id">${shown.length} of ${routes.features.length} ${library.toLowerCase()}</p>
<div class="route-list">${shown.map(routeCard)}</div>`Route numbers (L01, OB01; BL01 for bike rides) go shortest first, so they change when the routes are rebuilt; a route’s own link doesn’t.