// Rendert eine Szene und liefert das "Google-Maps-Gefuehl" via panzoom. // // WICHTIG (Performance): Alle Items werden EINMAL in ein einzelnes Bild (Canvas) // gezeichnet. panzoom bewegt danach nur dieses eine -> butterweich, egal // wie viele tausend Items die Szene hat. (Vorher war jedes Emoji ein eigenes // -Element, das beim Zoomen pro Frame neu gerastert wurde = extremes Lag.) // // Klicks werden transform-sicher (getScreenCTM) in Szenen-Koordinaten umgerechnet. const SVG_NS = 'http://www.w3.org/2000/svg'; const EMOJI_BASE = '/emoji/'; const TAP_THRESHOLD_PX = 8; // Bewegung darueber = Ziehen, kein Tippen const RENDER_SCALE = 2; // Aufloesung des Szenen-Bitmaps (fuer Schaerfe beim Zoomen) const EMOJI_FONT = '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif'; // Merkt sich pro Session, ob ein Emoji-Bildset vorliegt (sonst native Emojis). let emojiAssetsAvailable = null; function loadImage(url) { return new Promise((resolve) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve({ url, img }); img.onerror = () => resolve({ url, img: null }); img.src = url; }); } function el(name, attrs = {}) { const node = document.createElementNS(SVG_NS, name); for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v); return node; } export class Scene { constructor(svg, layer, stage) { this.svg = svg; this.layer = layer; // , das panzoom transformiert this.stage = stage; this.pz = null; this.tapHandler = null; this._down = null; this._objectUrl = null; this._imgCache = new Map(); // url -> HTMLImageElement this.svg.addEventListener('pointerdown', (e) => { this._down = { x: e.clientX, y: e.clientY }; }); this.svg.addEventListener('click', (e) => { if (!this._down) return; const moved = Math.hypot(e.clientX - this._down.x, e.clientY - this._down.y); this._down = null; if (moved > TAP_THRESHOLD_PX) return; // war ein Ziehen this._emitTap(e); }); } onTap(handler) { this.tapHandler = handler; } _emitTap(e) { if (!this.tapHandler) return; const ctm = this.layer.getScreenCTM(); if (!ctm) return; const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse()); this.tapHandler({ x: pt.x, y: pt.y }); } async render(payload) { if (this.pz) { this.pz.dispose(); this.pz = null; } this.layer.innerHTML = ''; // Emoji-Set einmal pruefen if (payload.type === 'emoji' && emojiAssetsAvailable === null) { const sample = payload.items.find((i) => i.kind === 'emoji'); emojiAssetsAvailable = sample ? (await loadImage(EMOJI_BASE + sample.codepoint + '.png')).img !== null : false; } // benoetigte Bilder vorladen (Clipart/Foto + ggf. Emoji-PNGs) await this._preloadAssets(payload); // Szene in ein Bitmap rendern const dataUrl = await this._rasterize(payload); // altes Object-URL freigeben if (this._objectUrl) URL.revokeObjectURL(this._objectUrl); this._objectUrl = dataUrl; const image = el('image', { x: 0, y: 0, width: payload.width, height: payload.height, }); image.setAttribute('href', dataUrl); this.layer.appendChild(image); this._initPanzoom(payload); } async _preloadAssets(payload) { const urls = new Set(); for (const it of payload.items) { if (it.kind === 'image') urls.add(it.assetUrl); else if (it.kind === 'emoji' && emojiAssetsAvailable) urls.add(EMOJI_BASE + it.codepoint + '.png'); } const missing = [...urls].filter((u) => !this._imgCache.has(u)); const loaded = await Promise.all(missing.map(loadImage)); for (const { url, img } of loaded) this._imgCache.set(url, img); } async _rasterize(payload) { const canvas = document.createElement('canvas'); canvas.width = payload.width * RENDER_SCALE; canvas.height = payload.height * RENDER_SCALE; const ctx = canvas.getContext('2d'); ctx.scale(RENDER_SCALE, RENDER_SCALE); ctx.fillStyle = payload.background || '#ffffff'; ctx.fillRect(0, 0, payload.width, payload.height); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (const item of payload.items) { ctx.save(); ctx.translate(item.cx, item.cy); ctx.rotate((item.rot * Math.PI) / 180); this._drawItem(ctx, item); ctx.restore(); } // als Blob-URL (effizienter als riesiger dataURL-String) return await new Promise((resolve) => { canvas.toBlob((blob) => resolve(URL.createObjectURL(blob)), 'image/png'); }); } _drawItem(ctx, item) { const size = item.size; if (item.kind === 'emoji' && !emojiAssetsAvailable) { ctx.font = `${size}px ${EMOJI_FONT}`; ctx.fillText(item.glyph, 0, 0); return; } const url = item.kind === 'image' ? item.assetUrl : EMOJI_BASE + item.codepoint + '.png'; const img = this._imgCache.get(url); if (!img) { // Emoji-Fallback, falls PNG fehlt if (item.kind === 'emoji') { ctx.font = `${size}px ${EMOJI_FONT}`; ctx.fillText(item.glyph, 0, 0); } return; } // Seitenverhaeltnis: SVGs sind quadratisch; Raster nach natuerlicher Groesse einpassen const isSvg = url.toLowerCase().endsWith('.svg'); let dw = size; let dh = size; if (!isSvg && img.naturalWidth && img.naturalHeight) { const s = Math.min(size / img.naturalWidth, size / img.naturalHeight); dw = img.naturalWidth * s; dh = img.naturalHeight * s; } ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh); } _initPanzoom(payload) { if (typeof panzoom === 'function') { this.pz = panzoom(this.layer, { maxZoom: 10, minZoom: 0.9, bounds: true, boundsPadding: 0.15, smoothScroll: true, zoomDoubleClickSpeed: 1, }); } // viewBox NACH panzoom setzen (panzoom entfernt es beim Init). this.svg.setAttribute('viewBox', `0 0 ${payload.width} ${payload.height}`); } _stageCenterClient() { const r = this.stage.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; } zoomIn() { if (!this.pz) return; const c = this._stageCenterClient(); this.pz.smoothZoom(c.x, c.y, 1.6); } zoomOut() { if (!this.pz) return; const c = this._stageCenterClient(); this.pz.smoothZoom(c.x, c.y, 0.625); } resetView() { if (!this.pz) return; this.pz.moveTo(0, 0); this.pz.zoomAbs(0, 0, 1); } // kurze Treffer-/Daneben-Animation an einer Szenen-Position pingAt(x, y, ok) { const ring = el('circle', { cx: x, cy: y, r: 10, fill: 'none', 'stroke-width': 6, class: ok ? 'ping ping-ok' : 'ping ping-miss', }); this.layer.appendChild(ring); setTimeout(() => ring.remove(), 650); } }