Vestigingenzoeker op postcode Nieuw
Vestigingenlijst met een gestileerde SVG-kaart: vul je postcode in en de winkels sorteren op afstand (hemelsbreed), met per vestiging of hij nu open is en tot hoe laat. Lijst en kaart zijn gekoppeld (hover en klik), met foutmelding bij een onbekende of verkeerde postcode. Op smalle schermen wissel je tussen lijst en kaart. De demo gebruikt een klein lijstje postcodes; koppel in productie de PDOK Locatieserver voor coördinaten en eventueel de Geolocation API.
Zo gebruik je het
- Plak de HTML op de plek waar het onderdeel moet komen.
- Zet de CSS in je stylesheet. Alle klassen hebben een eigen voorvoegsel, dus niets botst met je eigen opmaak.
- Voeg de JavaScript toe en roep init() aan met het element waar de HTML in staat.
<!-- Vestigingenzoeker op postcode · NedDev · eigen werk. -->
<div class="jouw-container">
<div class="vzk">
<form class="vzk__form" novalidate>
<label class="vzk__lbl" for="vzk-pc">Jouw postcode</label>
<div class="vzk__row">
<input class="vzk__in" id="vzk-pc" inputmode="text" autocomplete="postal-code" placeholder="2611 AB" value="2611 AB" aria-describedby="vzk-err" maxlength="7">
<button class="vzk__go" type="submit">Zoek</button>
<div class="vzk__tabs" role="group" aria-label="Weergave"><button type="button" data-t="lijst" aria-pressed="true">Lijst</button><button type="button" data-t="kaart" aria-pressed="false">Kaart</button></div>
</div>
<p class="vzk__err" id="vzk-err" role="alert"></p>
</form>
<div class="vzk__body">
<ul class="vzk__list" role="list" aria-label="Vestigingen"></ul>
<div class="vzk__map">
<svg viewBox="0 0 200 200" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<path class="vzk__sea" d="M0 0H98L86 16 37 68 0 108Z"/>
<path class="vzk__coast" d="M98 0 86 16 37 68 0 108"/>
<path class="vzk__river" d="M0 114C40 124 72 140 102 148S140 172 162 190 190 200 200 200"/>
<path class="vzk__river" d="M200 102C180 108 158 120 118 142"/>
<path class="vzk__river" d="M78 32C94 42 106 48 122 52S160 58 200 62"/>
</svg>
<div class="vzk__pins"></div>
</div>
</div>
<p class="vzk__sr" aria-live="polite"></p>
</div>
</div>/* Vestigingenzoeker op postcode · NedDev · eigen werk. */
.vzk{--ink:#0a0a0a;--pop:#F5480F;--pd:#b83a0b;--mut:#5b5b5b;--line:rgba(10,10,10,.12);--ok:#1d6b3a;position:absolute;inset:0;display:flex;flex-direction:column;padding:10px 12px;font-family:'Switzer',system-ui,sans-serif;font-size:12.5px;line-height:1.35;color:var(--ink);background:#fff;container:vzk/inline-size;text-align:left}
.vzk *,.vzk *::before,.vzk *::after{box-sizing:border-box}
.vzk__sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);margin:0}
.vzk__lbl{display:block;font-weight:600;font-size:12px;margin-bottom:3px}
.vzk__row{display:flex;gap:6px}
.vzk__in{width:92px;height:30px;padding:0 8px;border:1px solid rgba(10,10,10,.35);border-radius:4px;font:600 13px/1 'Switzer',system-ui,sans-serif;color:var(--ink);letter-spacing:.02em;background:#fff}
.vzk__in[aria-invalid="true"]{border-color:var(--pd)}
.vzk__go{height:30px;padding:0 12px;border:0;border-radius:4px;background:var(--ink);color:#fff;font:600 12px/1 'Switzer',system-ui,sans-serif;cursor:pointer;transition:background-color .15s}
.vzk__go:hover{background:#2a2a2a}
.vzk__tabs{display:none;margin-left:auto}
.vzk__tabs button{height:30px;padding:0 9px;border:1px solid rgba(10,10,10,.25);background:#fff;font:500 12px/1 'Switzer',system-ui,sans-serif;color:var(--ink);cursor:pointer}
.vzk__tabs button:first-child{border-radius:4px 0 0 4px}
.vzk__tabs button:last-child{border-radius:0 4px 4px 0;margin-left:-1px}
.vzk__tabs button[aria-pressed="true"]{background:var(--ink);color:#fff;border-color:var(--ink)}
.vzk button:focus-visible,.vzk__in:focus-visible{outline:2px solid var(--pop);outline-offset:2px}
.vzk__err{margin:4px 0 0;color:var(--pd);font-size:11.5px;font-weight:500}
.vzk__err:empty{display:none}
.vzk__body{flex:1;min-height:0;display:grid;grid-template-columns:1fr minmax(120px,36%);gap:10px;margin-top:8px}
.vzk__list{min-height:0;overflow:auto;list-style:none;margin:0;padding:0;border-top:1px solid var(--ink);scrollbar-width:thin}
.vzk__it{display:flex;gap:8px;width:100%;padding:6px 4px;border:0;border-bottom:1px solid var(--line);background:none;font:inherit;color:inherit;text-align:left;cursor:pointer;transition:background-color .15s}
.vzk__it:hover,.vzk__it.is-hot{background:#f4f3f1}
.vzk__it[aria-current="true"]{background:#fdeee8}
.vzk__nm{flex:1;min-width:0}
.vzk__nm b{display:block;font-weight:600}
.vzk__nm span{display:block;font-size:11.5px;color:var(--mut);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vzk__open{display:flex;align-items:center;gap:5px;font-size:11.5px;color:var(--mut)}
.vzk__open::before{content:'';width:7px;height:7px;border-radius:4px;background:#b5b2ad;flex:none}
.vzk__open.is-open{color:var(--ok);font-weight:500}
.vzk__open.is-open::before{background:var(--ok)}
.vzk__km{flex:none;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
.vzk__map{position:relative;min-height:0;border:1px solid var(--line);border-radius:4px;overflow:hidden;background:#f4f3f1}
.vzk__map svg{position:absolute;inset:0;width:100%;height:100%}
.vzk__sea{fill:#e4e2de}
.vzk__coast{fill:none;stroke:#b5b2ad;stroke-width:1.2;vector-effect:non-scaling-stroke}
.vzk__river{fill:none;stroke:#d3d0cb;stroke-width:3;stroke-linecap:round;vector-effect:non-scaling-stroke}
.vzk__pins{position:absolute;inset:0}
.vzk__pin{position:absolute;width:24px;height:24px;margin:-12px 0 0 -12px;padding:0;border:0;background:none;cursor:pointer;display:grid;place-items:center}
.vzk__pin::before{content:'';width:10px;height:10px;border-radius:4px;background:#fff;border:2px solid var(--ink);transition:transform .15s,background-color .15s}
.vzk__pin:hover::before,.vzk__pin.is-hot::before{transform:scale(1.3)}
.vzk__pin[aria-current="true"]::before{background:var(--ink)}
.vzk__me{position:absolute;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:4px;background:var(--pop);border:2px solid #fff;outline:1px solid var(--pop)}
.vzk__me[hidden]{display:none}
.vzk__tag{position:absolute;transform:translate(-50%,-150%);padding:2px 5px;border-radius:4px;background:var(--ink);color:#fff;font-size:11px;font-weight:600;white-space:nowrap;pointer-events:none}
.vzk__tag[hidden]{display:none}
@container vzk (max-width:340px){
.vzk__tabs{display:flex}
.vzk__body{grid-template-columns:1fr}
.vzk__body.is-kaart .vzk__list,.vzk__body:not(.is-kaart) .vzk__map{display:none}
}
@media (prefers-reduced-motion:reduce){.vzk *,.vzk *::before{transition:none!important}}// Vestigingenzoeker op postcode · NedDev · eigen werk.
// root = het element waarin de HTML staat
function init(root){
const w = root.querySelector('.vzk'); if (!w) return;
const form = w.querySelector('.vzk__form'), inp = w.querySelector('.vzk__in'), err = w.querySelector('.vzk__err');
const list = w.querySelector('.vzk__list'), pins = w.querySelector('.vzk__pins'), bodyEl = w.querySelector('.vzk__body'), sr = w.querySelector('.vzk__sr');
// Kaartvlak: lengte 4,15-4,80 en breedte 51,78-52,28 (Zuid-Holland), vierkant gestileerd.
const X = lon => (lon - 4.15) / 0.65 * 100, Y = lat => (52.28 - lat) / 0.5 * 100;
const PC = { '2511': [52.078, 4.313], '2512': [52.074, 4.31], '2585': [52.093, 4.291], '2611': [52.011, 4.357], '2612': [52.003, 4.364], '2311': [52.158, 4.49], '2312': [52.156, 4.484], '3011': [51.921, 4.48], '3012': [51.922, 4.474], '2711': [52.06, 4.493], '2801': [52.016, 4.709], '3311': [51.814, 4.67], '2201': [52.236, 4.43], '2406': [52.13, 4.66], '2671': [51.993, 4.207], '3111': [51.917, 4.399], '2281': [52.037, 4.32], '2271': [52.07, 4.36] };
// uren per weekdag: zo, ma, di, wo, do, vr, za (null = dicht)
const STD = [null, [13, 18], [9, 18], [9, 18], [9, 18], [9, 18], [9, 17]];
const V = [
{ n: 'Den Haag Centrum', a: 'Prinsegracht 41', p: [52.075, 4.305], h: [[12, 17], [13, 18], [9, 18], [9, 18], [9, 21], [9, 18], [9, 17]] },
{ n: 'Scheveningen', a: 'Keizerstraat 88', p: [52.108, 4.273], h: STD },
{ n: 'Delft', a: 'Brabantse Turfmarkt 17', p: [52.009, 4.362], h: STD },
{ n: 'Leiden', a: 'Haarlemmerstraat 120', p: [52.161, 4.492], h: [[12, 17], [13, 18], [9, 18], [9, 18], [9, 21], [9, 18], [9, 17]] },
{ n: 'Rotterdam Centrum', a: 'Nieuwe Binnenweg 64', p: [51.916, 4.465], h: [[12, 17], [11, 18], [9, 18], [9, 18], [9, 18], [9, 21], [9, 18]] },
{ n: 'Zoetermeer', a: 'Promenade 31', p: [52.058, 4.497], h: STD },
{ n: 'Gouda', a: 'Lange Tiendeweg 9', p: [52.012, 4.713], h: STD }
];
const DAG = ['zo', 'ma', 'di', 'wo', 'do', 'vr', 'za'];
const km = (a, b) => { const R = 6371, t = Math.PI / 180, dl = (b[0] - a[0]) * t, dn = (b[1] - a[1]) * t; const x = Math.sin(dl / 2) ** 2 + Math.cos(a[0] * t) * Math.cos(b[0] * t) * Math.sin(dn / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(x)); };
const nl1 = n => n.toLocaleString('nl-NL', { maximumFractionDigits: 1, minimumFractionDigits: 1 });
const hh = x => x + ':00';
function status(v) {
const now = new Date(), d = now.getDay(), t = now.getHours() + now.getMinutes() / 60, u = v.h[d];
if (u && t >= u[0] && t < u[1]) return { open: true, txt: 'Nu open tot ' + hh(u[1]) };
if (u && t < u[0]) return { open: false, txt: 'Gesloten, opent om ' + hh(u[0]) };
for (let i = 1; i <= 7; i++) { const e = v.h[(d + i) % 7]; if (e) return { open: false, txt: 'Gesloten, opent ' + (i === 1 ? 'morgen' : DAG[(d + i) % 7]) + ' ' + hh(e[0]) }; }
return { open: false, txt: 'Gesloten' };
}
let me = null, order = V.map((v, i) => i), sel = -1;
function render() {
list.innerHTML = order.map(i => {
const v = V[i], s = status(v), d = me ? km(me, v.p) : null;
return '<li><button type="button" class="vzk__it" data-i="' + i + '" aria-current="' + (i === sel) + '"><span class="vzk__nm"><b>' + v.n + '</b><span>' + v.a + '</span><span class="vzk__open' + (s.open ? ' is-open' : '') + '">' + s.txt + '</span></span>' + (d != null ? '<span class="vzk__km">' + nl1(d) + ' km</span>' : '') + '</button></li>';
}).join('');
pins.innerHTML = V.map((v, i) => '<button type="button" class="vzk__pin" data-i="' + i + '" style="left:' + X(v.p[1]) + '%;top:' + Y(v.p[0]) + '%" aria-label="' + v.n + (me ? ', ' + nl1(km(me, v.p)) + ' km' : '') + '" aria-current="' + (i === sel) + '"></button>').join('') +
'<span class="vzk__me"' + (me ? ' style="left:' + X(me[1]) + '%;top:' + Y(me[0]) + '%"' : ' hidden') + '></span><span class="vzk__tag" hidden></span>';
}
function search() {
const raw = inp.value.trim().toUpperCase().replace(/\s+/g, '');
let msg = '';
if (!/^[1-9]\d{3}([A-Z]{2})?$/.test(raw)) msg = 'Vul een postcode in zoals 2611 AB (vier cijfers, twee letters).';
else if (!PC[raw.slice(0, 4)]) msg = 'Deze postcode kent de demo niet. Probeer 2511, 2311, 3011, 2711 of 2801.';
err.textContent = msg; inp.setAttribute('aria-invalid', String(!!msg));
if (msg) { inp.focus(); return; }
inp.value = raw.slice(0, 4) + (raw.length > 4 ? ' ' + raw.slice(4) : '');
me = PC[raw.slice(0, 4)];
order = V.map((v, i) => i).sort((a, b) => km(me, V[a].p) - km(me, V[b].p));
sel = order[0]; render();
sr.textContent = 'Gesorteerd op afstand. Dichtstbij: ' + V[sel].n + ', ' + nl1(km(me, V[sel].p)) + ' km.';
}
form.addEventListener('submit', e => { e.preventDefault(); search(); });
function hot(i, on) {
w.querySelectorAll('[data-i="' + i + '"]').forEach(el => el.classList.toggle('is-hot', on));
const tag = pins.querySelector('.vzk__tag');
if (on && i >= 0) { tag.hidden = false; tag.textContent = V[i].n; tag.style.left = X(V[i].p[1]) + '%'; tag.style.top = Y(V[i].p[0]) + '%'; } else tag.hidden = true;
}
w.addEventListener('mouseover', e => { const el = e.target.closest('[data-i]'); if (el) hot(+el.dataset.i, true); });
w.addEventListener('mouseout', e => { const el = e.target.closest('[data-i]'); if (el) hot(+el.dataset.i, false); });
w.addEventListener('focusin', e => { const el = e.target.closest('.vzk__pin'); if (el) hot(+el.dataset.i, true); });
w.addEventListener('focusout', e => { const el = e.target.closest('.vzk__pin'); if (el) hot(+el.dataset.i, false); });
w.addEventListener('click', e => {
const el = e.target.closest('.vzk__it,.vzk__pin');
if (el) {
sel = +el.dataset.i; const isPin = el.classList.contains('vzk__pin');
render(); const n = w.querySelector((isPin ? '.vzk__pin' : '.vzk__it') + '[data-i="' + sel + '"]'); if (n) n.focus();
if (isPin) { const li = list.querySelector('[data-i="' + sel + '"]'); if (li) li.scrollIntoView({ block: 'nearest' }); }
sr.textContent = V[sel].n + ' gekozen, ' + V[sel].a + '. ' + status(V[sel]).txt + '.';
return;
}
const t = e.target.closest('.vzk__tabs button');
if (t) { w.querySelectorAll('.vzk__tabs button').forEach(b => b.setAttribute('aria-pressed', String(b === t))); bodyEl.classList.toggle('is-kaart', t.dataset.t === 'kaart'); }
});
inp.addEventListener('input', () => { if (err.textContent) { err.textContent = ''; inp.setAttribute('aria-invalid', 'false'); } });
// Beginstand: voorbeeldpostcode al gezocht, zonder focus te verplaatsen.
const raw = '2611'; me = PC[raw]; order = V.map((v, i) => i).sort((a, b) => km(me, V[a].p) - km(me, V[b].p)); sel = order[0]; render();
}
init(document.querySelector('.jouw-container'));
Meer uit cijfers & grafieken
Iets gezien dat je mooi vindt?
Zet het in je selectie en stuur hem op. Dan bouwen we je site eromheen, in jouw huisstijl.
Neem contact op →