| 1 | /**
|
|---|
| 2 | * De avatarhemel: het model en de tekenlaag, met een naad ertussen.
|
|---|
| 3 | *
|
|---|
| 4 | * DE NAAD IS HET PUNT. `orbitPlacement` en `nearestFace` zijn pure wiskunde en
|
|---|
| 5 | * bestaan al in Swift en Kotlin; dit is de derde schrijving en dus de derde
|
|---|
| 6 | * kans op drift. Daarom staan ze hier apart, met dezelfde tests als daar, en
|
|---|
| 7 | * raakt de renderer ze niet aan. Blijkt Canvas 2D ooit te krap bij duizenden
|
|---|
| 8 | * gezichten, dan vervangt een WebGL-variant alleen `SkyRenderer` en merkt de
|
|---|
| 9 | * rest er niets van.
|
|---|
| 10 | *
|
|---|
| 11 | * Waarom Canvas en (nog) geen engine: de rem is niet het aantal sprites maar
|
|---|
| 12 | * het aantal texturen. Alle avatars gaan één keer in een atlas (één offscreen
|
|---|
| 13 | * canvas), en daarna is elk gezicht een `drawImage` uit datzelfde plaatje.
|
|---|
| 14 | */
|
|---|
| 15 | 'use strict';
|
|---|
| 16 |
|
|---|
| 17 | const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
|---|
| 18 |
|
|---|
| 19 | /**
|
|---|
| 20 | * Waar elk gezicht hangt, in eenheden van één avatarcel, met de oorsprong in
|
|---|
| 21 | * het midden. Hoe verder van het midden, hoe langer geleden je iets van
|
|---|
| 22 | * diegene hoorde: log-geschaald, want het verschil tussen een uur en een dag
|
|---|
| 23 | * telt zwaarder dan tussen een jaar en een jaar-en-een-dag.
|
|---|
| 24 | *
|
|---|
| 25 | * Letterlijke port van de Kotlin- en Swift-versie, inclusief de push-apart.
|
|---|
| 26 | */
|
|---|
| 27 | export function orbitPlacement(agesMs) {
|
|---|
| 28 | const n = agesMs.length;
|
|---|
| 29 | if (!n) return [];
|
|---|
| 30 | const logs = agesMs.map((a) => Math.log(1 + Math.max(0, a) / 3600000));
|
|---|
| 31 | const lo = Math.min(...logs);
|
|---|
| 32 | const hi = Math.max(...logs);
|
|---|
| 33 | const norm = logs.map((l) => (hi > lo ? (l - lo) / (hi - lo) : 0.5));
|
|---|
| 34 | // De ruimte groeit met de drukte, zodat de push-apart convergeert in plaats
|
|---|
| 35 | // van tegen zichzelf te duwen.
|
|---|
| 36 | const spread = Math.max(3, Math.sqrt(n) * 1.1);
|
|---|
| 37 | // De hoek volgt de RANG in tijd, zodat mensen die kort na elkaar schreven
|
|---|
| 38 | // niet op een hoop komen.
|
|---|
| 39 | const rank = new Map(
|
|---|
| 40 | agesMs.map((a, i) => [i, a]).sort((p, q) => p[1] - q[1]).map(([i], r) => [i, r]),
|
|---|
| 41 | );
|
|---|
| 42 | const xs = new Float64Array(n);
|
|---|
| 43 | const ys = new Float64Array(n);
|
|---|
| 44 | for (let i = 0; i < n; i++) {
|
|---|
| 45 | const r = 1.4 + norm[i] * spread;
|
|---|
| 46 | const a = (rank.get(i) ?? i) * GOLDEN;
|
|---|
| 47 | xs[i] = r * Math.cos(a);
|
|---|
| 48 | ys[i] = r * Math.sin(a);
|
|---|
| 49 | }
|
|---|
| 50 | const minD = 1.15;
|
|---|
| 51 | for (let pass = 0; pass < 120; pass++) {
|
|---|
| 52 | let moved = false;
|
|---|
| 53 | for (let i = 0; i < n; i++) {
|
|---|
| 54 | for (let j = i + 1; j < n; j++) {
|
|---|
| 55 | const dx = xs[j] - xs[i];
|
|---|
| 56 | const dy = ys[j] - ys[i];
|
|---|
| 57 | const d = Math.sqrt(dx * dx + dy * dy);
|
|---|
| 58 | if (d < minD) {
|
|---|
| 59 | moved = true;
|
|---|
| 60 | const push = (minD - d) / 2 + 1e-3;
|
|---|
| 61 | // Precies samenvallende plekken splitsen langs hun eigen indexhoek.
|
|---|
| 62 | const ux = d > 1e-6 ? dx / d : Math.cos(i * GOLDEN);
|
|---|
| 63 | const uy = d > 1e-6 ? dy / d : Math.sin(i * GOLDEN);
|
|---|
| 64 | xs[i] -= ux * push; ys[i] -= uy * push;
|
|---|
| 65 | xs[j] += ux * push; ys[j] += uy * push;
|
|---|
| 66 | }
|
|---|
| 67 | }
|
|---|
| 68 | }
|
|---|
| 69 | if (!moved) break;
|
|---|
| 70 | }
|
|---|
| 71 | return Array.from({ length: n }, (_, i) => ({ x: xs[i], y: ys[i] }));
|
|---|
| 72 | }
|
|---|
| 73 |
|
|---|
| 74 | /**
|
|---|
| 75 | * Het gezicht dat het dichtst bij een punt ligt, of null als je ernaast mikte.
|
|---|
| 76 | *
|
|---|
| 77 | * Dezelfde rekensom als de plaatsing, ZONDER de ademhaling: die beweegt hooguit
|
|---|
| 78 | * een paar pixels en zou de treffer alleen maar grillig maken.
|
|---|
| 79 | */
|
|---|
| 80 | export function nearestFace(px, py, spots, { cx, cy, cell, scale, panX, panY }) {
|
|---|
| 81 | const reach = (cell / 2) * Math.min(Math.max(scale, 0.5), 1.6);
|
|---|
| 82 | let best = -1;
|
|---|
| 83 | let bestD = Infinity;
|
|---|
| 84 | for (let i = 0; i < spots.length; i++) {
|
|---|
| 85 | const x = cx + panX + spots[i].x * cell * scale;
|
|---|
| 86 | const y = cy + panY + spots[i].y * cell * scale;
|
|---|
| 87 | const d = Math.hypot(px - x, py - y);
|
|---|
| 88 | if (d <= reach && d < bestD) { best = i; bestD = d; }
|
|---|
| 89 | }
|
|---|
| 90 | return best < 0 ? null : best;
|
|---|
| 91 | }
|
|---|
| 92 |
|
|---|
| 93 | /* ── De tekenlaag ─────────────────────────────────────────────────────────
|
|---|
| 94 | Alles hieronder raakt de DOM en is dus vervangbaar; alles hierboven niet. */
|
|---|
| 95 |
|
|---|
| 96 | const CELL = 92; // één avatarcel, zelfde maat als in de apps
|
|---|
| 97 | const FACE = 44; // de tekening zelf
|
|---|
| 98 | const ATLAS_PAD = 4;
|
|---|
| 99 |
|
|---|
| 100 | /** Eén canvas met alle avatars naast elkaar, zodat tekenen één drawImage is. */
|
|---|
| 101 | async function buildAtlas(faces, accent) {
|
|---|
| 102 | const size = FACE * 2; // retina-scherp
|
|---|
| 103 | const cols = Math.max(1, Math.ceil(Math.sqrt(faces.length || 1)));
|
|---|
| 104 | const rows = Math.max(1, Math.ceil((faces.length || 1) / cols));
|
|---|
| 105 | const atlas = document.createElement('canvas');
|
|---|
| 106 | atlas.width = cols * (size + ATLAS_PAD);
|
|---|
| 107 | atlas.height = rows * (size + ATLAS_PAD);
|
|---|
| 108 | const g = atlas.getContext('2d');
|
|---|
| 109 |
|
|---|
| 110 | await Promise.all(faces.map((f, i) => new Promise((done) => {
|
|---|
| 111 | const col = i % cols;
|
|---|
| 112 | const row = Math.floor(i / cols);
|
|---|
| 113 | const x = col * (size + ATLAS_PAD);
|
|---|
| 114 | const y = row * (size + ATLAS_PAD);
|
|---|
| 115 | f.atlas = { x, y, size };
|
|---|
| 116 |
|
|---|
| 117 | const drawInitial = () => {
|
|---|
| 118 | // Geen plaatje? Dan de eerste letter op een gekleurde schijf, zoals de
|
|---|
| 119 | // apps ook doen: een gezicht zonder foto is nog steeds een persoon.
|
|---|
| 120 | g.save();
|
|---|
| 121 | g.beginPath(); g.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); g.clip();
|
|---|
| 122 | g.fillStyle = accent || '#5a32e6'; g.fillRect(x, y, size, size);
|
|---|
| 123 | g.fillStyle = '#fff';
|
|---|
| 124 | g.font = `600 ${size * 0.45}px system-ui, sans-serif`;
|
|---|
| 125 | g.textAlign = 'center'; g.textBaseline = 'middle';
|
|---|
| 126 | g.fillText((f.name || '?').trim().charAt(0).toUpperCase(), x + size / 2, y + size / 2);
|
|---|
| 127 | g.restore();
|
|---|
| 128 | done();
|
|---|
| 129 | };
|
|---|
| 130 |
|
|---|
| 131 | if (!f.icon) return drawInitial();
|
|---|
| 132 | const img = new Image();
|
|---|
| 133 | img.crossOrigin = 'anonymous';
|
|---|
| 134 | img.onload = () => {
|
|---|
| 135 | g.save();
|
|---|
| 136 | g.beginPath(); g.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); g.clip();
|
|---|
| 137 | g.drawImage(img, x, y, size, size);
|
|---|
| 138 | g.restore();
|
|---|
| 139 | done();
|
|---|
| 140 | };
|
|---|
| 141 | // Een avatar van een andere server kan weigeren; dan de letter, niet niets.
|
|---|
| 142 | img.onerror = drawInitial;
|
|---|
| 143 | img.src = f.icon;
|
|---|
| 144 | })));
|
|---|
| 145 |
|
|---|
| 146 | return atlas;
|
|---|
| 147 | }
|
|---|
| 148 |
|
|---|
| 149 | export class SkyRenderer {
|
|---|
| 150 | /**
|
|---|
| 151 | * @param {HTMLCanvasElement} canvas
|
|---|
| 152 | * @param {(index:number)=>void} onPick een gezicht aangetikt
|
|---|
| 153 | */
|
|---|
| 154 | constructor(canvas, onPick, onNew) {
|
|---|
| 155 | this.canvas = canvas;
|
|---|
| 156 | this.g = canvas.getContext('2d');
|
|---|
| 157 | this.onPick = onPick;
|
|---|
| 158 | // De (+) in het midden: de deur naar iemand NIEUW. Hij zit op de
|
|---|
| 159 | // OORSPRONG van het veld en pant en zoomt met de gezichten mee, zodat hij
|
|---|
| 160 | // altijd het midden van de vliegende avatars is, waar de hemel ook heen
|
|---|
| 161 | // drijft. De plaatsing houdt daar bewust ruimte vrij (elk gezicht hangt
|
|---|
| 162 | // minstens 1.4 cel naar buiten).
|
|---|
| 163 | this.onNew = onNew;
|
|---|
| 164 | this.faces = [];
|
|---|
| 165 | this.spots = [];
|
|---|
| 166 | this.scale = 1;
|
|---|
| 167 | this.panX = 0;
|
|---|
| 168 | this.panY = 0;
|
|---|
| 169 | this.running = false;
|
|---|
| 170 | this.interactive = false;
|
|---|
| 171 | this._wire();
|
|---|
| 172 | }
|
|---|
| 173 |
|
|---|
| 174 | /** @param {{name:string, icon:string|null, ageMs:number}[]} faces */
|
|---|
| 175 | async setFaces(faces) {
|
|---|
| 176 | this.faces = faces;
|
|---|
| 177 | this.spots = orbitPlacement(faces.map((f) => f.ageMs));
|
|---|
| 178 | this.atlas = await buildAtlas(faces, this._accent());
|
|---|
| 179 | this._resize();
|
|---|
| 180 | }
|
|---|
| 181 |
|
|---|
| 182 | /** De themakleur uit de CSS-variabele, voor de placeholder-schijfjes. */
|
|---|
| 183 | _accent() {
|
|---|
| 184 | return getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() || '#5a32e6';
|
|---|
| 185 | }
|
|---|
| 186 |
|
|---|
| 187 | /** Het thema veranderde: de letters-op-schijf opnieuw in het nieuwe accent. */
|
|---|
| 188 | async reskin() {
|
|---|
| 189 | if (this.faces.length) this.atlas = await buildAtlas(this.faces, this._accent());
|
|---|
| 190 | }
|
|---|
| 191 |
|
|---|
| 192 | /** Publiek mikken, voor de zwaai-hand: zelfde rekensom als een tik. */
|
|---|
| 193 | pick(px, py) {
|
|---|
| 194 | return nearestFace(px, py, this.spots, {
|
|---|
| 195 | cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
|
|---|
| 196 | panX: this.panX, panY: this.panY,
|
|---|
| 197 | });
|
|---|
| 198 | }
|
|---|
| 199 |
|
|---|
| 200 | start() { if (!this.running) { this.running = true; this._loop(); } }
|
|---|
| 201 | stop() { this.running = false; }
|
|---|
| 202 |
|
|---|
| 203 | _resize() {
|
|---|
| 204 | const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|---|
| 205 | const r = this.canvas.getBoundingClientRect();
|
|---|
| 206 | this.canvas.width = Math.max(1, r.width * dpr);
|
|---|
| 207 | this.canvas.height = Math.max(1, r.height * dpr);
|
|---|
| 208 | this.g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|---|
| 209 | this.w = r.width; this.h = r.height;
|
|---|
| 210 | }
|
|---|
| 211 |
|
|---|
| 212 | _loop() {
|
|---|
| 213 | if (!this.running) return;
|
|---|
| 214 | this._draw(performance.now());
|
|---|
| 215 | requestAnimationFrame(() => this._loop());
|
|---|
| 216 | }
|
|---|
| 217 |
|
|---|
| 218 | _draw(t) {
|
|---|
| 219 | const { g } = this;
|
|---|
| 220 | g.clearRect(0, 0, this.w, this.h);
|
|---|
| 221 | if (!this.atlas) return;
|
|---|
| 222 | const cx = this.w / 2;
|
|---|
| 223 | const cy = this.h / 2;
|
|---|
| 224 | // Eén gedeelde klok, elk gezicht zijn eigen fase: de hemel ademt zonder in
|
|---|
| 225 | // de pas te lopen. Zelfde beweging als in de apps (zeven punten, negen sec).
|
|---|
| 226 | const phase = ((t / 9000) % 1) * Math.PI * 2;
|
|---|
| 227 | const s = Math.min(Math.max(this.scale, 0.5), 1.6);
|
|---|
| 228 | const draw = FACE * s;
|
|---|
| 229 |
|
|---|
| 230 | // De (+) eerst, dan de gezichten eroverheen als het krap wordt.
|
|---|
| 231 | if (this.interactive && this.onNew) {
|
|---|
| 232 | const px = cx + this.panX;
|
|---|
| 233 | const py = cy + this.panY;
|
|---|
| 234 | const pr = 22 * s;
|
|---|
| 235 | g.beginPath(); g.arc(px, py, pr, 0, Math.PI * 2);
|
|---|
| 236 | g.fillStyle = 'rgba(255,255,255,0.92)'; g.fill();
|
|---|
| 237 | g.strokeStyle = '#11131a'; g.lineWidth = Math.max(2, 3 * s); g.lineCap = 'round';
|
|---|
| 238 | g.beginPath();
|
|---|
| 239 | g.moveTo(px - pr * 0.45, py); g.lineTo(px + pr * 0.45, py);
|
|---|
| 240 | g.moveTo(px, py - pr * 0.45); g.lineTo(px, py + pr * 0.45);
|
|---|
| 241 | g.stroke();
|
|---|
| 242 | }
|
|---|
| 243 |
|
|---|
| 244 | for (let i = 0; i < this.spots.length; i++) {
|
|---|
| 245 | const wob = { x: Math.sin(phase + i * 0.9) * 7, y: Math.cos(phase + i * 1.3) * 7 };
|
|---|
| 246 | const x = cx + this.panX + (this.spots[i].x * CELL + wob.x) * this.scale;
|
|---|
| 247 | const y = cy + this.panY + (this.spots[i].y * CELL + wob.y) * this.scale;
|
|---|
| 248 | // Buiten beeld niet tekenen: dat is de goedkoopste optimalisatie en de
|
|---|
| 249 | // enige die bij duizenden gezichten echt telt.
|
|---|
| 250 | if (x < -draw || y < -draw || x > this.w + draw || y > this.h + draw) continue;
|
|---|
| 251 | const a = this.faces[i].atlas;
|
|---|
| 252 | g.drawImage(this.atlas, a.x, a.y, a.size, a.size, x - draw / 2, y - draw / 2, draw, draw);
|
|---|
| 253 | }
|
|---|
| 254 | }
|
|---|
| 255 |
|
|---|
| 256 | /** De schaal binnen zijn grenzen. Eén plek, zodat wiel en pinch gelijk zijn. */
|
|---|
| 257 | _clampScale(s) { return Math.min(Math.max(s, 0.2), 2.5); }
|
|---|
| 258 |
|
|---|
| 259 | /**
|
|---|
| 260 | * Zoomen ROND een schermpunt, niet rond het canvas-midden: de wereld onder
|
|---|
| 261 | * je vingers (of je muis) blijft staan, zodat de hemel zich als een kaart
|
|---|
| 262 | * gedraagt in plaats van onder je weg te schuiven.
|
|---|
| 263 | */
|
|---|
| 264 | _zoomAround(px, py, factor) {
|
|---|
| 265 | const next = this._clampScale(this.scale * factor);
|
|---|
| 266 | const cx = this.w / 2, cy = this.h / 2;
|
|---|
| 267 | // Het wereldpunt onder (px,py) mag na de zoom op dezelfde plek blijven.
|
|---|
| 268 | const worldX = (px - cx - this.panX) / this.scale;
|
|---|
| 269 | const worldY = (py - cy - this.panY) / this.scale;
|
|---|
| 270 | this.panX = px - cx - worldX * next;
|
|---|
| 271 | this.panY = py - cy - worldY * next;
|
|---|
| 272 | this.scale = next;
|
|---|
| 273 | }
|
|---|
| 274 |
|
|---|
| 275 | _wire() {
|
|---|
| 276 | const c = this.canvas;
|
|---|
| 277 | // Alle vingers die nu op de hemel liggen. Eén = pannen of tikken, twee =
|
|---|
| 278 | // knijpen. Een Map want pointers komen en gaan in willekeurige volgorde.
|
|---|
| 279 | const pts = new Map();
|
|---|
| 280 | let moved = 0; // hoeveel de ENE vinger bewoog (tik vs sleep)
|
|---|
| 281 | let pinchDist = 0; // vingerafstand bij het begin van een knijp
|
|---|
| 282 | let becamePinch = false; // ooit twee vingers? dan geen tik aan het eind
|
|---|
| 283 |
|
|---|
| 284 | const local = (e) => {
|
|---|
| 285 | const r = c.getBoundingClientRect();
|
|---|
| 286 | return { x: e.clientX - r.left, y: e.clientY - r.top };
|
|---|
| 287 | };
|
|---|
| 288 | const twoPointerState = () => {
|
|---|
| 289 | const [a, b] = [...pts.values()];
|
|---|
| 290 | return {
|
|---|
| 291 | dist: Math.hypot(a.x - b.x, a.y - b.y),
|
|---|
| 292 | mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2,
|
|---|
| 293 | };
|
|---|
| 294 | };
|
|---|
| 295 |
|
|---|
| 296 | c.addEventListener('pointerdown', (e) => {
|
|---|
| 297 | if (!this.interactive) return;
|
|---|
| 298 | c.setPointerCapture(e.pointerId);
|
|---|
| 299 | pts.set(e.pointerId, local(e));
|
|---|
| 300 | moved = 0;
|
|---|
| 301 | if (pts.size === 2) { becamePinch = true; pinchDist = twoPointerState().dist; }
|
|---|
| 302 | });
|
|---|
| 303 |
|
|---|
| 304 | c.addEventListener('pointermove', (e) => {
|
|---|
| 305 | if (!this.interactive || !pts.has(e.pointerId)) return;
|
|---|
| 306 | const prev = pts.get(e.pointerId);
|
|---|
| 307 | const cur = local(e);
|
|---|
| 308 | pts.set(e.pointerId, cur);
|
|---|
| 309 |
|
|---|
| 310 | if (pts.size >= 2) {
|
|---|
| 311 | // Knijpen: schaal volgt de verhouding van de vingerafstand, en we
|
|---|
| 312 | // zoomen om het midden tussen de vingers.
|
|---|
| 313 | const { dist, mx, my } = twoPointerState();
|
|---|
| 314 | if (pinchDist > 0 && dist > 0) {
|
|---|
| 315 | this._zoomAround(mx, my, dist / pinchDist);
|
|---|
| 316 | pinchDist = dist;
|
|---|
| 317 | }
|
|---|
| 318 | } else {
|
|---|
| 319 | // Eén vinger: pannen.
|
|---|
| 320 | const dx = cur.x - prev.x, dy = cur.y - prev.y;
|
|---|
| 321 | moved += Math.abs(dx) + Math.abs(dy);
|
|---|
| 322 | this.panX += dx; this.panY += dy;
|
|---|
| 323 | }
|
|---|
| 324 | });
|
|---|
| 325 |
|
|---|
| 326 | const lift = (e) => {
|
|---|
| 327 | if (!pts.has(e.pointerId)) return;
|
|---|
| 328 | const cur = pts.get(e.pointerId);
|
|---|
| 329 | pts.delete(e.pointerId);
|
|---|
| 330 | // Nog één vinger over na een knijp: zijn afstand als nieuw ijkpunt, en
|
|---|
| 331 | // niet meteen als tik tellen.
|
|---|
| 332 | if (pts.size === 1) { pinchDist = 0; return; }
|
|---|
| 333 | if (pts.size > 1) return;
|
|---|
| 334 |
|
|---|
| 335 | // Laatste vinger los. Een tik alleen als er nooit geknepen is en de
|
|---|
| 336 | // vinger nauwelijks bewoog.
|
|---|
| 337 | if (!becamePinch && moved < 6) {
|
|---|
| 338 | const { x, y } = cur;
|
|---|
| 339 | const hit = nearestFace(x, y, this.spots, {
|
|---|
| 340 | cx: this.w / 2, cy: this.h / 2, cell: CELL, scale: this.scale,
|
|---|
| 341 | panX: this.panX, panY: this.panY,
|
|---|
| 342 | });
|
|---|
| 343 | if (hit != null) { this.onPick?.(hit); }
|
|---|
| 344 | else {
|
|---|
| 345 | // Geen gezicht? Dan misschien de (+): zelfde middelpunt als getekend,
|
|---|
| 346 | // met een raakzone die meeschaalt zodat mikken bij elke zoom klopt.
|
|---|
| 347 | const s2 = Math.min(Math.max(this.scale, 0.5), 1.6);
|
|---|
| 348 | const d = Math.hypot(x - (this.w / 2 + this.panX), y - (this.h / 2 + this.panY));
|
|---|
| 349 | if (d <= 26 * s2) this.onNew?.();
|
|---|
| 350 | }
|
|---|
| 351 | }
|
|---|
| 352 | becamePinch = false;
|
|---|
| 353 | };
|
|---|
| 354 | c.addEventListener('pointerup', lift);
|
|---|
| 355 | c.addEventListener('pointercancel', (e) => { pts.delete(e.pointerId); if (pts.size < 2) pinchDist = 0; });
|
|---|
| 356 |
|
|---|
| 357 | // Het wiel op een desktop: zoomen om de muis, dezelfde weg als de pinch.
|
|---|
| 358 | c.addEventListener('wheel', (e) => {
|
|---|
| 359 | if (!this.interactive) return;
|
|---|
| 360 | e.preventDefault();
|
|---|
| 361 | const { x, y } = local(e);
|
|---|
| 362 | this._zoomAround(x, y, e.deltaY < 0 ? 1.1 : 0.9);
|
|---|
| 363 | }, { passive: false });
|
|---|
| 364 |
|
|---|
| 365 | window.addEventListener('resize', () => this._resize());
|
|---|
| 366 | }
|
|---|
| 367 | }
|
|---|