// Leaflet maps, driven entirely by data attributes so templates stay plain
// HTML and hx-boost swaps just work:
//
// <div data-map
// data-tiles="https://tiles.neiam.org" tile server base
// data-lat=".." data-lon=".." data-zoom=".."
// data-markers='[{name, lat, lon, rank?, url?}]'
// data-pick="nearby|point"> optional, see below
//
// data-pick="nearby": a click asks data-nearby-url (default /places/nearby)
// what's there and swaps the answer into #candidates (and also drops a pin, for places OSM lacks).
// data-pick="point": a click only drops the pin.
// Either way the pin's position lands in #place-form's lat/lon inputs, and
// clicking a [data-candidate] button fills #place-form from its JSON.
import L from "leaflet";
// daisyUI theme -> osm-tiles theme, so the map matches the page.
const TILE_THEMES = {
light: "grayscale", lofi: "grayscale",
dark: "dark", black: "dark", stones: "dark",
her: "her", afterdark: "afterdark",
sky: "blueprint", forest: "night", clays: "night",
};
// Rank colours, as in the badges: flame / success / lime / info / neutral /
// error. Unranked is white, so blue can mean "to try".
const RANK_COLORS = {
obsessed: "#ea580c", always: "#16a34a", positive: "#84cc16", to_try: "#3b82f6",
neutral: "#a8a29e", never: "#dc2626",
};
const UNRANKED = "#ffffff";
const CANDIDATE = "#f59e0b";
const maps = new Set();
function tileTheme() {
return TILE_THEMES[document.documentElement.getAttribute("data-theme")] || "grayscale";
}
function tileLayer(base) {
return L.tileLayer(`${base}/${tileTheme()}/{z}/{x}/{y}.png`, {
maxZoom: 19,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
});
}
function dot(lat, lon, color, radius = 8) {
return L.circleMarker([lat, lon], {
radius, color: "#0008", weight: 1.5, fillColor: color, fillOpacity: 0.9,
});
}
function esc(s) {
const d = document.createElement("div");
d.textContent = s ?? "";
return d.innerHTML;
}
function placeForm() {
return document.getElementById("place-form");
}
function setPin(el, lat, lon) {
const st = el._fieri;
if (st.pin) st.pin.setLatLng([lat, lon]);
else st.pin = L.marker([lat, lon], {
icon: L.divIcon({ className: "", html: '<i class="fas fa-location-dot text-3xl text-primary drop-shadow"></i>', iconSize: [24, 32], iconAnchor: [12, 30] }),
}).addTo(st.map);
const form = placeForm();
if (form) {
form.elements.lat.value = lat.toFixed(7);
form.elements.lon.value = lon.toFixed(7);
}
}
function init(el) {
if (el._fieri) return;
const lat = parseFloat(el.dataset.lat), lon = parseFloat(el.dataset.lon);
const map = L.map(el).setView([lat, lon], parseInt(el.dataset.zoom || "15", 10));
const st = { map, base: el.dataset.tiles, tiles: tileLayer(el.dataset.tiles).addTo(map), candidates: L.layerGroup().addTo(map) };
el._fieri = st;
maps.add(el);
const markers = JSON.parse(el.dataset.markers || "[]");
for (const m of markers) {
const marker = dot(m.lat, m.lon, m.rank ? RANK_COLORS[m.rank] : UNRANKED).addTo(map);
const label = m.url ? `<a href="${esc(m.url)}">${esc(m.name)}</a>` : esc(m.name);
marker.bindTooltip(esc(m.name));
marker.bindPopup(label + (m.rank ? ` <span class="opacity-70">· ${esc(m.rank)}</span>` : ""));
}
if (markers.length > 1 && el.dataset.fit !== undefined) {
map.fitBounds(L.latLngBounds(markers.map((m) => [m.lat, m.lon])).pad(0.15), { maxZoom: 16 });
}
const pick = el.dataset.pick;
if (pick) {
map.on("click", (e) => {
setPin(el, e.latlng.lat, e.latlng.lng);
// A hand-placed pin is no longer the OSM element picked earlier.
const osmRef = placeForm()?.elements.osm_ref;
if (osmRef) osmRef.value = "";
if (pick === "nearby") {
const base = el.dataset.nearbyUrl || "/places/nearby";
const sep = base.includes("?") ? "&" : "?";
htmx.ajax("GET", `${base}${sep}lat=${e.latlng.lat}&lon=${e.latlng.lng}`, {
target: "#candidates", select: "#candidates", swap: "outerHTML",
});
}
});
}
// The container may have been laid out after Leaflet measured it.
setTimeout(() => map.invalidateSize(), 0);
}
// Re-tile every live map when the theme changes.
function retheme() {
for (const el of maps) {
if (!el.isConnected) { el._fieri.map.remove(); maps.delete(el); continue; }
const st = el._fieri;
st.map.removeLayer(st.tiles);
st.tiles = tileLayer(st.base).addTo(st.map);
}
}
function activeMap() {
for (const el of maps) if (el.isConnected) return el;
return null;
}
// Plot a freshly swapped-in candidate list on the map.
function plotCandidates(root) {
const el = activeMap();
if (!el) return;
const st = el._fieri;
st.candidates.clearLayers();
const pts = [];
root.querySelectorAll("[data-candidate]").forEach((btn) => {
const c = JSON.parse(btn.dataset.candidate);
dot(c.lat, c.lon, CANDIDATE, 7).bindTooltip(esc(c.name)).on("click", (ev) => {
L.DomEvent.stopPropagation(ev);
btn.click();
}).addTo(st.candidates);
pts.push([c.lat, c.lon]);
});
// Searches can land anywhere; nearby results are already on screen.
if (pts.length && root.dataset.fit !== undefined) {
st.map.fitBounds(L.latLngBounds(pts).pad(0.2), { maxZoom: 17 });
}
}
function fillFromCandidate(btn) {
const c = JSON.parse(btn.dataset.candidate);
const form = placeForm();
if (!form) return;
for (const [k, v] of Object.entries(c)) {
const input = form.elements[k];
if (input && input.type !== "radio") input.value = v ?? "";
}
const el = activeMap();
if (el) {
setPin(el, c.lat, c.lon);
el._fieri.map.setView([c.lat, c.lon], Math.max(el._fieri.map.getZoom(), 16));
}
document.querySelectorAll("[data-candidate].btn-active").forEach((b) => b.classList.remove("btn-active"));
btn.classList.add("btn-active");
form.querySelector("[name=name]")?.focus();
}
export function installMaps() {
htmx.onLoad((root) => {
// Drop maps whose element was swapped out by hx-boost navigation.
for (const el of maps) if (!el.isConnected) { el._fieri.map.remove(); maps.delete(el); }
if (root.matches?.("[data-map]")) init(root);
root.querySelectorAll?.("[data-map]").forEach(init);
if (root.id === "candidates") plotCandidates(root);
else root.querySelectorAll?.("#candidates").forEach(plotCandidates);
});
document.addEventListener("click", (e) => {
const btn = e.target.closest("[data-candidate]");
if (btn) { e.preventDefault(); fillFromCandidate(btn); }
});
const set = window.appSetTheme;
window.appSetTheme = (t) => { set(t); retheme(); };
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
6.6 KiB · text
History
7a00c02