Kühlfinder MVP: Finder, Geräte, Karte (MapLibre), Preis-Intelligenz + Wucher-Warnung, PortaSplit-Hub, llms.txt
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { allProducts } from '../lib/pricing';
|
||||
const products = allProducts.map(p => ({ slug:p.slug, name:p.name }));
|
||||
---
|
||||
<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>
|
||||
</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>
|
||||
<p class="note" id="count">…</p>
|
||||
<p class="note">Hinweis: aktuelle Demo-Daten. Live-Anbindung der Ketten-Bestände folgt.</p>
|
||||
</aside>
|
||||
<div id="map"></div>
|
||||
</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}}
|
||||
</style>
|
||||
<script>
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import storesData from '../data/stores.json';
|
||||
|
||||
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 map = new maplibregl.Map({
|
||||
container:'map',
|
||||
style:'https://tiles.openfreemap.org/styles/liberty',
|
||||
center:[10.2,51.1], zoom:5.1
|
||||
});
|
||||
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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
elCount.textContent = `${avail} Märkte mit Bestand${center?` im Umkreis von ${radius} km`:''}`;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
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);
|
||||
</script>
|
||||
Reference in New Issue
Block a user