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:
+227
-108
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user