- Excluded-district shading switches from amber/yellow to a soft red (fill #fca5a5 at 0.3 opacity, stroke #c84545) — reads as "blocked" rather than "highlighted", which matches the meaning better. - Drop the "Leaflet" attribution prefix (not legally required). Keep "© OpenStreetMap · © CARTO" — ODbL and CARTO's basemap terms both require attribution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
299 lines
10 KiB
JavaScript
299 lines
10 KiB
JavaScript
// 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) => ({
|
||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||
}[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);
|
||
}
|
||
|
||
// 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: "#c84545",
|
||
weight: 1,
|
||
opacity: 0.6,
|
||
fill: excluded,
|
||
fillColor: "#fca5a5",
|
||
fillOpacity: 0.3,
|
||
};
|
||
};
|
||
}
|
||
|
||
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);
|
||
// Drop the "Leaflet" prefix — not legally required. OSM (ODbL) and CARTO's
|
||
// basemap terms both REQUIRE attribution, so those credits stay.
|
||
mapInstance.attributionControl.setPrefix(false);
|
||
// 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);
|