lazyflat/web/static/map.js
EiSiMo 2ebbf76a80 experiment: shade excluded Bezirke on map (faint yellow overlay)
Tints districts the user's Bezirk filter has EXCLUDED in light yellow
(#fde68a, fillOpacity 0.35) so the active selection is obvious from
the map alone — and you can see at a glance whether a "rausgefiltert"
flat fell in a no-go district. When no district filter is set the
overlay stays off entirely (nothing is excluded).

Wiring:
- Berlin Bezirke GeoJSON checked in at web/static/berlin-districts.geojson
  (12 features, name property matches our DISTRICTS list 1:1, props
  stripped down to {name} and minified — 312KB raw, ~80KB gzipped).
- Route exposes the user's selected_districts_csv to the template.
- The flats-map-data <script> carries it on data-selected-districts so
  it flows in alongside csrf and the marker payload.
- map.js fetches the GeoJSON once (cache normally), keeps the layer in
  a module-level reference, and re-styles it via setStyle() on every
  swap (cheap). Marker layer is kicked to the front so pins always
  paint above the shaded polygons. fingerprintOf now also folds in
  the selected-districts CSV so a Bezirk-only filter change still
  triggers a re-render.

Branch only — kept off main while we see if this reads well.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 13:30:30 +02:00

296 lines
10 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 districtLayer = null;
let districtGeoJson = null;
let districtFetchStarted = false;
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: "", selectedDistricts: [], flats: [] };
let flats = [];
try {
const parsed = JSON.parse(script.textContent || "[]");
flats = Array.isArray(parsed) ? parsed : [];
} catch (e) {
flats = [];
}
const csv = script.dataset.selectedDistricts || "";
const selectedDistricts = csv
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return {
csrf: script.dataset.csrf || "",
selectedDistricts,
flats,
};
}
function fingerprintOf(data, selectedDistricts) {
const flatPart = 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("|");
// Include selected districts so changing the Bezirk filter re-renders the
// overlay even when no flat-level field changed.
return flatPart + "||" + selectedDistricts.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>`;
}
// Once the user has successfully applied, both action buttons disappear —
// matches the list view, which hides them on success too.
const applied = !!(f.status && f.status.chip === "ok");
if (!applied) {
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>`;
}
html += `</div>`;
return html;
}
// Custom pin icon — divIcon with inline SVG so we can color it per-state
// without shipping marker images. Successful apply → green; everything else
// → brand blue. Sized + anchored to roughly match Leaflet's default pin.
function pinIcon(color) {
const svg =
`<svg width="28" height="38" viewBox="0 0 28 38" xmlns="http://www.w3.org/2000/svg">` +
`<path d="M14 1C7 1 1 6.6 1 13.4c0 9.5 13 23.6 13 23.6s13-14.1 13-23.6C27 6.6 21 1 14 1z" ` +
`fill="${color}" stroke="rgba(0,0,0,0.25)" stroke-width="1"/>` +
`<circle cx="14" cy="13.5" r="4.5" fill="white"/>` +
`</svg>`;
return L.divIcon({
className: "lazyflat-pin",
html: svg,
iconSize: [28, 38],
iconAnchor: [14, 38],
popupAnchor: [0, -34],
});
}
function renderMarkers(payload) {
if (!mapInstance) return;
const { csrf, flats, selectedDistricts } = payload;
const fp = fingerprintOf(flats, selectedDistricts);
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;
const applied = !!(f.status && f.status.chip === "ok");
const color = applied ? "#1f8a4a" : "#2f8ae0";
L.marker([f.lat, f.lng], { icon: pinIcon(color) })
.addTo(markerLayer)
.bindPopup(popupHtml(f, csrf), { minWidth: 240, closeButton: true });
});
ensureDistrictLayer(selectedDistricts);
}
// EXPERIMENT: faintly highlight Bezirke the user's filter EXCLUDES so the
// active selection is obvious at a glance. When the filter is empty (no
// district narrowing) the layer is hidden — nothing is excluded.
function districtStyle(selectedSet) {
const showShading = selectedSet.size > 0;
return (feature) => {
const name = feature.properties && feature.properties.name;
const excluded = showShading && !selectedSet.has(name);
return {
stroke: excluded,
color: "#c89318",
weight: 1,
opacity: 0.55,
fill: excluded,
fillColor: "#fde68a",
fillOpacity: 0.35,
};
};
}
function ensureDistrictLayer(selectedDistricts) {
if (!mapInstance) return;
// Lazy-fetch the GeoJSON once. The /static/ asset is small (~80KB gzipped).
if (!districtGeoJson && !districtFetchStarted) {
districtFetchStarted = true;
fetch("/static/berlin-districts.geojson")
.then((r) => r.json())
.then((g) => {
districtGeoJson = g;
ensureDistrictLayer(readMapData().selectedDistricts);
})
.catch((e) => console.warn("[lazyflat.map] district geojson load failed:", e));
return;
}
if (!districtGeoJson) return;
const set = new Set(selectedDistricts);
if (districtLayer) {
districtLayer.setStyle(districtStyle(set));
} else {
districtLayer = L.geoJSON(districtGeoJson, {
style: districtStyle(set),
interactive: false,
});
districtLayer.addTo(mapInstance);
// Markers must paint above the overlay; re-add marker layer to ensure
// it ends up on top regardless of insertion order.
if (markerLayer) {
markerLayer.bringToFront();
}
}
}
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;
districtLayer = 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);