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:
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
File diff suppressed because one or more lines are too long
+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="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>
|
||||
<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>
|
||||
<p class="note" id="count">…</p>
|
||||
<p class="note">Hinweis: aktuelle Demo-Daten. Live-Anbindung der Ketten-Bestände folgt.</p>
|
||||
</aside>
|
||||
<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><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>
|
||||
</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';});
|
||||
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();}));
|
||||
}
|
||||
|
||||
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';});
|
||||
map.on('load',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>
|
||||
|
||||
@@ -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 .wrap{padding-top:2rem;padding-bottom:2rem}
|
||||
@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}}
|
||||
|
||||
Reference in New Issue
Block a user