1/* Spiegelproef bij kopers thuis: loep op echte klantfoto's. H.spiegel.html(opts), H.spiegel.mount(root) -> cleanup. Classes gf-spiegel-. */ 2(function(){ 3 "use strict"; 4 var H = window.H = window.H || {}, P = "gf-spiegel", K1 = 9, K2 = 6.25; 5 /* Points and areas in % of the photo, traced on 4-8x crops and checked on the debug overlay. 6 glass = the HOWAT glass, top = the worktop, skip = things on them (pan, basket, grille, remote): there the pill stays empty. 7 The glass spot sits just below the pan, 72 px (source) from the glass edge and clear of the printed zone lines, 8 so no loupe size shows the mirrored worktop under the label HOWAT-glas. */ 9 var SLIDES = [ 10 { rev: "172183", a: "Eric W.", d: "2026-03-23", chip: "Steen", img: "/wp-content/themes/howat-2026/media/reviews/r089.jpg", w: 1000, h: 750, start: [18, 70], rest: [30, 58.7], 11 spots: [{ at: [26.2, 62.7], title: "Werkblad", sub: "Spiegelt het raam" }, { at: [37.5, 52.8], title: "HOWAT-glas", sub: "Geen spiegelbeeld van de pan" }], 12 glass: [[22, 50.4], [43.2, 44.9], [74, 60.1], [47.8, 73.9]], out: "Werkblad", 13 top: [[10.3, 39.8], [38.5, 35.7], [38.5, 40], [60.3, 48.4], [79.3, 55.1], [100, 62.2], [100, 100], [46.7, 100], [20, 59.7], [14, 51.1], [10.3, 45]], 14 skip: [[[26.5, 45], [29, 43.5], [32.5, 41.6], [36, 43.5], [39, 45.3], [37.5, 48], [37.2, 52.4], [28.8, 52.4], [28.4, 48], [26.5, 47.5]], [[21, 35.5], [32, 35.2], [32.2, 43], [28.5, 45], [26, 48.6], [22.5, 49], [21, 47]], [[36.6, 55.3], [52.8, 50.6], [55.2, 53.2], [39.2, 57.6]], [[15, 45], [18.5, 44.6], [18.8, 46.8], [15.2, 47.3]]], 15 cap: "Het stenen werkblad spiegelt het raam. Het glas niet.", 16 alt: "Klantfoto van Eric W. Een HOWAT-kookplaat met afzuiging in een stenen werkblad bij het raam, met een pan op het glas." } 17 ]; 18 var QUOTES = [{ rev: "154618", a: "Frank", d: "2025-12-12", text: "Mooie matte plaat, geen gedoe met vingers ;)" }, { rev: "192260", a: "Henri S.", d: "2026-08-05", text: "waar je niets op ziet en krasvrij super", lead: true }]; 19 20 function esc(s){ return H.esc(s); } 21 /* the review by its key (WooCommerce review id, H.reviewKey), checked on author and date and on the same photo for a 22 slide or the exact excerpt for a quote; found again by those when the key is unknown (a catalogue without ids) */ 23 function review(x){ 24 var R = (window.HOWAT || {}).reviews || [], file = x.img ? String(x.img).split("/").pop() : ""; 25 function ok(r){ return !!r && r.a === x.a && r.d === x.d && (file ? H.photoIndex(r, file) > -1 : String(r.t).indexOf(x.text) > -1); } 26 var hit = H.reviewByKey ? H.reviewByKey(x.rev) : null; 27 if (ok(hit)) return hit; 28 for (var i = 0; i < R.length; i++) if (ok(R[i])) return R[i]; 29 return null; 30 } 31 function slide(n){ var s = SLIDES.filter(function(x){ return x.rev === String(n); })[0]; return s && review(s) ? s : null; } 32 function model(r){ if (!r.p) return "Winkelreview"; var p = H.product(r.p); return p ? "Kookplaat " + p.attrs.breedte + " cm" + (p.attrs.afzuiging ? " met afzuiging" : "") : ""; } 33 /* two meta lines. Card: stars, name, verified / date, model. List: stars, name, date / model */ 34 function who(r, card){ 35 var m = esc(model(r)), d = esc(H.dateNL ? H.dateNL(r.d, true) : r.d), stars = H.starsFor ? H.starsFor(r.r, 13) : ""; 36 return card ? stars + "<b>" + esc(r.a) + "</b>" + (r.v ? '<span class="' + P + '-v">' + H.icon("check") + "Geverifieerde koper</span>" : "") + "<i></i><span>" + d + (m ? "\u00a0·</span><span>" + m : "") + "</span>" 37 : stars + "<b>" + esc(r.a) + "</b><span>·\u00a0" + d + "</span>" + (m ? "<i></i><span>" + m + "</span>" : ""); 38 } 39 /* background-position % that centres v (0-1 along one axis) in a box of ratio b (height/width), the photo being k box widths long on that axis */ 40 function bgp(v, k, b){ return ((b / 2 - v * k) / (b - k) * 100).toFixed(2) + "%"; } 41 /* close-up = the same photo as a background: a square (K1) in wide tiles, a 2:1 strip (K2) in narrow tiles */ 42 function tiles(s){ 43 var ar = s.h / s.w; 44 return s.spots.map(function(sp, i){ 45 var x = sp.at[0] / 100, y = sp.at[1] / 100; 46 return '<button type="button" class="' + P + '-tile" data-spot="' + i + '" aria-pressed="false"><span class="' + P + '-tv" style="--x1:' + bgp(x, K1, 1) + ";--y1:" + bgp(y, K1 * ar, 1) + ";--x2:" + bgp(x, K2, 1) + ";--y2:" + bgp(y, K2 * ar, .5) + '"></span><span class="' + P + '-tt"><b>' + esc(sp.title) + "</b><span>" + esc(sp.sub) + "</span></span></button>"; 47 }).join(""); 48 } 49 function capHTML(s, compact, pre, note){ 50 var r = review(s), t = compact ? "h2" : "h3"; 51 return "<" + t + ' class="' + (compact ? "h3 " : "") + P + '-h"' + (compact ? ' id="' + pre + '-h"' : "") + ">" + esc(s.cap) + "</" + t + ">" + 52 '<p class="' + P + '-meta">' + who(r, true) + "</p>" + 53 (r.t ? '<blockquote class="' + P + '-q"><p>â' + esc(r.t) + "â</p></blockquote>" : "") + 54 (compact || note ? '<p class="' + P + '-note">Foto van de ' + esc(model(r).replace("Kookplaat ", "")) + ". Alle modellen hebben hetzelfde matte glas van 4 mm.</p>" : ""); 55 } 56 57 function html(o){ 58 o = o || {}; 59 var compact = !!o.compact, panel = !!o.panel, pre = esc(o.idPrefix || "sp"); 60 var list = (compact || panel ? ["172183"] : o.slides || ["172183"]).map(slide).filter(
60Boolean); 61 if (!list.length) return ""; 62 var s = list[0], data = ' id="' + pre + '" data-gf-spiegel="' + list.map(function(x){ return x.rev; }).join(",") + '"'; 63 var media = '<div class="' + P + '-stage" style="aspect-ratio:' + s.w + "/" + s.h + '">' + list.map(function(x, i){ 64 /* in the glass test tabs the photo sits in a hidden tab: it loads only near the block (bind), since lazy loading does not hold back a hidden image */ 65 return '<img class="' + P + "-img" + (i ? "" : " is-on") + '" ' + (i || panel ? "data-src" : "src") + '="' + x.img + '" width="' + x.w + '" height="' + x.h + '" alt="' + esc(x.alt) + '"' + (i ? ' aria-hidden="true"' : "") + ' loading="lazy" decoding="async" draggable="false">'; 66 }).join("") + 67 '<div class="' + P + '-hit" tabindex="0" role="application" aria-label="Loep op de klantfoto. Gebruik de pijltjestoetsen om te kijken."></div><div class="' + P + '-loupe" aria-hidden="true"><span class="' + P + '-pill" hidden></span></div>' + 68 '<span class="' + P + '-cue" aria-hidden="true">Beweeg de loep over het glas</span></div>'; 69 var tbox = '<div class="' + P + '-tiles">' + tiles(s) + "</div>"; 70 var cap = '<div class="' + P + '-cap"' + (compact || panel ? "" : ' aria-live="polite"') + ">" + capHTML(s, compact, pre, panel) + "</div>"; 71 /* panel: inside the glass test tabs (glass-glastest.js), the tab names it */ 72 if (panel) return '<div class="' + P + " " + P + '--panel"' + data + ">" + media + '<div class="' + P + '-side">' + tbox + cap + "</div></div>"; 73 media += tbox; 74 if (compact) return '<section class="section" aria-labelledby="' + pre + '-h"><div class="container ' + P + " " + P + '--compact"' + data + '><div class="' + P + '-head"><span class="label">Klantfoto</span></div><div class="' + P + '-media">' + media + '</div><div class="' + P + '-copy">' + cap + "</div></div></section>"; 75 var chips = list.length > 1 ? '<div class="' + P + '-chips" role="group" aria-label="Kies een klantfoto">' + list.map(function(x, i){ return '<button type="button" class="' + P + '-chip" data-slide="' + i + '" aria-pressed="' + !i + '">' + esc(x.chip) + "</button>"; }).join("") + "</div>" : ""; 76 var qs = o.quotes === false ? "" : QUOTES.map(function(q){ 77 var r = review(q); 78 return r ? "<li><figure><blockquote><p>â" + (q.lead ? "â¦" : "") + esc(q.text) + "â</p></blockquote><figcaption>" + who(r) + "</figcaption></figure></li>" : ""; 79 }).join(""); 80 return '<div class="' + P + '"' + data + '><div class="' + P + '-top"><span class="' + P + '-eye">' + H.icon("image") + "Klantfoto</span>" + chips + "</div>" + media + cap + 81 (qs ? '<div class="' + P + '-list"><h3 class="' + P + '-lh">Kopers over het glas</h3><ul class="' + P + '-qs">' + qs + '</ul><a class="link ' + P + '-all" href="' + H.url("/beoordelingen") + '">Lees alle beoordelingen' + H.icon("arrow") + "</a></div>" : "") + "</div>"; 82 } 83 84 function inPoly(x, y, p){ 85 for (var c = false, i = 0, j = p.length - 1; i < p.length; j = i++) if ((p[i][1] > y) !== (p[j][1] > y) && x < (p[j][0] - p[i][0]) * (y - p[i][1]) / (p[j][1] - p[i][1]) + p[i][0]) c = !c; 86 return c; 87 } 88 /* what is under the loupe centre: HOWAT-glas, the worktop label, or nothing */ 89 function name(s, x, y){ 90 if (s.skip.some(function(p){ return inPoly(x, y, p); })) return ""; 91 return inPoly(x, y, s.glass) ? "HOWAT-glas" : s.top && inPoly(x, y, s.top) ? s.out : ""; 92 } 93 function ease(t){ return t < .5 ? 4 * t * t * t : 1 - Math.pow(2 - 2 * t, 3) / 2; } 94 function clamp(v){ return v < 0 ? 0 : v > 1 ? 1 : v; } 95 function pt(a){ return { x: a[0] / 100, y: a[1] / 100 }; } 96 97 function bind(el){ 98 el.setAttribute("data-gf-spiegel-bound", ""); 99 el.classList.add("is-live"); 100 function $(c){ return el.querySelector("." + P + "-" + c); } 101 var list = el.getAttribute("data-gf-spiegel").split(",").map(slide), compact = el.classList.contains(P + "--compact"); 102 var stage = $("stage"), hit = $("hit"), loupe = $("loupe"), pill = $("pill"), tbox = $("tiles"), cbox = $("cap"); 103 var imgs = [].slice.call(el.querySelectorAll("." + P + "-img")), chips = [].slice.call(el.querySelectorAll("." + P + "-chip")); 104 var cur = 0, s = list[0], pos = pt(s.start), chosen = null, anim = null, raf = 0, ready = false, seen = false, sched = false, glided = {}, want = -1; 105 var dW = 0, dH = 0, D = 0, z = 1, label = null, up = null, rect = null, grab = null, L = [], io, ro, t1 = 0, t2 = 0, idle = 0; 106 function on(t, e, f, o){ t.addEventListener(e, f, o); L.push([t, e, f, o]); } 107 108 /* the loupe circle always stays whole inside the photo; near an edge the magnified point moves off its centre */ 109 function centre(){ var R = D / 2; return { x: Math.max(R, Math.min(dW - R, pos.x * dW)), y: Math.max(R, Math.min(dH - R, pos.y * dH)) }; } 110 function paint(){ 111 if (!dW) return; 112 var R = D / 2, x = pos.x * dW, y = pos.y * dH, c = centre(), st = loupe.style; 113 st.transform = "translate3d(" + (c.x - R) + "px," + (c.y - R) + "px,0)"; 114 st.backgroundPosition = (R + x - c.x - x * z) + "px " + (R + y - c.y - y * z) + "px"; 115 var l = name(s, pos.x * 100, pos.y * 100); 116 if (l !== label) { label = l; pill.textContent = l; pill.hidden = !l; } 117 /* the pill sits low in the loupe; when the magnified point is in the lower part it moves to the top */ 118 var u = y - c.y > R * .25; 119 if (u !== up) { up = u; loupe.classList.toggle(P + "-up", u); } 120 } 121 function frame(now){ 122 raf = 0; 123 if (anim) { 124 anim.t0 = anim.t0 || now; 125 var t = Math.min(1, (now - anim.t0) / anim.d), k = ease(t); 126 pos = { x: anim.x + (anim.tx - anim.x) * k, y: anim.y + (anim.ty - anim.y) * k }; 127 if (t === 1) anim = null; 128 }
129 paint(); 130 if (anim) raf = requestAnimationFrame(frame); 131 } 132 function kick(){ if (!raf) raf = requestAnimationFrame(frame); } 133 function glide(p, d){ anim = { x: pos.x, y: pos.y, tx: p.x, ty: p.y, d: d }; kick(); } 134 function jump(p){ anim = null; pos = p; kick(); } 135 function measure(){ 136 /* at most 2.5x; the photo itself is upscaled only when 1.5x of a wide stage needs it, so the loupe stays sharp */ 137 dW = stage.clientWidth; dH = dW * s.h / s.w; D = loupe.offsetWidth; z = dW ? Math.min(2.5, Math.max(s.w, 1.5 * dW) / dW) : 1; rect = null; 138 loupe.style.backgroundSize = dW * z + "px " + dH * z + "px"; 139 paint(); 140 } 141 function user(){ anim = null; glided[cur] = true; el.classList.add("is-used"); } 142 function press(i){ [].forEach.call(tbox.children, function(b, k){ b.setAttribute("aria-pressed", k === i); }); } 143 function spot(i){ var sp = s.spots[i]; if (!sp) return; user(); chosen = pt(sp.at); press(i); glide(chosen, 400); } 144 function at(e){ rect = rect || stage.getBoundingClientRect(); return { x: clamp((e.clientX - rect.left) / dW), y: clamp((e.clientY - rect.top) / dH) }; } 145 function paintImg(){ 146 var u = 'url("' + s.img + '")'; 147 loupe.style.backgroundImage = u; 148 [].forEach.call(tbox.querySelectorAll("." + P + "-tv"), function(t){ t.style.backgroundImage = u; }); 149 debug(); 150 } 151 function debug(){ 152 var old = stage.querySelector("svg"), c = function(a, col){ return '<circle cx="' + a[0] * s.w / 100 + '" cy="' + a[1] * s.h / 100 + '" r="9" fill="none" stroke="' + col + '" stroke-width="4"/>'; }; 153 if (old) old.remove(); 154 if (H.spiegel.debug) stage.insertAdjacentHTML("beforeend", '<svg viewBox="0 0 ' + s.w + " " + s.h + '" style="position:absolute;left:0;top:0;width:100%;z-index:1;pointer-events:none">' + [[s.glass, "#0f0"], [s.top, "orange"]].concat(s.skip.map(function(p){ return [p, "magenta"]; })).map(function(g){ return g[0] ? '<polygon points="' + g[0].map(function(a){ return a[0] * s.w / 100 + "," + a[1] * s.h / 100; }).join(" ") + '" fill="none" stroke="' + g[1] + '" stroke-width="3"/>' : ""; }).join("") + c(s.start, "red") + c(s.rest, "blue") + s.spots.map(function(sp){ return c(sp.at, "yellow"); }).join("") + "</svg>"); 155 } 156 function loaded(m, f){ 157 if (m.complete && m.naturalWidth) return f(); 158 var g = function(){ m.removeEventListener("load", g); f(); }; 159 on(m, "load", g); 160 } 161 function load(i){ var m = imgs[i]; if (m && !m.getAttribute("src")) { m.loading = "eager"; m.src = m.getAttribute("data-src"); } } 162 function maybe(){ if (ready && seen && !glided[cur]) { glided[cur] = true; jump(pt(s.start)); glide(pt(s.rest), 2200); } } 163 164 function show(i){ 165 if (i === want || (i === cur && want < 0)) return; 166 want = i; user(); load(i); 167 chips.forEach(function(c, k){ c.setAttribute("aria-pressed", k === i); }); 168 loaded(imgs[i], function(){ 169 if (want !== i) return; 170 want = -1; 171 if (i === cur) return; 172 var h0 = stage.offsetHeight, w = stage.clientWidth; 173 cur = i; s = list[i]; grab = null; chosen = null; 174 el.classList.add("is-switch"); 175 /* aspect-ratio off while the height animates: with a fixed height it would size the width instead */ 176 stage.style.aspectRatio = "auto"; 177 stage.style.height = h0 + "px"; 178 void stage.offsetHeight; 179 stage.style.height = w * s.h / s.w + "px"; 180 imgs.forEach(function(m, k){ m.classList.toggle("is-on", k === i); if (k === i) m.removeAttribute("aria-hidden"); else m.setAttribute("aria-hidden", "true"); }); 181 clearTimeout(t2); 182 t2 = setTimeout(function(){ 183 t2 = 0; stage.style.aspectRatio = s.w + "/" + s.h; stage.style.height = ""; 184 tbox.innerHTML = tiles(s); cbox.innerHTML = capHTML(s, compact); 185 ready = true; el.classList.add("is-ready"); paintImg(); 186 label = null; jump(pt(s.start)); measure(); 187 el.classList.remove("is-switch"); 188 glided[i] = true; glide(pt(s.rest), 1600); 189 }, 260); 190 }); 191 } 192 193 on(stage, "pointerenter", function(e){ if (e.pointerType === "mouse") rect = null; }); 194 on(window, "scroll", function(){ rect = null; }, { passive: true }); 195 on(stage, "pointerdown", function(e){ 196 if (!ready) return; 197 rect = null; 198 var p = at(e), c = centre(), dx = p.x * dW - c.x, dy = p.y * dH - c.y; 199 if (e.pointerType === "mouse") { user(); chosen = p; press(-1); return; } 200 /* on touch nothing moves until the finger goes sideways; a vertical swipe, also on the loupe, scrolls the page */ 201 grab = { id: e.pointerId, cx: e.clientX, cy: e.clientY, dx: pos.x - p.x, dy: pos.y - p.y, drag: dx * dx + dy * dy <= Math.pow(D / 2 + 12, 2) }; 202 }); 203 function capture(e){ try { stage.setPointerCapture(e.pointerId); } catch (x) {} } 204 on(stage, "pointermove", function(e){ 205 if (!ready) return; 206 if (e.pointerType === "mouse") { user(); jump(at(e)); return; } 207 if (!grab || grab.id !== e.pointerId) return; 208 var p = at(e), mx = Math.abs(e.clientX - grab.cx), my = Math.abs(e.clientY - grab.cy); 209 if (!grab.lock) { 210 if (mx <= 10 || mx <= my) return; 211 grab.lock = true; user(); capture(e); press(-1); 212 if (grab.drag) loupe.classList.add("is-drag"); 213 } 214 if (grab.drag) jump({ x: clamp(p.x + grab.dx), y: clamp(p.y + grab.dy) }); else glide(p, 120); 215 }); 216 function release(e){ 217 if (!grab || grab.id !== e.pointerId) return; 218 var g = grab, p = at(e); 219 grab = null; loupe.classList.remove("is-drag"); 220 if (e.type === "pointercancel") return; 221 /* a tap, also on or near the loupe, glides it to that point */ 222 if (Math.abs(e.clientX - g.cx) < 10 && Math.abs(e.clientY - g.cy) < 10) { user(); chosen = p; press(-1); glide(p, 300); } 223 else chosen = g.lock && !g.drag ? p : pos; 224 } 225 on(stage, "pointerup", release); 226 on(stage, "pointercancel", release); 227 on(stage, "pointerleave", function(e){ if (e.pointerType === "mouse" && ready) glide(chosen || pt(s.rest), 300); }); 228 on(hit, "keydown", function(e){ 229 var k = e.key, dx = k === "ArrowLeft" ? -1 : k === "ArrowRight" ? 1 : 0, dy = k === "ArrowUp" ? -1 : k === "ArrowDown" ? 1 : 0; 230 if (dx || dy) { var b = anim ? { x: anim.tx, y: anim.ty } : pos; user(); chosen = { x: clamp(b.x + dx * .03), y: clamp(b.y + dy * .03) }; press(-1); glide(chosen, 140); } 231 else if (k === "Home" || k === "End") spot(k === "Home" ? 0 : 1); 232 else return; 233 e.preventDefault(); 234 }); 235 on(tbox, "click", function(e){ var b = e.target.closest("[data-spot]"); if (b) spot(+b.getAttribute("data-spot")); }); 236 chips.forEach(function(c, i){ 237 on(c, "click", function(){ show(i); }); 238 on(c, "pointerenter", function(){ load(i); }); 239 on(c, "focus", function(){ load(i); }); 240 }); 241
242 /* panel: first photo from data-src once the glass block (also while this tab is hidden) comes within 600 px */ 243 var near = null; 244 if (!imgs[0].getAttribute("src")) { 245 if (window.IntersectionObserver) { near = new IntersectionObserver(function(es){ if (es[0].isIntersecting) { load(0); near.disconnect(); } }, { rootMargin: "600px 0px" }); near.observe(el.closest("[data-gt]") || el); } 246 else load(0); 247 } 248 loaded(imgs[0], function(){ if (cur || want > 0) return; ready = true; el.classList.add("is-ready"); paintImg(); measure(); maybe(); }); 249 if (window.ResizeObserver) { ro = new ResizeObserver(measure); ro.observe(stage); } else on(window, "resize", measure); 250 if (window.IntersectionObserver) { 251 io = new IntersectionObserver(function(es){ 252 var e = es[es.length - 1]; 253 if (!e.isIntersecting) return; 254 if (!sched && list.length > 1) { sched = true; t1 = setTimeout(function(){ t1 = 0; if (window.requestIdleCallback) idle = requestIdleCallback(function(){ idle = 0; list.forEach(function(x, i){ load(i); }); }, { timeout: 2000 }); else list.forEach(function(x, i){ load(i); }); }, 2000); } 255 if (e.intersectionRatio >= .5) { seen = true; maybe(); } 256 if (seen && (sched || list.length < 2)) io.disconnect(); 257 }, { threshold: [0, .5] }); 258 io.observe(stage); 259 } else { seen = true; list.forEach(function(x, i){ load(i); }); } 260 261 return function(){ 262 L.forEach(function(l){ l[0].removeEventListener(l[1], l[2], l[3]); }); 263 L = []; 264 if (io) io.disconnect(); 265 if (ro) ro.disconnect(); 266 if (near) near.disconnect(); 267 cancelAnimationFrame(raf); clearTimeout(t1); clearTimeout(t2); 268 if (idle) cancelIdleCallback(idle); 269 raf = t1 = t2 = idle = 0; anim = null; 270 el.removeAttribute("data-gf-spiegel-bound"); 271 el.classList.remove("is-live", "is-ready", "is-switch", "is-used"); 272 stage.style.height = ""; stage.style.aspectRatio = s.w + "/" + s.h; 273 }; 274 } 275 276 function mount(root){ 277 var offs = [].map.call((root || document).querySelectorAll("[data-gf-spiegel]:not([data-gf-spiegel-bound])"), bind); 278 return function(){ offs.splice(0).forEach(function(f){ f(); }); }; 279 } 280 H.spiegel = { html: html, mount: mount, debug: false }; 281})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.