lazyflat/web/static/map.js
EiSiMo 931e0bb8b7 map: clickable address + status chip + Bewerben/Ablehnen in Leaflet popups
- map_points payload now carries flat id, per-user status, can_apply, is_running
- Popup titles link to the listing; status chip mirrors the list (beworben /
  läuft… / fehlgeschlagen); Bewerben + Ablehnen submit via the same HTMX
  endpoints as the list, re-swapping #wohnungen-body
- csrf token rides on the script[data-csrf] sibling of #flats-map
- popupopen → htmx.process(popupEl) so hx-* on freshly injected DOM binds
- site-style .map-popup-* CSS hooked into Leaflet's popup wrapper

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:01:11 +02:00

200 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lazyflat — Leaflet flat map.
//
// Two important properties:
// 1. The map must only be *built* once the container has a real size, because
// Leaflet initialised on a hidden 0×0 element never loads its tiles.
// 2. The `#wohnungen-body` partial is re-swapped by HTMX every few seconds.
// To avoid rebuilding Leaflet (and all its tile/marker state) on every
// poll — which caused the whitescreen + out-of-frame glitches — the map
// container itself is preserved across swaps via `hx-preserve`, and the
// marker data is pushed in through a sibling <script id="flats-map-data">
// element that DOES get swapped. On each swap we diff markers against the
// fresh data and update in place.
let mapInstance = null;
let markerLayer = null;
let currentFingerprint = "";
const BERLIN_CENTER = [52.52, 13.405];
const BERLIN_ZOOM = 11;
function readMapData() {
const script = document.getElementById("flats-map-data");
if (!script) return { csrf: "", flats: [] };
try {
const flats = JSON.parse(script.textContent || "[]");
return {
csrf: script.dataset.csrf || "",
flats: Array.isArray(flats) ? flats : [],
};
} catch (e) {
return { csrf: "", flats: [] };
}
}
function fingerprintOf(data) {
return data
.map((f) =>
[f.id, f.lat, f.lng, (f.status && f.status.label) || "",
f.can_apply ? 1 : 0, f.is_running ? 1 : 0].join(","),
)
.join("|");
}
function escHtml(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function popupHtml(f, csrf) {
const addrText = f.address || f.link || "—";
const addr = escHtml(addrText);
const link = escHtml(f.link || "#");
const metaParts = [];
if (f.rooms) metaParts.push(escHtml(f.rooms) + " Zi");
if (f.size) metaParts.push(Math.round(f.size) + " m²");
if (f.rent) metaParts.push(Math.round(f.rent) + " €");
const meta = metaParts.join(" · ");
let html = `<div class="map-popup">` +
`<a class="map-popup-title" href="${link}" target="_blank" rel="noopener">${addr}</a>`;
if (meta) html += `<div class="map-popup-meta">${meta}</div>`;
if (f.status) {
html += `<div class="map-popup-status">` +
`<span class="chip chip-${escHtml(f.status.chip)}">${escHtml(f.status.label)}</span>` +
`</div>`;
}
html += `<div class="map-popup-actions">`;
if (f.can_apply) {
const confirm = `Bewerbung für ${addrText.replace(/"/g, "'")} starten?`;
html +=
`<form hx-post="/actions/apply" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
`<button class="btn btn-primary text-xs" type="submit"` +
(f.is_running ? " disabled" : "") +
` hx-confirm="${escHtml(confirm)}">` +
(f.is_running ? "läuft…" : "Bewerben") +
`</button>` +
`</form>`;
}
html +=
`<form hx-post="/actions/reject" hx-target="#wohnungen-body" hx-swap="outerHTML">` +
`<input type="hidden" name="csrf" value="${escHtml(csrf)}">` +
`<input type="hidden" name="flat_id" value="${escHtml(f.id)}">` +
`<button class="btn btn-ghost text-xs" type="submit"` +
` hx-confirm="Ablehnen und aus der Liste entfernen?">Ablehnen</button>` +
`</form>`;
html += `</div></div>`;
return html;
}
function renderMarkers(payload) {
if (!mapInstance) return;
const { csrf, flats } = payload;
const fp = fingerprintOf(flats);
if (fp === currentFingerprint) return;
currentFingerprint = fp;
const geo = flats.filter((f) => typeof f.lat === "number" && typeof f.lng === "number").length;
console.log(`[lazyflat.map] rendering ${geo}/${flats.length} markers`);
if (markerLayer) {
markerLayer.clearLayers();
} else {
markerLayer = L.layerGroup().addTo(mapInstance);
}
flats.forEach((f) => {
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
L.marker([f.lat, f.lng])
.addTo(markerLayer)
.bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true });
});
}
function buildMap(el) {
console.log(`[lazyflat.map] building Leaflet instance (container ${el.clientWidth}×${el.clientHeight})`);
mapInstance = L.map(el, {
zoomControl: false,
scrollWheelZoom: false,
doubleClickZoom: false,
boxZoom: false,
touchZoom: false,
keyboard: false,
}).setView(BERLIN_CENTER, BERLIN_ZOOM);
// CartoDB Voyager — clean, Google-Maps-ish base style.
L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png", {
attribution: "© OpenStreetMap · © CARTO",
subdomains: "abcd",
maxZoom: 19,
}).addTo(mapInstance);
markerLayer = L.layerGroup().addTo(mapInstance);
// Leaflet injects popup HTML directly into the DOM — HTMX hasn't scanned it,
// so the hx-* attributes on Bewerben/Ablehnen wouldn't bind. Poke htmx.process
// at the popup element each time one opens.
mapInstance.on("popupopen", (e) => {
const el = e.popup && e.popup.getElement();
if (el && window.htmx) {
window.htmx.process(el);
}
});
}
function ensureMap() {
const el = document.getElementById("flats-map");
if (!el || typeof L === "undefined") return;
// Hidden (0×0) container → tiles would never load; retry after view toggle.
if (el.clientHeight < 10) return;
if (!mapInstance) {
buildMap(el);
} else if (mapInstance._container !== el) {
// Container node changed (shouldn't happen thanks to hx-preserve, but
// defensive: rebuild so Leaflet rebinds to the live element).
try { mapInstance.remove(); } catch (e) {}
mapInstance = null;
markerLayer = null;
currentFingerprint = "";
buildMap(el);
} else {
try { mapInstance.invalidateSize(); } catch (e) {}
}
renderMarkers(readMapData());
}
function wireViewToggle() {
document.querySelectorAll('input[name="view_mode"]').forEach((r) => {
if (r.dataset.wired === "1") return;
r.dataset.wired = "1";
r.addEventListener("change", (e) => {
try { localStorage.setItem("lazyflat_view_mode", e.target.value); } catch (err) {}
requestAnimationFrame(() => requestAnimationFrame(ensureMap));
setTimeout(ensureMap, 120);
setTimeout(ensureMap, 400);
});
});
}
function restoreView() {
let stored = null;
try { stored = localStorage.getItem("lazyflat_view_mode"); } catch (e) {}
if (!stored) return;
const el = document.querySelector(`input[name="view_mode"][value="${stored}"]`);
if (el && !el.checked) {
el.checked = true;
requestAnimationFrame(() => requestAnimationFrame(ensureMap));
setTimeout(ensureMap, 120);
setTimeout(ensureMap, 400);
}
}
function onReady() {
wireViewToggle();
restoreView();
ensureMap();
}
document.addEventListener("DOMContentLoaded", onReady);
document.body && document.body.addEventListener("htmx:afterSwap", onReady);