PageSourceSearch

https://buurtscan.com/zon3d.js?v=20260928c

js buurtscan.com collected 2026-10-02 01:24:57 UTC 45,922 bytes, 978 lines download raw bytes

1/**
2 * Zon in de tuin — ruimtelijke weergave van de omgeving.
3 *
4 * Axonometrische (parallelle) projectie op een gewoon 2D-canvas. Geen WebGL,
5 * geen bibliotheek: ongeveer 6 KB in plaats van 152 KB three.js over de lijn.
6 *
7 * Waarom parallel en niet perspectief
8 * -----------------------------------
9 * Bij een parallelprojectie is het maaiveld een LINEAIRE afbeelding van het
10 * rekenraster. Daardoor kan het berekende zonbeeld er met één affiene matrix
11 * op geprojecteerd worden (setTransform + drawImage) — exact, niet benaderd.
12 *
13 * Dat is het hele punt. Een WebGL-shadowmap ziet eruit als een berekening maar
14 * is een plaatje; wie hem naast de cijfers zet, heeft twee waarheden op één
15 * pagina. Hier is de zon op de grond hetzelfde raster dat de tegels en de
16 * tijdschuif voeden: dezelfde straaltracering, alleen schuin bekeken.
17 *
18 * Wat hier WEL een benadering is: de zijvlakken van gebouwen krijgen een vaste
19 * schaduwtint per oriëntatie, en bomen zijn bollen. Dat is decoratie en het
20 * draagt geen getal.
21 *
22 * Dezelfde projectie draait serverzijdig in PIL voor het PDF (zon_kaart.py),
23 * zodat het rapport en het scherm hetzelfde beeld tonen.
24 */
25(function () {
26  'use strict';
27
28  var KANTEL_START = 42 * Math.PI / 180;   // vanaf horizontaal; 90° = plattegrond
29  // Kantelgrenzen bewust smal. Het beeld bestaat om het BEREKENDE zonbeeld op
30  // de grond te tonen; onder ~30 graden zie je vooral gevels en is de grond
31  // weg, boven ~78 is het weer de plattegrond die we net vervangen hebben.
32  var KANTEL_MIN = 30 * Math.PI / 180;
33  var KANTEL_MAX = 78 * Math.PI / 180;
34  // Maaiveld in EEN tint, met schaduw als halfdoorzichtig zwart daaroverheen.
35  //
36  // Eerder verving schaduw de grondkleur door een vlak blok: blauw in de
37  // tuin, grijslila eromheen. Twee bezwaren. Dezelfde schaduw kreeg twee
38  // kleuren, terwijl schaduw schaduw is. En een massief gekleurd vierkant
39  // leest als pixel, waar een donkere waas als schaduw leest -- met een waas
40  // loopt de ondergrond eronder door en valt de 0,5 m-korrel veel minder op.
41  var KLEUR = {
42    maaiveld:   '#e7e1d1',   // grond in de zon
43    // Terreinvlakken uit de BGT. Ze mogen de schaduw niet overstemmen, maar de
44    // eerste versie was zó gedempt dat de weg onzichtbaar bleef: #dcd7cb ligt
45    // op een paar RGB-punten van de grondtint. Nu ver genoeg uit elkaar om te
46    // lezen, en nog steeds lichter dan de schaduwtint zodat schaduw wint.
47    tWater:     '#a7c3dc',
48    tWeg:       '#c2beb4',
49    tVerhard:   '#d5d1c8',
50    tErf:       '#ddd3bd',
51    tGroen:     '#cddcae',
52    schaduw:    '#14161c',   // wordt halfdoorzichtig overheen gelegd
53    // Buiten de rekentegel dezelfde tint als het maaiveld: een afwijkend
54    // grijs maakte van de tegelrand een harde lijn die als ontbrekende data
55    // las; zo loopt het beeld gewoon uit.
56    buiten:     '#e7e1d1',
57    kroon:      '#2f6b34',
58    stam:       '#5a4632',
59    dakEigen:   '#f0b429',
60    wandEigen:  '#c98f1e',
61    rand:       '#6f6a60',
62    // De perceelgrens in dezelfde oranje als het eigen huis, met een lichte
63    // onderlijn. Zwart leek neutraal, maar een dunne donkere lijn verdwijnt in
64    // de slagschaduw en tegen de donkere kant van een gevel -- precies waar hij
65    // het vaakst langsloopt. Oranje is in dit beeld al "van u", dus de lijn en
66    // het gemarkeerde huis horen zichtbaar bij elkaar.
67    perceel:    '#ce700e',
68    perceelRand: '#fcf6eb',
69    raam:       '#191b1f',
70  };
71
72  // Hoe zwart de schaduw op de grond wordt (0..1).
73  var SCHADUW_STERKTE = 0.55;
74
75  // Een straat waarin elk pand exact dezelfde kleur heeft leest als een
76  // massa. Vandaar een kleine reeks neutralen -- warm en koel door elkaar,
77  // zoals een echte straat -- die per pand vast gekozen wordt uit zijn eigen
78  // index, zodat hij niet wisselt bij elke hertekening.
79  var PANDTINTEN = [
80    { wand: '#c6bfb1', dak: '#efe9dc' },
81    { wand: '#b4ada1', dak: '#e4ddcf' },
82    { wand: '#cdc7bb', dak: '#f2eee4' },
83    { wand: '#bdb2a0', dak: '#e9e1d2' },
84    { wand: '#b8b8b2', dak: '#eceae4' },
85    { wand: '#c9bfae', dak: '#f0e8da' },
86    { wand: '#aaa79f', dak: '#e0ddd6' },
87  ];
88
89  // Hoe donker een gevel wordt die geen zon vangt. Niet nul: een noordgevel
90  // is niet zwart, hij krijgt hemellicht. Ook 's nachts blijft het beeld
91  // hierdoor leesbaar.
92  var AMBIENT = 0.68;
93
94  // Gevelmaten, uit de Nederlandse praktijk en niet uit de duim (zie de
95  // toelichting bij tekenRamen). Peil = bovenkant begane-grondvloer boven
96  // maaiveld; borstwering = vloer tot onderkant kozijn.
97  var PEIL_M = 0.18;
98  var BORSTWERING_M = 0.85;
99  var VERDIEPING_M = 2.9;      // vrije hoogte 2,4-2,6 plus vloerdikte
100  var RAAM_H_M = 1.45;
101  var RAAM_B_M = 1.15;
102
103  // ---------- LoD 2.2-schil ----------
104  //
105  // De server stuurt per pand de gereconstrueerde gevels en dakvlakken mee,
106  // in decimeters t.o.v. de linkeronderhoek van het tekenvenster en het
107  // maaiveld. Waarom niet gewoon JSON: dit is per pand een handvol vlakken van
108  // negen hoekpunten, en als tekst kost dat ruim drie keer zoveel.
109  //
110  // Ontbreekt de schil, of is hij onleesbaar, dan blijft pand_hoogtes_m over
111  // en tekent de weergave het blok dat ze daarvoor overal tekende.
112  var WAND_V = 0, DAK_V = 1;
113
114  function leesVormen(z) {
115    var b64 = (z.polygonen || {}).pand_vormen_b64;
116    var venster = (z.raster || {}).venster;
117    if (!b64 || !venster) return null;
118    try {
119      var bin = atob(b64), bytes = new Uint8Array(bin.length), i;
120      for (i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
121      if (bytes[0] !== 1) return null;                 // ander formaat: blok
122      var dv = new DataView(bytes.buffer), p = 1;
123      var aantal = dv.getUint16(p, true); p += 2;
124      var xr = venster[0], yr = venster[1], uit = [];
125      for (var k = 0; k < aantal; k++) {
126        var nv = dv.getUint16(p, true); p += 2;
127        var vlakken = [];
128        for (var v = 0; v < nv; v++) {
129          var soort = bytes[p], np = bytes[p + 1]; p += 2;
130          var pts = new Array(np);
131          for (var j = 0; j < np; j++) {
132            pts[j] = [xr + dv.getInt16(p, true) / 10,
133                      yr + dv.getInt16(p + 2, true) / 10,
134                      dv.getInt16(p + 4, true) / 10];
135            p += 6;
136          }
137          pts.push(pts[0]);                            // gesloten ring
138          vlakken.push({ soort: soort, pts: pts });
139        }
140        uit.push(vlakken.length ? vlakken : null);
141      }
142      return uit;
143    } catch (e) {
144      console.warn('[zon] vormen lezen faalde:', e);
145      return null;
146    }
147  }
148
149  function ZonRuimtelijk(host, z) {
150    var F = z.raster_fijn;
151    var pol = z.polygonen || {};
152    var panden = pol.panden || [];
153    var pandH = pol.pand_hoogtes_m || [];
154    var eigenIdx = (typeof pol.hoofdpand_idx === 'number') ? pol.hoofdpand_idx : -1;
155    var vormen = leesVormen(z);
156
157    var breed = F.nx * F.res, diep = F.ny * F.res;
158
159    // Kaderen op het PERCEEL, niet op de rekentegel.
160    //
161    // De tegel is een vierkant van tientallen meters rondom; recht van voren
162    // valt dat niet op, maar zodra je draait zie je de rand ervan als een
163    // ruit met rafelige hoeken en dat leest als ontbrekende data. Het is
164    // geen ontbrekende data: verder weg hoeft alleen nog schaduw geworpen te
165    // worden, niet getekend. Dus draaien we om het perceel en kaderen we op
166    // het perceel plus een rand, waardoor die tegelrand buiten beeld valt.
167    var perceelRing = (pol.perceel || [])[0] || [];
168    var MARGE_M = 14;                   // zoveel omgeving houden we eromheen
169    var MIN_KADER_M = 26;               // bij een postzegeltuin niet absurd inzoomen
170    // Nooit meer dan dit deel van de rekentegel tonen: de rand van die tegel
171    // is een rafelige rasterrand die als ontbrekende data leest, en die hoort
172    // buiten beeld te vallen -- ook als je draait, en juist dán, want een
173    // vierkant dat een kwartslag gedraaid is heeft nog maar 1/wortel2 (0,71)
174    // van zijn zijde als recht ingeschreven vlak. Vandaar ruim daaronder.
175    var TEGEL_AANDEEL = 0.85;
176    var cx, cy, kaderB, kaderD;
177    (function kader() {
178      if (perceelRing.length < 3) {     // geen perceel: terug naar de tegel
179        cx = F.x0 + breed / 2; cy = F.y1 - diep / 2;
180        kaderB = breed; kaderD = diep;
181        return;
182      }
183      var xs = perceelRing.map(function (p) { return p[0]; });
184      var ys = perceelRing.map(function (p) { return p[1]; });
185      var x0 = Math.min.apply(null, xs), x1 = Math.max.apply(null, xs);
186      var y0 = Math.min.apply(null, ys), y1 = Math.max.apply(null, ys);
187      cx = (x0 + x1) / 2; cy = (y0 + y1) / 2;
188      // Het hele kadastrale perceel past erin -- zolang het IN het rekenvenster
189      // past. Bij een landelijk kavel van 246 m is dat niet zo: daar staat
190      // alleen het deel bij de woning in het venster (zie VENSTER_MAX_M in
191      // zon.py), en dan is de vensterrand het plafond. Zonder dat plafond
192      // zoomde een tuin van 2,8 hectare het huis weg tot een stipje.
193      kaderB = Math.min(breed * TEGEL_AANDEEL,
194                        Math.max(MIN_KADER_M, (x1 - x0) + 2 * MARGE_M));
195      kaderD = Math.min(diep * TEGEL_AANDEEL,
196                        Math.max(MIN_KADER_M, (y1 - y0) + 2 * MARGE_M));
197
198      // Draaien vraagt de DIAGONAAL van het kader aan de tegel, niet de
199      // zijde. Past die niet in de smalste tegelmaat, dan krimpen we het
200      // kader evenredig -- anders komt bij een kwartslag alsnog de rand in
201      // beeld, precies waar het probleem zich voordeed.
202      var diagonaal = Math.hypot(kaderB, kaderD);
203      var ruimte = Math.min(breed, diep);
204      if (diagonaal > ruimte) {
205        kaderB *= ruimte / diagonaal;
206        kaderD *= ruimte / diagonaal;
207      }
208
209      // En het midden zo verschuiven dat het kader binnen de tegel blijft.
210      // Een perceel dat aan de rand van de tegel ligt zou anders het gat
211      // naast de tegel mee in beeld trekken.
212      var wcx = F.x0 + breed / 2, wcy = F.y1 - diep / 2;
213      var r = Math.hypot(kaderB, kaderD) / 2;
214      var speelX = Math.max(0, breed / 2 - r), speelY = Math.max(0, diep / 2 - r);
215      cx = Math.min(wcx + speelX, Math.max(wcx - speelX, cx));
216      cy = Math.min(wcy + speelY, Math.max(wcy - speelY, cy));
217    })();
218    var cv = document.createElement('canvas');
219    var ctx = cv.getContext('2d');
220    host.appendChild(cv);
221
222    var hoek = 0;                       // 0 = kijkend vanuit het zuiden
223    var kanteling = KANTEL_START;       // slepen omhoog/omlaag verandert hem
224    var schaal = 1;                     // px per meter, per hertekening bepaald
225    var W = 0, Hp = 0, midX = 0, midY = 0;
226    // Hoogte voor het kaderen: alleen panden die in beeld staan. Nam je de
227    // hoogste van de hele tegel, dan zoomt één flat twee straten verderop
228    // het perceel weg zonder zelf zichtbaar te zijn.
229    var maxH = (function () {
230      var h = 9;
231      panden.forEach(function (ring, i) {
232        if (!ring || ring.length < 3) return;
233        var raakt = ring.some(function (p) {
234          return Math.abs(p[0] - cx) <= kaderB / 2 && Math.abs(p[1] - cy) <= kaderD / 2;
235        });
236        if (raakt) h = Math.max(h, pandH[i] || 0);
237      });
238      return h;
239    })();
240
241    // Schaduwtextuur op DUBBELE dichtheid: 2x2 px per rekencel.
242    //
243    // Niet om resolutie te verzinnen -- elke cel houdt gewoon zijn eigen
244    // waarde -- maar om de filtering te temmen. Bij 1 px per cel smeert het
245    // bilineaire filter de rand over een volle 0,5 m uit, en dat is véél
246    // zachter dan de werkelijkheid: de zon is een schijf van 0,53 graden,
247    // goed voor 9 cm halfschaduw op tien meter en 28 cm op dertig. Met 2x2
248    // px per cel loopt dezelfde filtering over 0,25 m, dus dichter bij wat
249    // er echt gebeurt -- en scherper dan wat het daarvoor was.
250    var TEX = 2;
251    var rasterCv = document.createElement('canvas');
252    rasterCv.width = F.nx * TEX; rasterCv.height = F.ny * TEX;
253    var rasterCtx = rasterCv.getContext('2d');
254    var beeld = rasterCtx.createImageData(F.nx * TEX, F.ny * TEX);
255
256    function maat() {
257      W = host.clientWidth || 640;
258      Hp = Math.round(W * 0.64);
259      var dpr = Math.min(2, window.devicePixelRatio || 1);
260      cv.width = Math.round(W * dpr); cv.height = Math.round(Hp * dpr);
261      cv.style.width = W + 'px'; cv.style.height = Hp + 'px';
262      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
263
264      // Schaal uit de WERKELIJK geprojecteerde omhullende, niet uit de
265      // diagonaal: bij een parallelprojectie wordt de dieptekant met
266      // sin(kanteling) samengedrukt, dus een schatting op de diagonaal laat
267      // ruim een derde van het canvas ongebruikt.
268      var ct = Math.cos(hoek), st = Math.sin(hoek), sf = Math.sin(kanteling), cf = Math.cos(kanteling);
269      var hw = kaderB / 2, hd = kaderD / 2;
270      var xs = [], ys = [];
271      [-hw, hw].forEach(function (x) {
272        [-hd, hd].forEach(function (y) {
273          [0, maxH].forEach(function (zz) {
274            xs.push(x * ct - y * st);
275            ys.push(-((x * st + y * ct) * sf + zz * cf));
276          });
277        });
278      });
279      var x0 = Math.min.apply(null, xs), x1 = Math.max.apply(null, xs);
280      var y0 = Math.min.apply(null, ys), y1 = Math.max.apply(null, ys);
281      schaal = Math.min(W / Math.max(1e-6, x1 - x0), Hp / Math.max(1e-6, y1 - y0));
282      function plaats() {
283        // Verschuiving zó dat de omhullende netjes in het canvas staat.
284        midX = W / 2 - schaal * (x0 + x1) / 2;
285        midY = Hp / 2 - schaal * (y0 + y1) / 2;
286      }
287      plaats();
288
289      // Bijzoomen tot het CANVAS binnen de tegel valt, niet het kader.
290      //
291      // Een contain-fit past het kader in het canvas, maar langs de as die
292      // niet bindt blijft ruimte over, en daar loopt het beeld dus door tot
293      // voorbij de tegelrand. Die rand is een rafelige rasterrand en leest
294      // als ontbrekende data -- precies de klacht. Dus rekenen we de vier
295      // canvashoeken terug naar de grond en zoomen we in tot ze alle vier
296      // binnen de tegel liggen. Met een bovengrens: is het perceel groter
297      // dan de tegel, dan valt er niets te redden en is doorzoomen erger
298      // dan de rand.
299      var tx0 = F.x0, tx1 = F.x0 + breed, ty0 = F.y1 - diep, ty1 = F.y1;
300      function hoekenBinnen() {
301        for (var i = 0; i < 4; i++) {
302          var X = (i & 1 ? W : 0) - midX, Y = (i & 2 ? Hp : 0) - midY;
303          var u = X / schaal, v = -Y / (schaal * sf);
304          var wx = u * ct + v * st + cx, wy = -u * st + v * ct + cy;
305          if (wx < tx0 || wx > tx1 || wy < ty0 || wy > ty1) return false;
306        }
307        return true;
308      }
309      // Hooguit een duwtje. Doorzoomen tot er gegarandeerd geen tegelrand
310      // meer in beeld komt maakte van een rijtjeshuis een close-up waarin de
311      // buren wegvielen -- en juist die buren werpen de schaduw. Het perceel
312      // moet in beeld staan; dat weegt zwaarder. Wat er aan rand overblijft
313      // valt weg tegen de uitloopkleur, die dezelfde tint heeft als de
314      // gedempte omgeving.
315      var grens = schaal * 1.15;
316      for (var poging = 0; poging < 60 && schaal < grens && !hoekenBinnen(); poging++) {
317        schaal *= 1.04;
318        plaats();
319      }
320    }
321
322    // Wereld (meters t.o.v. het midden, z omhoog) → scherm.
323    function proj(x, y, zz) {
324      var ct = Math.cos(hoek), st = Math.sin(hoek);
325      return [schaal * (x * ct - y * st),
326              -schaal * ((x * st + y * ct) * Math.sin(kanteling) + zz * Math.cos(kanteling))];
327    }
328    // Diepte: groter = verder weg, dus eerst tekenen.
329    function diepte(x, y) { return x * Math.sin(hoek) + y * Math.cos(hoek); }
330
331    /**
332     * Schaduwlaag: zwart met alfa per cel, verder niets.
333     *
334     * Het maaiveld zelf is één vlakke tint die eronder ligt; hier komt alleen
335     * de schaduw overheen. Omdat we alfa laten interpoleren in plaats van
336     * kleur, lopen de randen in elkaar over en valt de 0,5 m-korrel veel
337     * minder op dan bij vlakken van volle kleur.
338     *
339     * De voetafdruk van een pand krijgt geen schaduw: daar staat straks het
340     * prisma overheen, en een donkere vlek die er een fractie naast valt
341     * leest als een fout.
342     */
343    function vulZonraster(zonMask, dek) {
344      var d = beeld.data, W2 = F.nx * TEX;
345      var zw = SCHADUW_STERKTE * 255;
346      for (var r = 0; r < F.ny; r++) {
347        for (var c = 0; c < F.nx; c++) {
348          var i = r * F.nx + c;
349          // Dekking waar die er is (0 = vol schaduw, 255 = vol zon), anders
350          // terug naar het aan/uit-masker. Randcellen zijn daardoor deels
351          // donker in plaats van helemaal, en dat haalt de trap eruit.
352          var a;
353          if (fGeb[i]) a = 0;
354          else if (dek) a = zw * (1 - dek[i] / 255);
355          else a = zonMask[i] ? 0 : zw;
356          for (var sr = 0; sr < TEX; sr++) {
357            var rij = (r * TEX + sr) * W2 + c * TEX;
358            for (var sc = 0; sc < TEX; sc++) {
359              var o = (rij + sc) * 4;
360              d[o] = 20; d[o + 1] = 22; d[o + 2] = 28; d[o + 3] = a;
361            }
362          }
363        }
364      }
365      rasterCtx.putImageData(beeld, 0, 0);
366    }
367
368    var fGeb = null, fH = null, fG = null, fTuin = null;
369    function zetRasters(geb, h, g, tuinBits) {
370      fGeb = geb; fH = h; fG = g; fTuin = tuinBits || null;
371    }
372
373    /** Het maaiveld met het BEREKENDE zonbeeld erop, via één affiene matrix. */
374    function tekenGrond() {
375      var ct = Math.cos(hoek), st = Math.sin(hoek), sf = Math.sin(kanteling);
376      var res = F.res / TEX;                  // textuurpixel, niet rekencel
377      var A = F.x0 + 0.5 * res - cx;          // wereld-x bij kolom 0
378      var B = F.y1 - 0.5 * res - cy;          // wereld-y bij rij 0
379      var a = schaal * ct * res;
380      var b = -schaal * sf * st * res;
381      var c = schaal * st * res;
382      var d = schaal * sf * ct * res;
383      var e = schaal * (A * ct - B * st);
384      var f = -schaal * sf * (A * st + B * ct);
385      ctx.save();
386      ctx.translate(midX, midY);
387      ctx.transform(a, b, c, d, e, f);
388      // Wél filteren bij het opschalen. Het raster is 0,5 m -- dat is de
389      // resolutie van het AHN en daar valt niets aan te verzinnen -- maar
390      // ongefilterd opschalen maakt er een trap van, en dat leest als een
391      // tekenfout. Een echte schaduwrand ís zacht: de zon is een schijf van
392      // 0,53 graden, wat op tien meter afstand al zo'n tien centimeter
393      // halfschaduw geeft. Het filter zit dus dichter bij de werkelijkheid
394      // dan de trap, zolang we niet doen alsof er meer detail is.
395      ctx.imageSmoothingEnabled = true;
396      ctx.imageSmoothingQuality = 'high';
397      ctx.drawImage(rasterCv, 0, 0);
398      ctx.restore();
399    }
400
401    /**
402     * Terreinvlakken onder de schaduw: water, wegen, groen, verharding.
403     *
404     * Ze gaan er vóór de schaduwlaag op, want schaduw valt erover heen -- een
405     * sloot in de schaduw van een boom is donker water, geen donkere grond.
406     * Volgorde van grof naar fijn, zodat een weg over de verharding ligt waar
407     * de BGT ze allebei kent.
408     */
409    function tekenTerrein() {
410      var vlakken = (pol.terrein || {});
411      // Grof naar fijn: erf onderop, dan verharding, dan groen, dan de weg,
412      // dan water. Waar de BGT twee dingen op dezelfde plek kent wint het
413      // specifiekere.
414      var volgorde = [['erf', KLEUR.tErf], ['verhard', KLEUR.tVerhard],
415                      ['groen', KLEUR.tGroen], ['weg', KLEUR.tWeg],
416                      ['water', KLEUR.tWater]];
417      volgorde.forEach(function (paar) {
418        var ringen = vlakken[paar[0]];
419        if (!ringen || !ringen.length) return;
420        ctx.fillStyle = paar[1];
421        ringen.forEach(function (ring) {
422          if (!ring || ring.length < 3) return;
423          ctx.beginPath();
424          ring.forEach(function (p, i) {
425            var sp = proj(p[0] - cx, p[1] - cy, 0);
426            if (i === 0) ctx.moveTo(sp[0], sp[1]); else ctx.lineTo(sp[0], sp[1]);
427          });
428          ctx.closePath();
429          ctx.fill();
430        });
431      });
432    }
433
434    function padWereld(punten) {
435      ctx.beginPath();
436      punten.forEach(function (p, i) {
437        var s = proj(p[0], p[1], p[2] || 0);
438        if (i === 0) ctx.moveTo(s[0], s[1]); else ctx.lineTo(s[0], s[1]);
439      });
440      ctx.closePath();
441    }
442
443    /** Kleur × factor. Gebruikt voor de wandtint; zie tekenPand. */
444    function tint(hex, f) {
445      var n = parseInt(hex.slice(1), 16);
446      return 'rgb(' + Math.round(((n >> 16) & 255) * f) + ',' +
447                      Math.round(((n >> 8) & 255) * f) + ',' +
448                      Math.round((n & 255) * f) + ')';
449    }
450
451    /** Ligt de grond op dit wereldpunt in de zon? Leest het echte masker. */
452    function grondInZon(wx, wy) {
453      if (!laatsteMask) return true;
454      var c = Math.floor((wx + cx - F.x0) / F.res);
455      var r = Math.floor((F.y1 - (wy + cy)) / F.res);
456      if (r < 0 || c < 0 || r >= F.ny || c >= F.nx) return true;
457      return !!laatsteMask[r * F.nx + c];
458    }
459
460    /**
461     * Ramen op een gevel, op Nederlandse maat.
462     *
463     * Dit is aankleding -- we weten niet waar de ramen echt zitten -- maar de
464     * maten zijn niet verzonnen. Het peil (bovenkant begane-grondvloer) ligt
465     * 150-300 mm boven maaiveld, de borstwering onder een kozijn is 600-900
466     * mm en op verdiepingen minimaal 850, en de vrije verdiepingshoogte is
467     * 2,6 m nieuwbouw tot ~2,4 m bij oudere woningen. De onderste
468     * vensterbank komt daarmee op ongeveer een meter boven het maaiveld.
469     *
470     * Wat hier eerder fout ging was niet die hoogte maar de VERDELING: de
471     * rijen werden vanaf de grond gestapeld met een vaste 3 m, dus een pand
472     * van 8 m kreeg twee rijen en daarboven 2,6 m blinde muur. Dat leest als
473     * ramen die tegen de grond geduwd zijn. Nu bepalen we eerst het aantal
474     * lagen en verdelen we die over de hele gevel, zodat de bovenste rij op
475     * een normale afstand van de dakrand eindigt.
476     *
477     * Bij kleine panden op het scherm slaan we ze over: dan zijn het stippen
478     * en dus ruis in plaats van informatie.
479     */
480    function tekenRamen(p0, p1, h, licht) {
481      var L = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
482      if (L < 2.2 || h < PEIL_M + 2.2) return;
483      if (L * schaal < 34 || h * schaal * Math.cos(kanteling) < 26) return;
484
485      // Aantal lagen NAAR BENEDEN afronden, met een kleine marge.
486      //
487      // De hoogte die we krijgen is 3DBAG's h_70p: het 70-percentiel van het
488      // DAKVLAK. Dat ligt boven het bovenste plafond, want het dak zit erbij
489      // in. Die halve verdieping dak door een verdiepingshoogte delen en dan
490      // afronden levert er systematisch één rij te veel op -- een woning van
491      // 8 m kreeg drie rijen waar er twee horen. Naar beneden afronden haalt
492      // die weg; de 0,2 marge voorkomt dat een platdakpand van 5,5 m net zijn
493      // tweede laag verliest.
494      var wand = h - PEIL_M;
495      var lagen = Math.max(1, Math.min(8, Math.floor(wand / VERDIEPING_M + 0.2)));
496      // Laagafstand binnen wat een woning werkelijk heeft. Zonder bovengrens
497      // zou de resterende dakruimte over de lagen uitgesmeerd worden en
498      // zweefden de ramen uit elkaar; wat overblijft bovenaan ís het dak.
499      var verd = Math.max(2.6, Math.min(3.4, wand / lagen));
500      var bank = Math.min(BORSTWERING_M, verd * 0.32);
501      var rH = Math.min(RAAM_H_M, verd - bank - 0.4);
502      if (rH < 0.7) return;
503      var perLaag = Math.max(1, Math.min(6, Math.floor(L / 2.4)));
504      var ux = (p1[0] - p0[0]) / L, uy = (p1[1] - p0[1]) / L;
505      // Ruiten krijgen de lichtfactor van hun eigen gevel, continu. Eerst
506      // sloegen ze om bij een drempel en dan zie je een pand van kleur
507      // verspringen terwijl de zon nauwelijks bewoog.
508      // Altijd dezelfde zwarte ruit. Een raam dat met de gevel mee oplicht
509      // trekt de aandacht naar de aankleding in plaats van naar de schaduw.
510      ctx.fillStyle = KLEUR.raam;
511      for (var laag = 0; laag < lagen; laag++) {
512        var zb = PEIL_M + laag * verd + bank;
513        if (zb + rH > h - 0.25) break;
514        for (var k = 0; k < perLaag; k++) {
515          var mid = L * (k + 0.5) / perLaag;
516          var a = mid - RAAM_B_M / 2, b = mid + RAAM_B_M / 2;
517          if (a < 0.4 || b > L - 0.4) continue;
518          padWereld([[p0[0] + ux * a, p0[1] + uy * a, zb],
519                     [p0[0] + ux * b, p0[1] + uy * b, zb],
520                     [p0[0] + ux * b, p0[1] + uy * b, zb + rH],
521                     [p0[0] + ux * a, p0[1] + uy * a, zb + rH]]);
522          ctx.fill();
523        }
524      }
525    }
526
527    /**
528     * Het pand zoals de 3DBAG het gereconstrueerd heeft: gevels en dakvlakken
529     * als losse polygonen, van ver naar dichtbij.
530     *
531     * Waarom niet een blok met een dakje erop: een woning heeft zijgevels die
532     * meelopen naar de nok, dakkapellen, aanbouwen met een eigen nok. Die
533     * zitten allemaal in dezelfde schil, dus door hem te tekenen komen ze er
534     * vanzelf bij -- en hoeven we niets te raden over waar de dakrand ligt.
535     */
536    function tekenVorm(vorm, eigen, idx) {
537      var tinten = PANDTINTEN[((idx || 0) * 5 + 3) % PANDTINTEN.length];
538      var sf = Math.sin(kanteling), cf = Math.cos(kanteling);
539      // Volledige zonvector: een dakvlak staat scheef en heeft alle drie de
540      // componenten nodig, een gevel alleen de horizontale twee.
541      var zx = zonX * zonScheer, zy = zonY * zonScheer, zz = zonHoog;
542      // De voet van DIT pand, niet het maaiveld van de scan. Een pand op een
543      // dijk of talud staat hoger dan de referentiegrond, en met een absolute
544      // drempel kreeg zo'n pand helemaal geen ramen.
545      var pandVoet = Infinity;
546      vorm.forEach(function (vlak) {
547        vlak.pts.forEach(function (q) { pandVoet = Math.min(pandVoet, q[2]); });
548      });
549      // Eenheidsvector NAAR de kijker toe, rechtstreeks uit proj(): scherm-
550      // rechts is (ct,-st,0) en scherm-boven (st*sf, ct*sf, cf); hun
551      // kruisproduct is de blikrichting.
552      var ctv = Math.cos(hoek), stv = Math.sin(hoek);
553      var kijk = [-stv * cf, -ctv * cf, sf];
554      var lijst = [];
555      vorm.forEach(function (vlak) {
556        var p = vlak.pts, i, j, nx = 0, ny = 0, nz = 0;
557        // Normaal volgens Newell: een som over ALLE randen, niet een
558        // kruisproduct van drie gekozen punten. Dat laatste leek genoeg en is
559        // het niet: bij een niet-convex vlak -- een gevel met een uitsparing,
560        // een dakvlak met een dakkapel -- kan de gekozen driehoek buiten de
561        // polygoon vallen en wijst de normaal precies de verkeerde kant op.
562        for (i = 0; i < p.length - 1; i++) {
563          j = i + 1;
564          nx += (p[i][1] - p[j][1]) * (p[i][2] + p[j][2]);
565          ny += (p[i][2] - p[j][2]) * (p[i][0] + p[j][0]);
566          nz += (p[i][0] - p[j][0]) * (p[i][1] + p[j][1]);
567        }
568        var L = Math.hypot(nx, ny, nz);
569        if (L < 1e-9) return;                 // ontaard vlak: overslaan
570        var n = [nx / L, ny / L, nz / L];
571        // Achterkanten EERST, daarna de voorkanten. De schil is een gesloten
572        // volume met een winding naar buiten, dus een vlak dat van de kijker
573        // af wijst ligt achter een ander vlak van hetzelfde pand. Zonder dit
574        // werd zo'n achterwand soms ná de voorgevel getekend en leek het pand
575        // doorschijnend; dieptesortering alleen redt dat niet, want bij een
576        // niet-convex pand bestaat er geen volgorde die overal klopt.
577        //
578        // Weglaten zou goedkoper zijn, maar dan hangt het beeld ervan af dat
579        // de winding voor élk vlak klopt. Achteraan zetten kan per definitie
580        // niets kwijtraken.
581        var achter = (n[0] * kijk[0] + n[1] * kijk[1] + n[2] * kijk[2]) <= 0 ? 0 : 1;
582        // Binnen elke groep: verder weg eerst, op het zwaartepunt. Bij een
583        // schuine blik telt de hoogte mee -- een nok staat dichter bij de
584        // camera dan de goot eronder.
585        var mx0 = 0, my0 = 0, mz0 = 0;
586        for (i = 0; i < p.length - 1; i++) { mx0 += p[i][0]; my0 += p[i][1]; mz0 += p[i][2]; }
587        var m = p.length - 1;
588        var d = diepte(mx0 / m - cx, my0 / m - cy) * cf - (mz0 / m) * sf;
589        lijst.push({ achter: achter, d: d, soort: vlak.soort, p: p, n: n });
590      });
591      lijst.sort(function (u, v) {
592        return u.achter !== v.achter ? u.achter - v.achter : v.d - u.d;
593      });
594
595      lijst.forEach(function (it) {
596        var p = it.p, n = it.n, licht, direct;
597        if (it.soort === DAK_V) {
598          direct = zonBoven ? Math.max(0, n[0] * zx + n[1] * zy + n[2] * zz) : 0;
599          licht = AMBIENT + (1 - AMBIENT) * (0.25 + 0.75 * direct);
600          ctx.fillStyle = tint(eigen ? KLEUR.dakEigen : tinten.dak, licht);
601          ctx.strokeStyle = KLEUR.rand; ctx.lineWidth = 1;
602          padWereld(p.map(function (q) { return [q[0] - cx, q[1] - cy, q[2]]; }));
603          ctx.fill(); ctx.stroke();
604          return;
605        }
606        // Gevel: dezelfde half-Lambert als het blokpad, zodat een pand met
607        // vorm en een pand zonder vorm naast elkaar hetzelfde licht vangen.
608        var wikkel = 0.5 + 0.5 * (n[0] * zonX + n[1] * zonY);
609        direct = zonBoven ? Math.pow(wikkel, 1.35) * zonScheer : 0;
610        var mx = 0, my = 0;
611        p.forEach(function (q) { mx += q[0]; my += q[1]; });
612        mx = mx / p.length - cx; my = my / p.length - cy;
613        if (!grondInZon(mx + n[0] * 1.2, my + n[1] * 1.2)) direct *= 0.4;
614        licht = AMBIENT + (1 - AMBIENT) * direct;
615        ctx.fillStyle = tint(eigen ? KLEUR.wandEigen : tinten.wand, licht);
616        padWereld(p.map(function (q) { return [q[0] - cx, q[1] - cy, q[2]]; }));
617        ctx.fill();
618        // Ramen alleen op het rechte stuk: van de voet tot de GOOT, niet tot
619        // de nok. Een puntgevel loopt door tot boven in de kap, en met de
620        // hoogste hoek als maat klommen de ramen die punt in.
621        var voet = Infinity, i2;
622        for (i2 = 0; i2 < p.length - 1; i2++) voet = Math.min(voet, p[i2][2]);
623        if (voet > pandVoet + 0.5) return;
624        var goot = Infinity, onder = [];
625        for (i2 = 0; i2 < p.length - 1; i2++) {
626          if (p[i2][2] > voet + 0.3) goot = Math.min(goot, p[i2][2]);
627          else onder.push(p[i2]);
628        }
629        if (!isFinite(goot) || onder.length < 2) return;
630        tekenRamen([onder[0][0] - cx, onder[0][1] - cy],
631                   [onder[onder.length - 1][0] - cx, onder[onder.length - 1][1] - cy],
632                   goot, licht);
633      });
634    }
635
636    function tekenPand(ring, h, eigen, idx) {
637      var tinten = PANDTINTEN[((idx || 0) * 5 + 3) % PANDTINTEN.length];
638      var pts = ring.map(function (p) { return [p[0] - cx, p[1] - cy]; });
639      // Loopt de ring met de klok mee? Dan wijst (dy, -dx) naar BINNEN en
640      // zou elke gevel de zon precies verkeerd om krijgen.
641      var opp = 0;
642      for (var q = 0; q < pts.length - 1; q++) {
643        opp += pts[q][0] * pts[q + 1][1] - pts[q + 1][0] * pts[q][1];
644      }
645      var draai = opp >= 0 ? 1 : -1;
646
647      // Zijvlakken: per segment een quad, van ver naar dichtbij.
648      var segs = [];
649      for (var i = 0; i < pts.length - 1; i++) {
650        var p0 = pts[i], p1 = pts[i + 1];
651        segs.push({ p0: p0, p1: p1, d: (diepte(p0[0], p0[1]) + diepte(p1[0], p1[1])) / 2 });
652      }
653      segs.sort(function (u, v) { return v.d - u.d; });
654      segs.forEach(function (s) {
655        // Licht op de gevel volgt de ECHTE zon, niet de kijkhoek. Staat de
656        // zon in het westen, dan is de westgevel licht -- ook als je er van
657        // achteren naar kijkt. Dat maakt het beeld consistent met de
658        // schaduw op de grond ernaast.
659        var nx = draai * (s.p1[1] - s.p0[1]), ny = draai * -(s.p1[0] - s.p0[0]);
660        var len = Math.hypot(nx, ny) || 1;
661        nx /= len; ny /= len;
662        var mx = (s.p0[0] + s.p1[0]) / 2, my = (s.p0[1] + s.p1[1]) / 2;
663        // Staat de voet van deze gevel zelf in de schaduw van de buren? Dan
664        // is hij onder ook donker, hoe gunstig hij ook georiënteerd is.
665        var voetInZon = grondInZon(mx + nx * 1.2, my + ny * 1.2);
666        // Half-Lambert in plaats van afkappen op nul: het licht loopt door
667        // tot de achterkant in plaats van op de terminator dicht te slaan.
668        // Met max(0, n·zon) kreeg elke gevel die van de zon af stond exact
669        // dezelfde vlakke tint, en sprong het beeld daar zichtbaar.
670        var wikkel = 0.5 + 0.5 * (nx * zonX + ny * zonY);
671        var direct = zonBoven ? Math.pow(wikkel, 1.35) * zonScheer : 0;
672        // Staat de voet van deze gevel in de schaduw van de buren, dan dempen
673        // we -- niet uitschakelen, want een muur in de schaduw is nog steeds
674        // lichter aan de kant waar de hemel vandaan komt.
675        if (!voetInZon) direct *= 0.4;
676        var licht = AMBIENT + (1 - AMBIENT) * direct;
677        ctx.fillStyle = tint(eigen ? KLEUR.wandEigen : tinten.wand, licht);
678        padWereld([[s.p0[0], s.p0[1], 0], [s.p1[0], s.p1[1], 0],
679                   [s.p1[0], s.p1[1], h], [s.p0[0], s.p0[1], h]]);
680        ctx.fill();
681        tekenRamen(s.p0, s.p1, h, licht);
682      });
683      // Het dak vangt de zon naar zijn hoogte aan de hemel, en wordt niet
684      // gegokt uit het grondmasker: een dak ligt bijna altijd in de zon ook
685      // als de tuin eronder in de schaduw staat.
686      ctx.fillStyle = tint(eigen ? KLEUR.dakEigen : tinten.dak,
687                           AMBIENT + (1 - AMBIENT) * (zonBoven ? 0.35 + 0.65 * zonHoog : 0));
688      ctx.strokeStyle = KLEUR.rand; ctx.lineWidth = 1;
689      padWereld(pts.map(function (p) { return [p[0], p[1], h]; }));
690      ctx.fill(); ctx.stroke();
691    }
692
693    /**
694     * Eén kroon = één vulling, en die kroon gaat mee in de dieptesortering.
695     *
696     * Twee eisen die elkaar bijten. Per bol apart vullen klontert: elke
697     * overlap mengt opnieuw en een heg wordt een donkere muur. Maar álle
698     * bollen in één laag over de rest heen betekent dat een boom achter een
699     * huis er dwars doorheen schijnt -- precies wat er gebeurde.
700     *
701     * De uitweg is groeperen. Het boommasker wordt om de 6 cellen bemonsterd,
702     * dus één boom levert een handvol punten vlak bij elkaar. Die punten
703     * bundelen we per boom (samen-en-zoek met een buur-afstand van 4,5 m),
704     * vullen we als één pad -- nonzero-winding vult de vereniging, dus overal
705     * even dicht -- en die bundel krijgt zijn eigen diepte tussen de panden.
706     * Halfdoorzichtig blijft het, want dat is wat het model zegt: een kroon
707     * houdt licht tegen, hij blokkeert het niet.
708     */
709    function bundelBomen(punten) {
710      var D = 4.5, D2 = D * D;
711      var ouder = punten.map(function (_, i) { return i; });
712      function vind(i) { while (ouder[i] !== i) { ouder[i] = ouder[ouder[i]]; i = ouder[i]; } return i; }
713      function unie(a, b) { a = vind(a); b = vind(b); if (a !== b) ouder[b] = a; }
714      // Rasterhash, anders wordt dit kwadratisch bij een straat vol bomen.
715      var vak = {};
716      punten.forEach(function (p, i) {
717        var kx = Math.floor(p[0] / D), ky = Math.floor(p[1] / D);
718        for (var dx = -1; dx <= 1; dx++) {
719          for (var dy = -1; dy <= 1; dy++) {
720            var lijst = vak[(kx + dx) + ':' + (ky + dy)];
721            if (!lijst) continue;
722            for (var n = 0; n < lijst.length; n++) {
723              var q = punten[lijst[n]];
724              if ((q[0] - p[0]) * (q[0] - p[0]) + (q[1] - p[1]) * (q[1] - p[1]) <= D2) unie(i, lijst[n]);
725            }
726          }
727        }
728        (vak[kx + ':' + ky] = vak[kx + ':' + ky] || []).push(i);
729      });
730      var groepen = {};
731      punten.forEach(function (p, i) {
732        var r = vind(i);
733        (groepen[r] = groepen[r] || []).push(p);
734      });
735      return Object.keys(groepen).map(function (k) { return groepen[k]; });
736    }
737
738    function tekenKroon(groep) {
739      // Stam: zonder is een boom een zwevende bol. Eén stam per boom, op het
740      // zwaartepunt van de bundel, tot waar de kroon begint.
741      var mx = 0, my = 0, mh = 0;
742      groep.forEach(function (b) { mx += b[0]; my += b[1]; mh = Math.max(mh, b[2]); });
743      mx /= groep.length; my /= groep.length;
744      var kroonOnder = Math.max(0.6, mh - Math.max(0.8, Math.min(2.4, mh * 0.30)) * 1.5);
745      if (kroonOnder > 0.8 && mh * schaal * Math.cos(kanteling) > 14) {
746        var v = proj(mx, my, 0), t = proj(mx, my, kroonOnder);
747        ctx.strokeStyle = KLEUR.stam;
748        ctx.lineWidth = Math.max(1.5, Math.min(5, 0.28 * schaal));
749        ctx.beginPath();
750        ctx.moveTo(v[0], v[1]); ctx.lineTo(t[0], t[1]);
751        ctx.stroke();
752      }
753      ctx.beginPath();
754      groep.forEach(function (b) {
755        var straal = Math.max(0.8, Math.min(2.4, b[2] * 0.30));
756        var s = proj(b[0], b[1], Math.max(straal, b[2] - straal * 0.5));
757        var rx = straal * schaal, ry = rx * Math.cos(kanteling) * 1.35;
758        // ctx.ellipse begint GEEN nieuw subpad: zonder deze moveTo trekt de
759        // canvas een rechte lijn van de vorige bol naar deze, en dan vult
760        // nonzero-winding die driehoeken mee.
761        ctx.moveTo(s[0] + rx, s[1]);
762        ctx.ellipse(s[0], s[1], rx, ry, 0, 0, 6.2832);
763      });
764      ctx.fillStyle = KLEUR.kroon;
765      ctx.globalAlpha = 0.9;
766      ctx.fill();
767      ctx.globalAlpha = 1;
768    }
769
770    var bomen = null, kronen = null;
771    function verzamelBomen() {
772      if (bomen || !fH || !fG || !fGeb) return;
773      bomen = [];
774      var stap = 6;
775      for (var r = 0; r < F.ny; r += stap) {
776        for (var c = 0; c < F.nx; c += stap) {
777          var i = r * F.nx + c;
778          if (fGeb[i]) continue;
779          var hm = (fH[i] - fG[i]) / 100;
780          if (hm < 1.5) continue;
781          bomen.push([F.x0 + (c + 0.5) * F.res - cx, F.y1 - (r + 0.5) * F.res - cy, hm]);
782        }
783      }
784    }
785
786    // Zonstand van het getekende moment, voor het licht op de gevels.
787    // zonX/zonY wijzen NAAR de zon in RD-coördinaten (azimut vanaf noord,
788    // met de klok mee), zonHoog is sin(hoogte).
789    var zonX = 0, zonY = 1, zonHoog = 0, zonScheer = 1, zonBoven = false, laatsteMask = null;
790
791    var vorigMask = null, vorigGroen = null, vorigDek = null;
792    function teken(zonMask, groen, zonnestand, dek) {
793      if (!fGeb) return;
794      laatsteMask = zonMask;
795      if (zonnestand) {
796        var el = zonnestand.hoogte * Math.PI / 180, az = zonnestand.azimut * Math.PI / 180;
797        zonBoven = zonnestand.hoogte > 0;
798        zonX = Math.sin(az); zonY = Math.cos(az);
799        zonHoog = Math.max(0, Math.sin(el));
800        // Een lage zon scheert langs de gevels en zet ze vol in het licht;
801        // een hoge zon valt vooral op de daken. Vandaar cos(hoogte) hier en
802        // sin(hoogte) op het dak.
803        zonScheer = Math.max(0, Math.cos(el));
804      }
805      maat();
806      // Het raster hoeft niet opnieuw als alleen de kijkhoek wijzigde. Dat
807      // scheelt een lus over nx*ny per beeld tijdens het slepen en draaien.
808      if (zonMask !== vorigMask || groen !== vorigGroen || dek !== vorigDek) {
809        vulZonraster(zonMask, dek);
810        vorigMask = zonMask; vorigGroen = groen; vorigDek = dek;
811      }
812      verzamelBomen();
813      ctx.clearRect(0, 0, cv.width, cv.height);
814      ctx.fillStyle = KLEUR.maaiveld;
815      ctx.fillRect(0, 0, W, Hp);
816      ctx.save();
817      ctx.translate(midX, midY);
818      tekenTerrein();                     // water, wegen, groen, verharding
819      ctx.restore();
820      tekenGrond();                       // daaroverheen de schaduwwaas
821
822      ctx.save();
823      ctx.translate(midX, midY);
824
825      // De perceelgrens hoort BIJ de grond, dus hij gaat er meteen overheen
826      // en de panden daarna. Eerst tekenden we hem als laatste, over alles
827      // heen, en liep de lijn dwars door de daken van de huizen die ervoor
828      // stonden. Dat er ook een pand áchter de lijn overheen zou kunnen
829      // tekenen kan niet: verder weg is bij een schuine blik hoger op het
830      // scherm, dus weg van een lijn die dichterbij ligt.
831      if (perceelRing.length > 2) {
832        var perceelPad = perceelRing.map(function (p) { return [p[0] - cx, p[1] - cy, 0.05]; });
833        ctx.lineJoin = 'round';
834        ctx.strokeStyle = KLEUR.perceelRand;
835        ctx.lineWidth = 3.8;
836        padWereld(perceelPad);
837        ctx.stroke();
838        ctx.strokeStyle = KLEUR.perceel;
839        ctx.lineWidth = 2.2;
840        padWereld(perceelPad);
841        ctx.stroke();
842      }
843
844      // Alles boven maaiveld in één diepte-volgorde: ver eerst.
845      var items = [];
846      panden.forEach(function (ring, i) {
847        if (!ring || ring.length < 3) return;
848        var h = pandH[i];
849        if (!(h > 0.3)) h = 3.0;
850        var dsom = 0;
851        ring.forEach(function (p) { dsom += diepte(p[0] - cx, p[1] - cy); });
852        var vorm = vormen && vormen[i];
853        items.push({ d: dsom / ring.length,
854                     teken: vorm
855                       ? (function (v, e, ix) {
856                           return function () { tekenVorm(v, e, ix); };
857                         })(vorm, i === eigenIdx, i)
858                       : (function (r, hh, e, ix) {
859                           return function () { tekenPand(r, hh, e, ix); };
860                         })(ring, h, i === eigenIdx, i) });
861      });
862      // Kronen doen mee in dezelfde dieptesortering als de panden, per boom.
863      if (groen && bomen && bomen.length) {
864        if (!kronen) kronen = bundelBomen(bomen);
865        kronen.forEach(function (groep) {
866          var d = 0;
867          groep.forEach(function (b) { d += diepte(b[0], b[1]); });
868          items.push({ d: d / groep.length, teken: function () { tekenKroon(groep); } });
869        });
870      }
871
872      items.sort(function (u, v) { return v.d - u.d; });
873      items.forEach(function (it) { it.teken(); });
874      ctx.restore();
875    }
876
877    // ---- bediening -------------------------------------------------------
878    // Slepen doet wat het in elke 3D-kijker doet: horizontaal draaien om de
879    // staande as, verticaal kantelen. Het punt dat je vastpakt volgt je
880    // vinger. Daarnaast knoppen, want een sleepvlak zonder knoppen vertelt
881    // niemand dát er iets te draaien valt -- en met toetsenbord of
882    // schermlezer kwam je er helemaal niet.
883    var hertekenFn = null, draaiFn = null;
884
885    function meld() {
886      if (hertekenFn) hertekenFn();
887      if (draaiFn) draaiFn(hoek, kanteling);
888    }
889    function klem(k) { return Math.max(KANTEL_MIN, Math.min(KANTEL_MAX, k)); }
890
891    // Doelstand, los van de getoonde stand. Twee keer snel op dezelfde knop
892    // hoort een halve slag te zijn; zou elke klik vanaf de LOPENDE animatie
893    // rekenen, dan slikt de tweede klik de eerste op en kom je 45 graden
894    // tekort. Slepen zet het doel gelijk aan waar je zelf bent geëindigd.
895    var doelHoek = hoek, doelKant = kanteling, animatie = 0;
896
897    /** Vloeiend naar de doelstand. Springen leest als een herladen pagina. */
898    function naar(nieuwHoek, nieuwKant, ms) {
899      doelHoek = nieuwHoek;
900      doelKant = klem(nieuwKant);
901      var h0 = hoek, k0 = kanteling, t0 = 0;
902      ms = ms || 280;
903      cancelAnimationFrame(animatie);
904      if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
905        hoek = doelHoek; kanteling = doelKant; meld(); return;
906      }
907      animatie = requestAnimationFrame(function stap(t) {
908        if (!t0) t0 = t;
909        var u = Math.min(1, (t - t0) / ms);
910        var e = u < 0.5 ? 2 * u * u : 1 - Math.pow(-2 * u + 2, 2) / 2;
911        hoek = h0 + (doelHoek - h0) * e;
912        kanteling = k0 + (doelKant - k0) * e;
913        meld();
914        if (u < 1) animatie = requestAnimationFrame(stap);
915      });
916    }
917
918    var sleept = false, lastX = 0, lastY = 0;
919    cv.style.touchAction = 'pan-y';   // verticaal scrollen blijft van de pagina
920    cv.style.cursor = 'grab';
921    cv.addEventListener('pointerdown', function (e) {
922      cancelAnimationFrame(animatie);
923      doelHoek = hoek; doelKant = kanteling;    // jij neemt het over
924      sleept = true; lastX = e.clientX; lastY = e.clientY;
925      cv.style.cursor = 'grabbing';
926      try { cv.setPointerCapture(e.pointerId); } catch (_) {}
927    });
928    function eindig(e) {
929      if (!sleept) return;
930      sleept = false; cv.style.cursor = 'grab';
931      cv.style.touchAction = 'pan-y';
932      try { cv.releasePointerCapture(e.pointerId); } catch (_) {}
933    }
934    cv.addEventListener('pointerup', eindig);
935    cv.addEventListener('pointercancel', eindig);
936    cv.addEventListener('pointermove', function (e) {
937      if (!sleept) return;
938      var dx = e.clientX - lastX, dy = e.clientY - lastY;
939      lastX = e.clientX; lastY = e.clientY;
940      // Zodra de beweging overwegend horizontaal is, is het een draai en niet
941      // een scrollpoging: dan pas het scrollen van de pagina overnemen.
942      if (Math.abs(dx) > Math.abs(dy)) cv.style.touchAction = 'none';
943      hoek += dx * 0.01;
944      kanteling = klem(kanteling + dy * 0.006);
945      doelHoek = hoek; doelKant = kanteling;
946      meld();
947    });
948
949    return {
950      zetRasters: zetRasters,
951      teken: teken,
952      opHerteken: function (fn) { hertekenFn = fn; },
953      opDraai: function (fn) { draaiFn = fn; fn(hoek, kanteling); },
954      draaiNaar: function (h) { hoek = h; },
955      /** Knoppen: een kwartslag, vloeiend. */
956      draaiMet: function (d) { naar(doelHoek + d, doelKant); },
957      kantelMet: function (d) { naar(doelHoek, doelKant + d); },
958      /** Terug naar noord-boven in de beginkanteling, via de korste weg. */
959      herstel: function () {
960        naar(Math.round(doelHoek / (2 * Math.PI)) * 2 * Math.PI, KANTEL_START);
961      },
962      stand: function () { return { hoek: hoek, kanteling: kanteling }; },
963    };
964  }
965
966  window.ZonDrieD = {
967    /** host = leeg element boven de 2D-kaart. Geeft een object met .teken(). */
968    koppel: function (host, z) {
969      if (!host || !z || !z.raster_fijn) return null;
970      try {
971        return ZonRuimtelijk(host, z);
972      } catch (e) {
973        console.warn('[zon] ruimtelijke weergave faalde:', e);
974        return null;
975      }
976    },
977  };
978})();

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.