Monitor v2: 1167 echte Filialen (braucheklima-Daten + Geocoding), GPU-Karte, Live-Feed, sortierbare Tabelle, Verfügbarkeits-Prognose, Online-Wucher-Zeilen

This commit is contained in:
2026-06-27 17:25:15 +00:00
parent f89db4ee8f
commit 67f25ffd10
8 changed files with 252 additions and 128 deletions
+227 -108
View File
@@ -1,134 +1,253 @@
---
import Base from '../layouts/Base.astro';
import { allProducts } from '../lib/pricing';
const products = allProducts.map(p => ({ slug:p.slug, name:p.name }));
const jsonld = {'@context':'https://schema.org','@type':'WebApplication',name:'Kühlfinder Monitor',applicationCategory:'ShoppingApplication',
description:'Live-Verfügbarkeit der Midea PortaSplit, PortaSplit Cool und Universalhalterung in über 1.160 Baumärkten, mit Verfügbarkeits-Prognose.'};
---
<Base title="Verfügbarkeits-Karte: portable Klimageräte in Baumärkten | Kühlfinder" description="Live-Karte: welche Baumärkte aktuell mobile Klimageräte auf Lager haben. Nach Gerät, Kette und Umkreis filtern.">
<section class="wrap" style="margin-top:1.4rem">
<p class="kicker"><span class="u"></span>Deutschlandkarte</p>
<h1>Wo gibt es heute ein Klimagerät?</h1>
<p class="muted">Märkte mit aktuellem Bestand. Wähle ein Gerät, nutze deinen Standort und stell den Umkreis ein.</p>
</section>
<section class="wrap mapwrap">
<aside class="panel card">
<label>Gerät
<select id="f-product">
<option value="all">Alle Geräte</option>
{products.map(p => <option value={p.slug}>{p.name}</option>)}
</select>
</label>
<button class="btn sm" id="locate">Meinen Standort nutzen</button>
<label>Umkreis: <span id="r-out">150 km</span>
<input type="range" id="f-radius" min="25" max="500" step="25" value="150" />
</label>
<div class="chains" id="f-chains">
<strong class="note">Ketten</strong>
<label><input type="checkbox" value="OBI" checked> OBI</label>
<label><input type="checkbox" value="Hornbach" checked> Hornbach</label>
<label><input type="checkbox" value="Bauhaus" checked> Bauhaus</label>
<label><input type="checkbox" value="toom" checked> toom</label>
<Base title="PortaSplit Monitor Live-Verfügbarkeit + Prognose in 1.167 Baumärkten | Kühlfinder" description="Sieh auf einen Blick, welche OBI-, Hornbach-, Bauhaus-, Toom-, Hagebau- und Globus-Filialen Midea PortaSplit, Cool & Halterung auf Lager haben mit Preis-Check, Wucher-Warnung und Verfügbarkeits-Prognose." jsonld={jsonld}>
<section class="mon-hero"><div class="wrap">
<div class="mon-hero-row">
<div>
<p class="kicker"><span class="u"></span>PortaSplit Monitor · Live</p>
<h1>Wo gibt es die Midea PortaSplit gerade?</h1>
<p class="lead">Live-Bestand in <span class="num" id="h-stores">1.167</span> Baumärkten plus <strong>Verfügbarkeits-Prognose</strong>, die dir sagt, wo die Chance in den nächsten 7 Tagen am höchsten ist. Das kann braucheklima nicht.</p>
</div>
<div class="upd card"><span class="note">Aktualisiert</span><div class="num upd-t" id="h-updated"></div><span class="note" id="h-feednote">lädt …</span></div>
</div>
<div class="stats" id="stats">
<div class="stat"><span class="note">Filialen</span><b class="num" data-k="storesTotal"></b></div>
<div class="stat ok"><span class="note">mit PortaSplit</span><b class="num" data-k="withPortaSplit"></b></div>
<div class="stat ok"><span class="note">mit Cool</span><b class="num" data-k="withPortaSplitCool"></b></div>
<div class="stat ok"><span class="note">mit Halterung</span><b class="num" data-k="withHalterung"></b></div>
<div class="stat"><span class="note">Restocks 7 Tg.</span><b class="num" id="s-restocks"></b></div>
<div class="stat warn"><span class="note">Wucher-Alarm</span><b class="num" id="s-wucher"></b></div>
</div>
</div></section>
<section class="wrap mon-grid">
<div class="mapcol">
<div id="map"></div>
<div class="legend">
<span><i style="background:#1d9e75"></i>verfügbar</span>
<span><i style="background:#ef9f27"></i>wenig</span>
<span><i style="background:#b4b2a9"></i>leer</span>
<span><span class="dot ok"></span>verfügbar</span>
<span><span class="dot" style="background:var(--petrol)"></span>Filiale (Cluster)</span>
<span><i class="hot"></i>hohe Prognose</span>
</div>
<p class="note" id="count">…</p>
<p class="note">Hinweis: aktuelle Demo-Daten. Live-Anbindung der Ketten-Bestände folgt.</p>
</div>
<aside class="feedcol card">
<h2 class="h-sm">Letzte Verfügbarkeiten</h2>
<p class="note">Echte Restock-Ereignisse, neueste zuerst.</p>
<ol class="feed" id="feed"><li class="skel" style="height:46px"></li><li class="skel" style="height:46px"></li><li class="skel" style="height:46px"></li></ol>
</aside>
<div id="map"></div>
</section>
<section class="wrap">
<div class="toolbar card">
<div class="tb-row">
<input id="q" type="search" placeholder="Filiale, Ort oder PLZ suchen …" />
<button class="btn sm ghost" id="locate" type="button">Standort</button>
<label class="rad">Umkreis <span class="num" id="r-out"></span><input id="radius" type="range" min="10" max="500" step="10" value="500" disabled /></label>
</div>
<div class="tb-row chips">
<span class="note">Produkt:</span>
<button class="chip on" data-p="ps">PortaSplit</button>
<button class="chip on" data-p="co">Cool</button>
<button class="chip on" data-p="ha">Halterung</button>
<span class="sep"></span>
<button class="chip ghost on" data-toggle="avail">Nur verfügbare</button>
<button class="chip ghost" data-toggle="chance">Beste Prognose zuerst</button>
<span class="sep"></span>
<span class="note">Kette:</span>
<span id="chainchips"></span>
<button class="chip ghost" id="reset" type="button">Zurücksetzen</button>
</div>
</div>
<div class="onlinebar card" id="onlinebar"></div>
<div class="tablewrap card">
<table class="mtab">
<thead><tr>
<th>Filiale</th>
<th class="ce" data-sort="ps">PortaSplit</th>
<th class="ce" data-sort="co">Cool</th>
<th class="ce" data-sort="ha">Halterung</th>
<th class="ce" data-sort="q">Prognose 7 Tg.</th>
<th class="ce" id="distcol" style="display:none">Entfernung</th>
</tr></thead>
<tbody id="rows"><tr><td colspan="6"><div class="skel" style="height:200px"></div></td></tr></tbody>
</table>
<div class="more"><button class="btn ghost" id="more" type="button" style="display:none">Mehr Filialen laden</button><p class="note" id="rowcount"></p></div>
</div>
<div class="alertcta card">
<div><h2 class="h-sm">Kein Bestand in deiner Nähe?</h2><p class="note" style="margin:0">Wir mailen dir, sobald PortaSplit & Co. in deinem Umkreis auftauchen inkl. Prognose-Frühwarnung.</p></div>
<a class="btn" href="/alarm?produkt=midea-portasplit-12000">Verfügbarkeits-Alarm</a>
</div>
<p class="note" style="margin-top:.6rem">Datenstand: Snapshot aus öffentlich einsehbaren Filial-/Online-Beständen (Vorbild braucheklima.de). Prognose = statistische Schätzung aus der Restock-Historie, keine Garantie. Preise/Bestände beim Händler prüfen.</p>
</section>
</Base>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<style>
.mapwrap{display:grid;grid-template-columns:280px 1fr;gap:1rem;margin-top:1rem;align-items:start}
.panel{display:flex;flex-direction:column;gap:.9rem;position:sticky;top:74px}
.panel label{display:block;font-weight:500;font-size:.9rem}
.panel select,.panel input[type=range]{width:100%;margin-top:.3rem}
.chains label{font-weight:400;display:flex;gap:.4rem;align-items:center;margin-top:.2rem}
.legend{display:flex;gap:.8rem;flex-wrap:wrap;font-size:.82rem;color:var(--slate)}
.legend i{display:inline-block;width:11px;height:11px;border-radius:50%;margin-right:.3rem;vertical-align:middle}
#map{height:600px;border-radius:var(--radius);overflow:hidden;border:1px solid var(--line)}
.maplibregl-popup-content{font-family:var(--font-body);border-radius:10px;padding:.7rem .8rem}
.pop strong{font-size:.98rem}
.pop .w{color:#a32d2d;font-weight:600}
@media(max-width:820px){.mapwrap{grid-template-columns:1fr}.panel{position:static}#map{height:60vh}}
.mon-hero{background:linear-gradient(180deg,var(--petrol-soft),var(--light));padding:1.8rem 0 1.4rem}
.mon-hero-row{display:flex;justify-content:space-between;gap:1.2rem;align-items:flex-start}
.mon-hero .lead{max-width:48rem;color:var(--ink-2);font-size:1.05rem}
.upd{min-width:140px;text-align:center}
.upd-t{font-size:1.6rem;font-weight:600;color:var(--petrol-deep)}
.stats{display:grid;grid-template-columns:repeat(6,1fr);gap:.6rem;margin-top:1.2rem}
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:.6rem .8rem;display:flex;flex-direction:column;gap:.1rem}
.stat b{font-size:1.45rem;line-height:1.1}
.stat.ok b{color:var(--ok-dot)} .stat.warn b{color:var(--danger)}
.mon-grid{display:grid;grid-template-columns:1.7fr 1fr;gap:1rem;margin-top:1.2rem;align-items:start}
.mapcol{position:sticky;top:74px}
#map{height:520px;border-radius:var(--radius);overflow:hidden;border:1px solid var(--line)}
.legend{display:flex;gap:1rem;flex-wrap:wrap;font-size:.8rem;color:var(--slate);margin-top:.5rem;align-items:center}
.legend i.hot{display:inline-block;width:11px;height:11px;border-radius:50%;border:2px solid var(--warm);margin-right:.3rem;vertical-align:middle}
.feedcol{max-height:560px;overflow:auto}
.h-sm{font-size:1.15rem;margin:.1rem 0 .2rem}
.feed{list-style:none;margin:.6rem 0 0;padding:0;display:flex;flex-direction:column;gap:.1rem}
.feed li{display:flex;justify-content:space-between;gap:.6rem;padding:.55rem .2rem;border-bottom:1px solid var(--line)}
.feed .fl b{font-weight:600;font-size:.92rem}
.feed .fl span{font-size:.8rem;color:var(--slate)}
.feed .ft{font-size:.8rem;color:var(--slate);white-space:nowrap;font-family:var(--mono)}
.toolbar{margin-top:1.4rem;display:flex;flex-direction:column;gap:.7rem}
.tb-row{display:flex;gap:.6rem;align-items:center;flex-wrap:wrap}
#q{flex:1;min-width:200px;font-family:var(--font-body);font-size:.95rem;padding:.6rem .8rem;border:1px solid var(--line);border-radius:8px;background:var(--surface-2)}
#q:focus{border-color:var(--petrol);background:#fff}
.rad{display:flex;align-items:center;gap:.5rem;font-size:.85rem;color:var(--slate)}
.chips{font-size:.85rem}
.chip{font-family:var(--font-body);font-weight:500;font-size:.85rem;border:1px solid var(--line);background:var(--card);color:var(--ink);padding:.4rem .8rem;border-radius:999px;cursor:pointer;transition:transform .1s,background .15s,border-color .15s}
.chip:active{transform:scale(.97)}
.chip.on{background:var(--petrol);color:#fff;border-color:var(--petrol)}
.chip.ghost.on{background:var(--ok-bg);color:var(--ok-dot);border-color:var(--ok-dot)}
.sep{width:1px;align-self:stretch;background:var(--line);margin:0 .2rem}
.onlinebar{margin-top:.8rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;padding:.7rem .9rem}
.osrc{display:flex;flex-direction:column;border:1px solid var(--line);border-radius:8px;padding:.4rem .6rem;min-width:120px}
.osrc b{font-size:.85rem}.osrc .num{font-size:.9rem}
.osrc.wucher{border-color:var(--danger);background:var(--danger-bg)}
.osrc.wucher .num{color:var(--danger)}
.tablewrap{margin-top:.8rem;padding:0;overflow:hidden}
.mtab{width:100%;border-collapse:collapse;font-size:.9rem}
.mtab thead th{text-align:left;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:var(--slate);font-weight:600;padding:.7rem .8rem;border-bottom:1px solid var(--line);position:sticky;top:62px;background:var(--card);cursor:pointer;white-space:nowrap}
.mtab th.ce{text-align:center}
.mtab td{padding:.6rem .8rem;border-bottom:1px solid var(--line);vertical-align:middle}
.mtab tbody tr:hover{background:var(--petrol-soft)}
.mtab .fname{font-weight:600;font-size:.92rem}.mtab .faddr{font-size:.78rem;color:var(--slate)}
.cell{display:flex;flex-direction:column;align-items:center;gap:.15rem;line-height:1.2}
.cell .num{font-size:.85rem}
.cell .pa{font-size:.72rem;color:var(--ok-dot);font-weight:600}
.cell .pn{font-size:.72rem;color:var(--slate)}
.prog{display:inline-flex;align-items:center;gap:.4rem}
.progbar{width:46px;height:6px;border-radius:99px;background:var(--line);overflow:hidden}
.progbar i{display:block;height:100%;background:var(--warm)}
.more{display:flex;justify-content:space-between;align-items:center;padding:.8rem}
.alertcta{margin-top:1rem;display:flex;justify-content:space-between;align-items:center;gap:1rem;background:var(--petrol-soft);border-color:transparent}
@media(max-width:900px){.stats{grid-template-columns:repeat(3,1fr)}.mon-grid{grid-template-columns:1fr}.mapcol{position:static}.mon-hero-row{flex-direction:column}.mtab thead th{position:static}}
</style>
<script>
import maplibregl from 'maplibre-gl';
import storesData from '../data/stores.json';
const PNAME={ps:'PortaSplit',co:'PortaSplit Cool',ha:'Universalhalterung'};
const PKEYFEED={'portasplit':'ps','portasplit-cool':'co','universalhalterung':'ha'};
const euro=n=>n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n);
const FAIR={ps:849,co:779,ha:70};
let DATA=null, center=null, shown=120;
const state={products:{ps:true,co:true,ha:true},chains:new Set(),avail:true,chance:false,q:'',radius:500,sort:null};
type Offer = { productId:string; sourceType:string; sourceId:string; chain:string; price:number|null; availability:string; };
const stores = storesData as any[];
const AVAIL = ['in_stock','low_stock'];
const params = new URLSearchParams(location.search);
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
function dist(aLat,aLng,bLat,bLng){const R=6371,dLat=(bLat-aLat)*Math.PI/180,dLng=(bLng-aLng)*Math.PI/180;const x=Math.sin(dLat/2)**2+Math.cos(aLat*Math.PI/180)*Math.cos(bLat*Math.PI/180)*Math.sin(dLng/2)**2;return 2*R*Math.asin(Math.sqrt(x));}
function rel(iso){const d=(Date.now()-new Date(iso))/3600000;if(d<1)return 'vor '+Math.max(1,Math.round(d*60))+' Min';if(d<24)return 'vor '+Math.round(d)+' Std';return 'vor '+Math.round(d/24)+' Tg';}
const map = new maplibregl.Map({
container:'map',
style:'https://tiles.openfreemap.org/styles/liberty',
center:[10.2,51.1], zoom:5.1
});
const map=new maplibregl.Map({container:'map',style:'https://tiles.openfreemap.org/styles/liberty',center:[10.2,51.1],zoom:5});
map.addControl(new maplibregl.NavigationControl(),'top-right');
let offers: Offer[] = [];
let center: [number,number] | null = null;
let markers: maplibregl.Marker[] = [];
const elProd = document.getElementById('f-product') as HTMLSelectElement;
const elRad = document.getElementById('f-radius') as HTMLInputElement;
const elROut = document.getElementById('r-out')!;
const elCount = document.getElementById('count')!;
const chainBoxes = Array.from(document.querySelectorAll('#f-chains input')) as HTMLInputElement[];
if (params.get('produkt')) elProd.value = params.get('produkt')!;
function dist(aLat:number,aLng:number,bLat:number,bLng:number){
const R=6371,dLat=(bLat-aLat)*Math.PI/180,dLng=(bLng-aLng)*Math.PI/180;
const x=Math.sin(dLat/2)**2+Math.cos(aLat*Math.PI/180)*Math.cos(bLat*Math.PI/180)*Math.sin(dLng/2)**2;
return 2*R*Math.asin(Math.sqrt(x));
function activeProducts(){return Object.keys(state.products).filter(k=>state.products[k]);}
function storeBest(s){ // returns {avail, bestProb, anyTracked}
let avail=false,bestProb=0,any=false;
for(const k of activeProducts()){ if(s[k]){any=true; if(s[k].a)avail=true; if(s[k].q>bestProb)bestProb=s[k].q;} }
return {avail,bestProb,any};
}
function passFilter(s){
const aps=activeProducts(); if(!aps.some(k=>s[k]))return false;
if(state.chains.size && !state.chains.has(s.c))return false;
if(state.q){const q=state.q.toLowerCase(); if(!(s.n.toLowerCase().includes(q)||s.ci.toLowerCase().includes(q)||s.z.startsWith(q)))return false;}
if(center && dist(center[1],center[0],s.la,s.lo)>state.radius)return false;
if(state.avail && !storeBest(s).avail)return false;
return true;
}
function setMap(list){
const all={type:'FeatureCollection',features:list.map(s=>({type:'Feature',geometry:{type:'Point',coordinates:[s.lo,s.la]},properties:{}}))};
const av={type:'FeatureCollection',features:list.filter(s=>storeBest(s).avail).map(s=>({type:'Feature',geometry:{type:'Point',coordinates:[s.lo,s.la]},properties:{n:s.n,ci:s.ci}}))};
map.getSource('all').setData(all); map.getSource('av').setData(av);
}
const COL:any={in_stock:'#1d9e75',low_stock:'#ef9f27',out_of_stock:'#b4b2a9'};
const fmt=(n:number|null)=> n==null?'':new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR',maximumFractionDigits:0}).format(n);
const slugByProductId:any = {};
function render(){
markers.forEach(m=>m.remove()); markers=[];
const prod = elProd.value;
const radius = +elRad.value;
elROut.textContent = radius+' km';
const chains = chainBoxes.filter(c=>c.checked).map(c=>c.value);
let shown=0, avail=0;
for (const s of stores){
if (!chains.includes(s.chain)) continue;
if (center && dist(center[1],center[0],s.lat,s.lng)>radius) continue;
const here = offers.filter(o=>o.sourceId===s.id && o.sourceType==='store' && (prod==='all'|| o.productId===prod));
if (!here.length) continue;
const best = here.find(o=>o.availability==='in_stock') || here.find(o=>o.availability==='low_stock') || here[0];
const isAvail = AVAIL.includes(best.availability);
shown++; if(isAvail) avail++;
const el=document.createElement('div');
el.style.cssText=`width:16px;height:16px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.4);background:${COL[best.availability]||'#b4b2a9'}`;
const list = here.filter(o=>AVAIL.includes(o.availability))
.map(o=>`<div>${o.chain} · ${fmt(o.price)} · ${o.availability==='low_stock'?'wenig':'verfügbar'}</div>`).join('') || '<div class="muted">aktuell vergriffen</div>';
const popup=new maplibregl.Popup({offset:14}).setHTML(`<div class="pop"><strong>${s.chain} ${s.city}</strong><br><span style="color:#666">${s.name}</span><br>${list}</div>`);
const m=new maplibregl.Marker({element:el}).setLngLat([s.lng,s.lat]).setPopup(popup).addTo(map);
markers.push(m);
const list=DATA.stores.filter(passFilter);
if(center){list.sort((a,b)=>dist(center[1],center[0],a.la,a.lo)-dist(center[1],center[0],b.la,b.lo));}
else if(state.chance){list.sort((a,b)=>storeBest(b).bestProb-storeBest(a).bestProb);}
else {list.sort((a,b)=>(storeBest(b).avail?1:0)-(storeBest(a).avail?1:0)||storeBest(b).bestProb-storeBest(a).bestProb);}
// map
setMap(list);
// table
const tb=$('#rows'); tb.innerHTML='';
const slice=list.slice(0,shown);
const cell=(o)=>{ if(!o)return '<td class="ce"><span class="pn"></span></td>';
const dotc=o.a?'ok':(o.q>=15?'low':'off');
return `<td class="ce"><div class="cell"><span><span class="dot ${dotc}"></span> <span class="num">${euro(o.p)}</span></span>${o.a?'<span class="pa">verfügbar</span>':`<span class="pn">${o.q>0?o.q+'% / 7 Tg.':'—'}</span>`}</div></td>`;};
for(const s of slice){
const b=storeBest(s);
const d=center?` · <span class="num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</span>`:'';
const prog=`<td class="ce"><span class="prog"><span class="progbar"><i style="width:${b.bestProb}%"></i></span><span class="num">${b.bestProb}%</span></span></td>`;
const distTd=center?`<td class="ce num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</td>`:'';
tb.insertAdjacentHTML('beforeend',`<tr><td><div class="fname">${s.n}</div><div class="faddr">${s.z} ${s.ci}${d}</div></td>${cell(s.ps)}${cell(s.co)}${cell(s.ha)}${prog}${distTd}</tr>`);
}
elCount.textContent = `${avail} Märkte mit Bestand${center?` im Umkreis von ${radius} km`:''}`;
$('#rowcount').textContent=`${slice.length} von ${list.length} Filialen`;
$('#more').style.display=list.length>shown?'inline-flex':'none';
$('#distcol').style.display=center?'table-cell':'none';
}
document.getElementById('locate')!.addEventListener('click',()=>{
if(!navigator.geolocation){elCount.textContent='Standort nicht verfügbar';return;}
navigator.geolocation.getCurrentPosition(pos=>{
center=[pos.coords.longitude,pos.coords.latitude];
map.flyTo({center,zoom:7});
new maplibregl.Marker({color:'#185fa5'}).setLngLat(center).addTo(map);
render();
},()=>{elCount.textContent='Standort abgelehnt zeige ganz Deutschland';});
});
elProd.addEventListener('change',render);
elRad.addEventListener('input',render);
chainBoxes.forEach(c=>c.addEventListener('change',render));
function fillStatic(){
$('#h-updated').textContent=DATA.stats.updated;
$('#h-stores').textContent=DATA.stats.storesTotal.toLocaleString('de-DE');
$$('#stats b[data-k]').forEach(b=>b.textContent=(DATA.stats[b.dataset.k]??'').toLocaleString('de-DE'));
const r7=DATA.feed.filter(f=>(Date.now()-new Date(f.at))/86400000<=7).length;
$('#s-restocks').textContent=r7+(r7>=DATA.feed.length?'+':'');
$('#h-feednote').textContent='Feed live';
const wuch=DATA.online.filter(o=>o.ps&&o.ps.wucher).length;
$('#s-wucher').textContent=wuch;
// feed
$('#feed').innerHTML=DATA.feed.slice(0,20).map(f=>`<li><div class="fl"><b>${f.name}</b><span>${PNAME[PKEYFEED[f.productId]]||f.productId}</span></div><span class="ft">${rel(f.at)}</span></li>`).join('');
// online bar
$('#onlinebar').innerHTML='<span class="note" style="align-self:center">Online:</span>'+DATA.online.map(o=>{
const ps=o.ps; if(!ps)return '';
const w=ps.wucher;
return `<a class="osrc ${w?'wucher':''}" href="${o.url}" target="_blank" rel="nofollow noopener"><b>${o.src}${w?' ⚠':''}</b><span class="num">${euro(ps.p)}</span><span class="note">${ps.a?(w?'Wucher':'verfügbar'):'nicht verf.'}</span></a>`;
}).join('');
// chains
const chains=[...new Set(DATA.stores.map(s=>s.c))].sort();
$('#chainchips').innerHTML=chains.map(c=>`<button class="chip ghost" data-chain="${c}">${c}</button>`).join(' ');
$$('#chainchips .chip').forEach(b=>b.addEventListener('click',()=>{const c=b.dataset.chain;if(state.chains.has(c)){state.chains.delete(c);b.classList.remove('on');}else{state.chains.add(c);b.classList.add('on');}render();}));
}
fetch('/data/offers.json').then(r=>r.json()).then(d=>{offers=d.offers;render();}).catch(()=>{elCount.textContent='Daten konnten nicht geladen werden';});
map.on('load',render);
map.on('load',()=>{
map.addSource('all',{type:'geojson',data:{type:'FeatureCollection',features:[]},cluster:true,clusterRadius:46,clusterMaxZoom:9});
map.addLayer({id:'cl',type:'circle',source:'all',filter:['has','point_count'],paint:{'circle-color':'#2b6a72','circle-opacity':.82,'circle-radius':['step',['get','point_count'],14,50,18,200,24,500,30]}});
map.addLayer({id:'cl-c',type:'symbol',source:'all',filter:['has','point_count'],layout:{'text-field':['get','point_count_abbreviated'],'text-size':12},paint:{'text-color':'#fff'}});
map.addLayer({id:'pt',type:'circle',source:'all',filter:['!',['has','point_count']],paint:{'circle-color':'#2b6a72','circle-opacity':.7,'circle-radius':4,'circle-stroke-width':1,'circle-stroke-color':'#fff'}});
map.addSource('av',{type:'geojson',data:{type:'FeatureCollection',features:[]}});
map.addLayer({id:'av',type:'circle',source:'av',paint:{'circle-color':'#1d9e75','circle-radius':7,'circle-stroke-width':2,'circle-stroke-color':'#fff'}});
map.on('click','av',e=>{const p=e.features[0].properties;new maplibregl.Popup().setLngLat(e.lngLat).setHTML(`<b>${p.n}</b><br>${p.ci} verfügbar`).addTo(map);});
map.on('click','cl',e=>{const f=map.queryRenderedFeatures(e.point,{layers:['cl']});map.getSource('all').getClusterExpansionZoom(f[0].properties.cluster_id).then(z=>map.easeTo({center:f[0].geometry.coordinates,zoom:z}));});
if(DATA)render();
});
// controls
$$('.chip[data-p]').forEach(b=>b.addEventListener('click',()=>{const k=b.dataset.p;state.products[k]=!state.products[k];b.classList.toggle('on');render();}));
$$('.chip[data-toggle]').forEach(b=>b.addEventListener('click',()=>{const t=b.dataset.toggle;state[t]=!state[t];b.classList.toggle('on');render();}));
$('#q').addEventListener('input',e=>{state.q=e.target.value.trim();shown=120;render();});
$('#radius').addEventListener('input',e=>{state.radius=+e.target.value;$('#r-out').textContent=state.radius+' km';render();});
$('#more').addEventListener('click',()=>{shown+=120;render();});
$('#reset').addEventListener('click',()=>{location.reload();});
$$('.mtab th[data-sort]').forEach(th=>th.addEventListener('click',()=>{state.chance=(th.dataset.sort==='q');render();}));
$('#locate').addEventListener('click',()=>{if(!navigator.geolocation)return;navigator.geolocation.getCurrentPosition(p=>{center=[p.coords.longitude,p.coords.latitude];$('#radius').disabled=false;state.radius=100;$('#radius').value=100;$('#r-out').textContent='100 km';new maplibregl.Marker({color:'#185fa5'}).setLngLat(center).addTo(map);map.flyTo({center,zoom:8});render();});});
fetch('/data/monitor.json').then(r=>r.json()).then(d=>{DATA=d;fillStatic();if(map.loaded())render();else render();});
</script>