Go + Templ + HTMX app that checks the weather (Open-Meteo, MET Norway fallback), scores run/ride/walk with plain rules, and has a local open-weight model (Gemma 4 E2B via any OpenAI-compatible server) write the plan. Routes are loops from your door (BRouter) plus signposted OpenStreetMap routes (Overpass), with GPX export.
135 lines
5.2 KiB
JavaScript
135 lines
5.2 KiB
JavaScript
// Small touches on top of HTMX: "📍 Here", remembering the form, the route
|
|
// map, quick follow-up chips and Enter-to-send.
|
|
(() => {
|
|
const ask = document.getElementById("ask");
|
|
const place = ask.elements.place;
|
|
const lat = ask.elements.lat;
|
|
const lon = ask.elements.lon;
|
|
const locate = document.getElementById("locate");
|
|
|
|
// Remember the last choices in this browser only.
|
|
const KEY = "workout-suggester:form";
|
|
const save = () => {
|
|
const d = Object.fromEntries(new FormData(ask));
|
|
try { localStorage.setItem(KEY, JSON.stringify(d)); } catch {}
|
|
};
|
|
try {
|
|
const d = JSON.parse(localStorage.getItem(KEY) || "{}");
|
|
for (const [k, v] of Object.entries(d)) {
|
|
const el = ask.elements[k];
|
|
if (!el) continue;
|
|
if (el instanceof RadioNodeList) {
|
|
for (const r of el) r.checked = r.value === v;
|
|
} else if (v) {
|
|
el.value = v;
|
|
}
|
|
}
|
|
if (lat.value) place.placeholder = "📍 your location";
|
|
} catch {}
|
|
ask.addEventListener("change", save);
|
|
ask.addEventListener("htmx:beforeRequest", save);
|
|
|
|
// Typing a place overrides the GPS fix.
|
|
place.addEventListener("input", () => {
|
|
lat.value = lon.value = "";
|
|
place.placeholder = "Town or city";
|
|
});
|
|
|
|
locate.addEventListener("click", () => {
|
|
if (!navigator.geolocation) return alert("This browser can't share its location. Type a town instead.");
|
|
locate.disabled = true;
|
|
locate.textContent = "📍 …";
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => {
|
|
lat.value = pos.coords.latitude.toFixed(5);
|
|
lon.value = pos.coords.longitude.toFixed(5);
|
|
place.value = "";
|
|
place.placeholder = "📍 your location";
|
|
locate.disabled = false;
|
|
locate.textContent = "📍 Here";
|
|
save();
|
|
ask.requestSubmit();
|
|
},
|
|
(err) => {
|
|
locate.disabled = false;
|
|
locate.textContent = "📍 Here";
|
|
alert("Couldn't get your location (" + err.message + "). Type a town instead.");
|
|
},
|
|
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 600000 }
|
|
);
|
|
});
|
|
|
|
// Draw route maps that arrive in HTMX swaps.
|
|
const COLORS = ["#2f8f4e", "#3b6cf6", "#c2410c"];
|
|
const drawMaps = (root) => {
|
|
if (!window.L) return;
|
|
for (const el of root.querySelectorAll(".map[data-map]:not(.leaflet-container)")) {
|
|
const d = JSON.parse(el.dataset.map);
|
|
// The view must exist before vector layers are added, or Leaflet throws.
|
|
const map = L.map(el, { scrollWheelZoom: false }).setView(d.you, 13);
|
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
maxZoom: 18,
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
}).addTo(map);
|
|
const layers = d.routes.map((r, i) =>
|
|
L.featureGroup([
|
|
L.polyline(r.ways, { color: COLORS[i % COLORS.length], weight: i === 0 ? 5 : 3, opacity: i === 0 ? 0.9 : 0.5 }),
|
|
L.circleMarker(r.join, { radius: 5, color: COLORS[i % COLORS.length], fillOpacity: 1 }).bindTooltip("Start here"),
|
|
]).bindTooltip(r.name, { sticky: true })
|
|
);
|
|
layers.forEach((l) => l.addTo(map));
|
|
L.circleMarker(d.you, { radius: 7, color: "#fff", weight: 2, fillColor: "#111", fillOpacity: 1 })
|
|
.bindTooltip("You").addTo(map);
|
|
const focus = (i) => {
|
|
layers.forEach((l, j) => l.eachLayer((x) => x.setStyle && x.setStyle({ weight: j === i ? 5 : 3, opacity: j === i ? 0.9 : 0.4 })));
|
|
layers[i].bringToFront();
|
|
map.fitBounds(layers[i].getBounds().extend(d.you), { padding: [20, 20] });
|
|
};
|
|
focus(0);
|
|
const list = el.parentElement.querySelectorAll("[data-route]");
|
|
list.forEach((li) =>
|
|
li.addEventListener("click", (e) => {
|
|
if (e.target.closest("a")) return;
|
|
list.forEach((x) => x.classList.toggle("active", x === li));
|
|
focus(+li.dataset.route);
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
// Follow-up chat: Enter to send, quick chips, auto-grow.
|
|
document.body.addEventListener("keydown", (e) => {
|
|
const t = e.target;
|
|
if (t.matches("#composer textarea") && e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
e.preventDefault();
|
|
if (t.value.trim()) t.form.requestSubmit();
|
|
}
|
|
});
|
|
document.body.addEventListener("input", (e) => {
|
|
if (e.target.matches("#composer textarea")) {
|
|
e.target.style.height = "auto";
|
|
e.target.style.height = e.target.scrollHeight + "px";
|
|
}
|
|
});
|
|
document.body.addEventListener("click", (e) => {
|
|
const chip = e.target.closest("[data-ask]");
|
|
if (!chip) return;
|
|
const form = document.getElementById("composer");
|
|
form.elements.message.value = chip.dataset.ask;
|
|
form.requestSubmit();
|
|
});
|
|
document.body.addEventListener("htmx:afterRequest", (e) => {
|
|
if (e.target.id === "composer" && e.detail.successful) {
|
|
e.target.reset();
|
|
e.target.elements.message.style.height = "auto";
|
|
}
|
|
});
|
|
|
|
// outerHTML swaps replace the event target, so just look everywhere.
|
|
document.body.addEventListener("htmx:afterSettle", (e) => {
|
|
drawMaps(document);
|
|
if (e.target.id === "plan") e.target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
});
|
|
addEventListener("load", () => drawMaps(document));
|
|
})();
|