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.