wohnungen: preserve map across HTMX polls, add rejected section, drop €/m²

- #flats-map uses hx-preserve; marker data moved to <script type="application/json">
  sibling that diffs+updates instead of rebuilding Leaflet every poll (fixes
  whitescreen + tiles rendering outside the card)
- upsert_flat backfills lat/lng on existing rows missing coords (older flats
  scraped before the lat/lng migration now appear on the map once alert re-submits)
- collapsible "Abgelehnte Wohnungen" section at the bottom with Wiederherstellen
- remove €/m² column from the list

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
EiSiMo 2026-04-21 13:33:55 +02:00
parent 4fd0b50a43
commit 51b6b02b24
4 changed files with 155 additions and 45 deletions

View file

@ -1,17 +1,75 @@
// lazyflat — Leaflet flat map
// Initialised LAZILY: we only build the Leaflet instance when the container
// actually has a rendered size (> 0 height). Building it on a hidden 0×0
// container leaves Leaflet in a state where tiles never load.
// 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 buildMap(el) {
if (mapInstance) {
try { mapInstance.remove(); } catch (e) {}
mapInstance = null;
function readMapData() {
const script = document.getElementById("flats-map-data");
if (!script) return [];
try {
const data = JSON.parse(script.textContent || "[]");
return Array.isArray(data) ? data : [];
} catch (e) {
return [];
}
}
function fingerprintOf(data) {
return data
.map((f) => `${f.lat},${f.lng},${f.address || ""},${f.rent || ""}`)
.join("|");
}
function renderMarkers(data) {
if (!mapInstance) return;
const fp = fingerprintOf(data);
if (fp === currentFingerprint) return;
currentFingerprint = fp;
if (markerLayer) {
markerLayer.clearLayers();
} else {
markerLayer = L.layerGroup().addTo(mapInstance);
}
const bounds = [];
data.forEach((f) => {
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
const rent = f.rent ? Math.round(f.rent) + " €" : "";
const rooms = f.rooms ? f.rooms + " Zi" : "";
const size = f.size ? Math.round(f.size) + " m²" : "";
const meta = [rooms, size, rent].filter(Boolean).join(" · ");
const safeAddr = (f.address || "").replace(/</g, "&lt;");
const safeLink = (f.link || "#").replace(/"/g, "&quot;");
L.marker([f.lat, f.lng]).addTo(markerLayer).bindPopup(
`<b>${safeAddr}</b>` + (meta ? `<br>${meta}` : "") +
`<br><a href="${safeLink}" target="_blank" rel="noopener">Zur Anzeige →</a>`,
);
bounds.push([f.lat, f.lng]);
});
if (bounds.length === 1) {
mapInstance.setView(bounds[0], 14);
} else if (bounds.length > 1) {
mapInstance.fitBounds(bounds, { padding: [30, 30] });
}
}
function buildMap(el) {
mapInstance = L.map(el, {
zoomControl: false,
scrollWheelZoom: false,
@ -25,45 +83,30 @@ function buildMap(el) {
maxZoom: 18,
subdomains: "abc",
}).addTo(mapInstance);
let data = [];
try { data = JSON.parse(el.dataset.flats || "[]"); } catch (e) {}
const bounds = [];
data.forEach((f) => {
if (typeof f.lat !== "number" || typeof f.lng !== "number") return;
const rent = f.rent ? Math.round(f.rent) + " €" : "";
const rooms = f.rooms ? f.rooms + " Zi" : "";
const size = f.size ? Math.round(f.size) + " m²" : "";
const meta = [rooms, size, rent].filter(Boolean).join(" · ");
const safeAddr = (f.address || "").replace(/</g, "&lt;");
const safeLink = (f.link || "#").replace(/"/g, "&quot;");
L.marker([f.lat, f.lng]).addTo(mapInstance).bindPopup(
`<b>${safeAddr}</b>` + (meta ? `<br>${meta}` : "") +
`<br><a href="${safeLink}" target="_blank" rel="noopener">Zur Anzeige →</a>`,
);
bounds.push([f.lat, f.lng]);
});
if (bounds.length === 1) {
mapInstance.setView(bounds[0], 14);
} else if (bounds.length > 1) {
mapInstance.fitBounds(bounds, { padding: [30, 30] });
}
markerLayer = L.layerGroup().addTo(mapInstance);
}
function ensureMap() {
const el = document.getElementById("flats-map");
if (!el || typeof L === "undefined") return;
// Container not actually visible yet → bail, we'll retry when the view toggles.
// Hidden (0×0) container → tiles would never load; retry after view toggle.
if (el.clientHeight < 10) return;
// Existing instance bound to THIS element → just recheck size.
if (mapInstance && mapInstance._container === el) {
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) {}
return;
}
buildMap(el);
renderMarkers(readMapData());
}
function wireViewToggle() {
@ -72,9 +115,7 @@ function wireViewToggle() {
r.dataset.wired = "1";
r.addEventListener("change", (e) => {
try { localStorage.setItem("lazyflat_view_mode", e.target.value); } catch (err) {}
// Wait for CSS :has() to reflow, then build/size the map.
requestAnimationFrame(() => requestAnimationFrame(ensureMap));
// belt & suspenders — re-check a couple more times in case of layout shifts.
setTimeout(ensureMap, 120);
setTimeout(ensureMap, 400);
});
@ -88,7 +129,6 @@ function restoreView() {
const el = document.querySelector(`input[name="view_mode"][value="${stored}"]`);
if (el && !el.checked) {
el.checked = true;
// Manually dispatching change would bubble and double-fire; call directly.
requestAnimationFrame(() => requestAnimationFrame(ensureMap));
setTimeout(ensureMap, 120);
setTimeout(ensureMap, 400);
@ -98,7 +138,7 @@ function restoreView() {
function onReady() {
wireViewToggle();
restoreView();
ensureMap(); // handles the case where map view is already visible
ensureMap();
}
document.addEventListener("DOMContentLoaded", onReady);