6.3 KiB · text History 25c4237
// 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 /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: success / lime / neutral / error.
const RANK_COLORS = {
  always: "#16a34a", positive: "#84cc16", neutral: "#a8a29e", never: "#dc2626",
};
const UNRANKED = "#3b82f6";
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: '&copy; <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") {
        htmx.ajax("GET", `/places/nearby?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(); };
}