PageSourceSearch

https://howat.nl/wp-content/themes/howat-2026/assets/glass-spiegel.js?ver=sp1-gx3b-gx4-r17p-p1-i2-202610021221

js howat.nl collected 2026-10-06 15:00:47 UTC 20,230 bytes, 281 lines download raw bytes

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.