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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -20
View File
File diff suppressed because one or more lines are too long
+227 -108
View File
@@ -1,134 +1,253 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import { allProducts } from '../lib/pricing'; const jsonld = {'@context':'https://schema.org','@type':'WebApplication',name:'Kühlfinder Monitor',applicationCategory:'ShoppingApplication',
const products = allProducts.map(p => ({ slug:p.slug, name:p.name })); 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."> <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="wrap" style="margin-top:1.4rem"> <section class="mon-hero"><div class="wrap">
<p class="kicker"><span class="u"></span>Deutschlandkarte</p> <div class="mon-hero-row">
<h1>Wo gibt es heute ein Klimagerät?</h1> <div>
<p class="muted">Märkte mit aktuellem Bestand. Wähle ein Gerät, nutze deinen Standort und stell den Umkreis ein.</p> <p class="kicker"><span class="u"></span>PortaSplit Monitor · Live</p>
</section> <h1>Wo gibt es die Midea PortaSplit gerade?</h1>
<section class="wrap mapwrap"> <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>
<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>
</div> </div>
<div class="legend"> <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>
<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>
</div> </div>
<p class="note" id="count">…</p> <div class="stats" id="stats">
<p class="note">Hinweis: aktuelle Demo-Daten. Live-Anbindung der Ketten-Bestände folgt.</p> <div class="stat"><span class="note">Filialen</span><b class="num" data-k="storesTotal"></b></div>
</aside> <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 id="map"></div>
<div class="legend">
<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>
</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>
</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> </section>
</Base> </Base>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<style> <style>
.mapwrap{display:grid;grid-template-columns:280px 1fr;gap:1rem;margin-top:1rem;align-items:start} .mon-hero{background:linear-gradient(180deg,var(--petrol-soft),var(--light));padding:1.8rem 0 1.4rem}
.panel{display:flex;flex-direction:column;gap:.9rem;position:sticky;top:74px} .mon-hero-row{display:flex;justify-content:space-between;gap:1.2rem;align-items:flex-start}
.panel label{display:block;font-weight:500;font-size:.9rem} .mon-hero .lead{max-width:48rem;color:var(--ink-2);font-size:1.05rem}
.panel select,.panel input[type=range]{width:100%;margin-top:.3rem} .upd{min-width:140px;text-align:center}
.chains label{font-weight:400;display:flex;gap:.4rem;align-items:center;margin-top:.2rem} .upd-t{font-size:1.6rem;font-weight:600;color:var(--petrol-deep)}
.legend{display:flex;gap:.8rem;flex-wrap:wrap;font-size:.82rem;color:var(--slate)} .stats{display:grid;grid-template-columns:repeat(6,1fr);gap:.6rem;margin-top:1.2rem}
.legend i{display:inline-block;width:11px;height:11px;border-radius:50%;margin-right:.3rem;vertical-align:middle} .stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:.6rem .8rem;display:flex;flex-direction:column;gap:.1rem}
#map{height:600px;border-radius:var(--radius);overflow:hidden;border:1px solid var(--line)} .stat b{font-size:1.45rem;line-height:1.1}
.maplibregl-popup-content{font-family:var(--font-body);border-radius:10px;padding:.7rem .8rem} .stat.ok b{color:var(--ok-dot)} .stat.warn b{color:var(--danger)}
.pop strong{font-size:.98rem} .mon-grid{display:grid;grid-template-columns:1.7fr 1fr;gap:1rem;margin-top:1.2rem;align-items:start}
.pop .w{color:#a32d2d;font-weight:600} .mapcol{position:sticky;top:74px}
@media(max-width:820px){.mapwrap{grid-template-columns:1fr}.panel{position:static}#map{height:60vh}} #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> </style>
<script> <script>
import maplibregl from 'maplibre-gl'; 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 $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
const stores = storesData as any[]; 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));}
const AVAIL = ['in_stock','low_stock']; 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 params = new URLSearchParams(location.search);
const map = new maplibregl.Map({ const map=new maplibregl.Map({container:'map',style:'https://tiles.openfreemap.org/styles/liberty',center:[10.2,51.1],zoom:5});
container:'map',
style:'https://tiles.openfreemap.org/styles/liberty',
center:[10.2,51.1], zoom:5.1
});
map.addControl(new maplibregl.NavigationControl(),'top-right'); map.addControl(new maplibregl.NavigationControl(),'top-right');
let offers: Offer[] = []; function activeProducts(){return Object.keys(state.products).filter(k=>state.products[k]);}
let center: [number,number] | null = null; function storeBest(s){ // returns {avail, bestProb, anyTracked}
let markers: maplibregl.Marker[] = []; 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;} }
const elProd = document.getElementById('f-product') as HTMLSelectElement; return {avail,bestProb,any};
const elRad = document.getElementById('f-radius') as HTMLInputElement; }
const elROut = document.getElementById('r-out')!; function passFilter(s){
const elCount = document.getElementById('count')!; const aps=activeProducts(); if(!aps.some(k=>s[k]))return false;
const chainBoxes = Array.from(document.querySelectorAll('#f-chains input')) as HTMLInputElement[]; if(state.chains.size && !state.chains.has(s.c))return false;
if (params.get('produkt')) elProd.value = params.get('produkt')!; 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;
function dist(aLat:number,aLng:number,bLat:number,bLng:number){ if(state.avail && !storeBest(s).avail)return false;
const R=6371,dLat=(bLat-aLat)*Math.PI/180,dLng=(bLng-aLng)*Math.PI/180; return true;
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 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(){ function render(){
markers.forEach(m=>m.remove()); markers=[]; const list=DATA.stores.filter(passFilter);
const prod = elProd.value; if(center){list.sort((a,b)=>dist(center[1],center[0],a.la,a.lo)-dist(center[1],center[0],b.la,b.lo));}
const radius = +elRad.value; else if(state.chance){list.sort((a,b)=>storeBest(b).bestProb-storeBest(a).bestProb);}
elROut.textContent = radius+' km'; else {list.sort((a,b)=>(storeBest(b).avail?1:0)-(storeBest(a).avail?1:0)||storeBest(b).bestProb-storeBest(a).bestProb);}
const chains = chainBoxes.filter(c=>c.checked).map(c=>c.value); // map
let shown=0, avail=0; setMap(list);
for (const s of stores){ // table
if (!chains.includes(s.chain)) continue; const tb=$('#rows'); tb.innerHTML='';
if (center && dist(center[1],center[0],s.lat,s.lng)>radius) continue; const slice=list.slice(0,shown);
const here = offers.filter(o=>o.sourceId===s.id && o.sourceType==='store' && (prod==='all'|| o.productId===prod)); const cell=(o)=>{ if(!o)return '<td class="ce"><span class="pn"></span></td>';
if (!here.length) continue; const dotc=o.a?'ok':(o.q>=15?'low':'off');
const best = here.find(o=>o.availability==='in_stock') || here.find(o=>o.availability==='low_stock') || here[0]; 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>`;};
const isAvail = AVAIL.includes(best.availability); for(const s of slice){
shown++; if(isAvail) avail++; const b=storeBest(s);
const el=document.createElement('div'); const d=center?` · <span class="num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</span>`:'';
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 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 list = here.filter(o=>AVAIL.includes(o.availability)) const distTd=center?`<td class="ce num">${dist(center[1],center[0],s.la,s.lo).toFixed(0)} km</td>`:'';
.map(o=>`<div>${o.chain} · ${fmt(o.price)} · ${o.availability==='low_stock'?'wenig':'verfügbar'}</div>`).join('') || '<div class="muted">aktuell vergriffen</div>'; 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>`);
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);
} }
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',()=>{ function fillStatic(){
if(!navigator.geolocation){elCount.textContent='Standort nicht verfügbar';return;} $('#h-updated').textContent=DATA.stats.updated;
navigator.geolocation.getCurrentPosition(pos=>{ $('#h-stores').textContent=DATA.stats.storesTotal.toLocaleString('de-DE');
center=[pos.coords.longitude,pos.coords.latitude]; $$('#stats b[data-k]').forEach(b=>b.textContent=(DATA.stats[b.dataset.k]??'').toLocaleString('de-DE'));
map.flyTo({center,zoom:7}); const r7=DATA.feed.filter(f=>(Date.now()-new Date(f.at))/86400000<=7).length;
new maplibregl.Marker({color:'#185fa5'}).setLngLat(center).addTo(map); $('#s-restocks').textContent=r7+(r7>=DATA.feed.length?'+':'');
render(); $('#h-feednote').textContent='Feed live';
},()=>{elCount.textContent='Standort abgelehnt zeige ganz Deutschland';}); 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();}));
}
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();
}); });
elProd.addEventListener('change',render);
elRad.addEventListener('input',render);
chainBoxes.forEach(c=>c.addEventListener('change',render));
fetch('/data/offers.json').then(r=>r.json()).then(d=>{offers=d.offers;render();}).catch(()=>{elCount.textContent='Daten konnten nicht geladen werden';}); // controls
map.on('load',render); $$('.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> </script>
+19
View File
@@ -58,3 +58,22 @@ header.site nav a:hover{color:var(--petrol)}
footer.site{margin-top:4rem;border-top:1px solid var(--line);background:var(--petrol-soft)} footer.site{margin-top:4rem;border-top:1px solid var(--line);background:var(--petrol-soft)}
footer.site .wrap{padding-top:2rem;padding-bottom:2rem} footer.site .wrap{padding-top:2rem;padding-bottom:2rem}
@media(max-width:720px){header.site nav a{margin-left:.8rem;font-size:.85rem}} @media(max-width:720px){header.site nav a{margin-left:.8rem;font-size:.85rem}}
/* --- v2 additions: data-monitor craft (ui-ux-design-pro + design-taste-frontend) --- */
:root{
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--ok-dot:oklch(0.62 0.13 155);
--low-dot:oklch(0.78 0.15 75);
--off-dot:oklch(0.80 0.01 240);
--ink-2:oklch(0.34 0.02 250);
--surface-2:oklch(0.975 0.006 230);
--shadow-sm:0 1px 2px oklch(0.4 0.03 250 / .06);
--shadow-md:0 6px 20px oklch(0.4 0.05 250 / .08),0 1px 3px oklch(0.4 0.05 250 / .05);
}
.num{font-family:var(--mono);font-variant-numeric:tabular-nums;letter-spacing:-.01em}
:focus-visible{outline:2px solid var(--petrol);outline-offset:2px;border-radius:4px}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;vertical-align:middle}
.dot.ok{background:var(--ok-dot)}.dot.low{background:var(--low-dot)}.dot.off{background:var(--off-dot)}
.skel{background:linear-gradient(90deg,var(--line),var(--surface-2),var(--line));background-size:200% 100%;animation:sh 1.2s infinite;border-radius:6px}
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
@media (prefers-reduced-motion: reduce){*,*::before,*::after{animation-duration:.01ms!important;transition-duration:.01ms!important}}