From b95464670a54a4602f15e93fd4e9fa18b63deeb7 Mon Sep 17 00:00:00 2001 From: Guido Date: Tue, 4 Aug 2026 18:50:44 +0200 Subject: [PATCH] Mobile-Fixes, Cover-Rendering, aufrechte Objekte, Fuss-Favicon - Rendering von SVG-viewBox auf sauberes DOM-Modell (div#world > img) umgestellt: behebt den Pinch-Fokus-Bug ("zoomt in die Ecke") auf Touch - Szene fuellt jetzt bildschirmfuellend (Cover) mit eigener Cover-Klemmung statt Letterbox-Balken (v.a. mobil im Hochformat) - Tippen ueber Pointer-Events statt click (funktioniert auf Touch, wo panzoom das synthetische click unterdrueckt); Mehrfinger/Pinch zaehlt nie als Tipp - touch-action:none aufs Bild -> fluessiges Pinch-Zoom - Objekte aufrecht (leichte Neigung) statt zufaellig gekippt + dezenter Tiefen-Verlauf -> wirkt wie eine Szene statt "Haufen" - Favicon: Fuss-Emoji Co-Authored-By: Claude Opus 4.8 --- public/css/style.css | 46 ++++++--- public/index.html | 7 +- public/js/app.js | 6 +- public/js/scene.js | 201 ++++++++++++++++++++++++--------------- server/sceneGenerator.js | 6 +- 5 files changed, 167 insertions(+), 99 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 5af628f..c9aadaa 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -118,15 +118,27 @@ html, body { touch-action: none; /* eigene Pinch/Pan-Gesten */ } -#scene-svg { +#stage { + cursor: grab; + -webkit-touch-callout: none; /* kein Langdruck-Menü auf iOS */ +} +#stage:active { cursor: grabbing; } + +#world { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + will-change: transform; +} +#scene-img { + display: block; width: 100%; height: 100%; - display: block; - cursor: grab; + pointer-events: none; + -webkit-user-drag: none; + user-select: none; } -#scene-svg:active { cursor: grabbing; } - -.item { pointer-events: auto; } .category-name { position: absolute; @@ -168,14 +180,22 @@ html, body { #controls button:hover { color: var(--pink-soft); border-color: var(--pink); } #controls button:active { transform: translateY(1px); } -/* ---- Ping-Feedback (im SVG) ---- */ -.ping { pointer-events: none; } -.ping-ok { stroke: var(--ok); } -.ping-miss { stroke: var(--miss); } -.ping { animation: ping 0.6s ease-out forwards; } +/* ---- Ping-Feedback (Treffer/Daneben) ---- */ +.ping { + position: absolute; + width: 46px; + height: 46px; + margin: -23px 0 0 -23px; /* auf (left/top) zentrieren */ + border-radius: 50%; + border: 6px solid var(--ok); + pointer-events: none; + animation: ping 0.6s ease-out forwards; +} +.ping-ok { border-color: var(--ok); } +.ping-miss { border-color: var(--miss); } @keyframes ping { - 0% { r: 8; opacity: 0.95; stroke-width: 8; } - 100% { r: 60; opacity: 0; stroke-width: 1; } + 0% { transform: scale(0.3); opacity: 0.95; } + 100% { transform: scale(2.3); opacity: 0; } } /* ---- Toast ---- */ diff --git a/public/index.html b/public/index.html index 296f330..8e38e31 100644 --- a/public/index.html +++ b/public/index.html @@ -1,6 +1,7 @@ fuesse.sexy — finde die Füße +
@@ -16,9 +17,9 @@
- - - +
+ Wimmelbild +
diff --git a/public/js/app.js b/public/js/app.js index b9dfdcd..3456263 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -4,14 +4,14 @@ import { Scene } from './scene.js'; import { Legend } from './legend.js'; -const svg = document.getElementById('scene-svg'); -const layer = document.getElementById('scene-layer'); const stage = document.getElementById('stage'); +const world = document.getElementById('world'); +const sceneImg = document.getElementById('scene-img'); const loading = document.getElementById('loading'); const winOverlay = document.getElementById('win-overlay'); const toast = document.getElementById('toast'); -const scene = new Scene(svg, layer, stage); +const scene = new Scene(stage, world, sceneImg); const legend = new Legend( document.getElementById('legend-items'), document.getElementById('legend-counter') diff --git a/public/js/scene.js b/public/js/scene.js index e22bdb6..7da12fd 100644 --- a/public/js/scene.js +++ b/public/js/scene.js @@ -1,20 +1,18 @@ // 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. +// Die komplette Szene wird EINMAL in ein Bild (Canvas -> ) gerendert; panzoom +// bewegt/zoomt dann nur dieses eine Bild -> butterweich, egal wie viele Items. +// Aufbau bewusst als reines DOM (div#world > img) statt SVG-viewBox: so stimmt der +// Pinch-Fokuspunkt auf Touch, und die Szene fuellt den Schirm (Cover), statt in +// schwarzen Balken zu ertrinken. -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 TAP_THRESHOLD_PX = 12; // Bewegung darueber = Ziehen, kein Tippen +const TAP_MAX_MS = 500; // laenger gedrueckt = kein Tippen +const RENDER_SCALE = 2; // Aufloesung des Szenen-Bitmaps (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; +let emojiAssetsAvailable = null; // pro Session: liegt ein Emoji-Bildset vor? function loadImage(url) { return new Promise((resolve) => { @@ -26,32 +24,48 @@ function loadImage(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 + constructor(stage, world, img) { this.stage = stage; + this.world = world; + this.img = img; this.pz = null; this.tapHandler = null; - this._down = null; this._objectUrl = null; - this._imgCache = new Map(); // url -> HTMLImageElement + this._imgCache = new Map(); + this._w = 2000; + this._h = 1400; + this._cover = 1; - this.svg.addEventListener('pointerdown', (e) => { - this._down = { x: e.clientX, y: e.clientY }; + // Tippen ueber Pointer-Events (funktioniert auch auf Touch, wo panzoom das + // synthetische click-Event unterdrueckt). Pinch/Mehrfinger zaehlt nie als Tipp. + this._pointers = new Map(); + this._multiTouch = false; + + this.stage.addEventListener('pointerdown', (e) => { + this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, t: performance.now() }); + if (this._pointers.size > 1) this._multiTouch = true; }); - 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); + this.stage.addEventListener('pointerup', (e) => { + const down = this._pointers.get(e.pointerId); + this._pointers.delete(e.pointerId); + const wasMulti = this._multiTouch; + if (this._pointers.size === 0) this._multiTouch = false; + if (!down || wasMulti) return; + const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y); + const dt = performance.now() - down.t; + if (moved > TAP_THRESHOLD_PX || dt > TAP_MAX_MS) return; // war Ziehen/Halten + this._emitTap(e.clientX, e.clientY); + }); + const forget = (e) => { + this._pointers.delete(e.pointerId); + if (this._pointers.size === 0) this._multiTouch = false; + }; + this.stage.addEventListener('pointercancel', forget); + + // Bei Groessen-/Orientierungswechsel die Cover-Ansicht neu einpassen. + window.addEventListener('resize', () => { + if (this.pz) this._applyCover(); }); } @@ -59,12 +73,13 @@ export class Scene { 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 }); + _emitTap(clientX, clientY) { + if (!this.tapHandler || !this.pz) return; + const r = this.stage.getBoundingClientRect(); + const t = this.pz.getTransform(); + const x = (clientX - r.left - t.x) / t.scale; + const y = (clientY - r.top - t.y) / t.scale; + this.tapHandler({ x, y }); } async render(payload) { @@ -72,9 +87,9 @@ export class Scene { this.pz.dispose(); this.pz = null; } - this.layer.innerHTML = ''; + this._w = payload.width; + this._h = payload.height; - // Emoji-Set einmal pruefen if (payload.type === 'emoji' && emojiAssetsAvailable === null) { const sample = payload.items.find((i) => i.kind === 'emoji'); emojiAssetsAvailable = sample @@ -82,26 +97,23 @@ export class Scene { : false; } - // benoetigte Bilder vorladen (Clipart/Foto + ggf. Emoji-PNGs) await this._preloadAssets(payload); + const url = await this._rasterize(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; + this._objectUrl = url; - const image = el('image', { - x: 0, - y: 0, - width: payload.width, - height: payload.height, + // Bild sicher laden, bevor wir einpassen (getBoundingClientRect etc.) + await new Promise((res) => { + this.img.onload = res; + this.img.onerror = res; + this.img.src = url; }); - image.setAttribute('href', dataUrl); - this.layer.appendChild(image); - this._initPanzoom(payload); + this.world.style.width = this._w + 'px'; + this.world.style.height = this._h + 'px'; + + this._initPanzoom(); } async _preloadAssets(payload) { @@ -122,8 +134,15 @@ export class Scene { const ctx = canvas.getContext('2d'); ctx.scale(RENDER_SCALE, RENDER_SCALE); + // Basisfarbe + dezenter Tiefen-Verlauf (oben heller, unten leichter Schatten) ctx.fillStyle = payload.background || '#ffffff'; ctx.fillRect(0, 0, payload.width, payload.height); + const grad = ctx.createLinearGradient(0, 0, 0, payload.height); + grad.addColorStop(0, 'rgba(255,255,255,0.22)'); + grad.addColorStop(0.5, 'rgba(255,255,255,0)'); + grad.addColorStop(1, 'rgba(0,0,0,0.14)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, payload.width, payload.height); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; @@ -136,7 +155,6 @@ export class Scene { ctx.restore(); } - // als Blob-URL (effizienter als riesiger dataURL-String) return await new Promise((resolve) => { canvas.toBlob((blob) => resolve(URL.createObjectURL(blob)), 'image/png'); }); @@ -154,7 +172,6 @@ export class Scene { 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); @@ -162,7 +179,6 @@ export class Scene { return; } - // Seitenverhaeltnis: SVGs sind quadratisch; Raster nach natuerlicher Groesse einpassen const isSvg = url.toLowerCase().endsWith('.svg'); let dw = size; let dh = size; @@ -174,19 +190,56 @@ export class Scene { ctx.drawImage(img, -dw / 2, -dh / 2, dw, dh); } - _initPanzoom(payload) { + _coverScale() { + const r = this.stage.getBoundingClientRect(); + return Math.max(r.width / this._w, r.height / this._h); + } + + _initPanzoom() { + this._cover = this._coverScale(); if (typeof panzoom === 'function') { - this.pz = panzoom(this.layer, { - maxZoom: 10, - minZoom: 0.9, - bounds: true, - boundsPadding: 0.15, + this.pz = panzoom(this.world, { + maxZoom: this._cover * 9, + minZoom: this._cover, // nicht weiter raus als "Cover" smoothScroll: true, zoomDoubleClickSpeed: 1, }); + // Eigene Cover-Klemmung: das Bild deckt den Schirm immer voll (kein Leerraum). + this._clamping = false; + this.pz.on('transform', () => this._clampToCover()); + } + this._applyCover(); + } + + // Szene bildschirmfuellend zentrieren (Cover). + _applyCover() { + if (!this.pz) return; + const r = this.stage.getBoundingClientRect(); + this._cover = this._coverScale(); + this.pz.zoomAbs(0, 0, this._cover); + const tx = (r.width - this._w * this._cover) / 2; + const ty = (r.height - this._h * this._cover) / 2; + this.pz.moveTo(tx, ty); + } + + // Haelt die Translation so, dass das Bild den Viewport immer vollstaendig deckt. + _clampToCover() { + if (!this.pz || this._clamping) return; + const r = this.stage.getBoundingClientRect(); + const t = this.pz.getTransform(); + const w = this._w * t.scale; + const h = this._h * t.scale; + + let x = t.x; + let y = t.y; + x = w >= r.width ? Math.min(0, Math.max(r.width - w, x)) : (r.width - w) / 2; + y = h >= r.height ? Math.min(0, Math.max(r.height - h, y)) : (r.height - h) / 2; + + if (Math.abs(x - t.x) > 0.5 || Math.abs(y - t.y) > 0.5) { + this._clamping = true; // Rekursion durch das erneute transform-Event vermeiden + this.pz.moveTo(x, y); + this._clamping = false; } - // viewBox NACH panzoom setzen (panzoom entfernt es beim Init). - this.svg.setAttribute('viewBox', `0 0 ${payload.width} ${payload.height}`); } _stageCenterClient() { @@ -207,22 +260,16 @@ export class Scene { } resetView() { - if (!this.pz) return; - this.pz.moveTo(0, 0); - this.pz.zoomAbs(0, 0, 1); + this._applyCover(); } - // kurze Treffer-/Daneben-Animation an einer Szenen-Position + // kurze Treffer-/Daneben-Animation an einer Szenen-Position (Welt-Pixel) 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); + const ping = document.createElement('div'); + ping.className = 'ping ' + (ok ? 'ping-ok' : 'ping-miss'); + ping.style.left = x + 'px'; + ping.style.top = y + 'px'; + this.world.appendChild(ping); + setTimeout(() => ping.remove(), 650); } } diff --git a/server/sceneGenerator.js b/server/sceneGenerator.js index fa5a13a..dc22b2a 100644 --- a/server/sceneGenerator.js +++ b/server/sceneGenerator.js @@ -99,7 +99,7 @@ function buildImageItem(cat, pos) { cx: pos.cx, cy: pos.cy, size: rand(cat.minSize, cat.maxSize), - rot: rand(-180, 180), + rot: rand(-14, 14), // aufrecht mit leichter Neigung -> wirkt platziert, nicht hingekippt }; } @@ -165,7 +165,7 @@ export function generateScene(cat) { cx: pos.cx, cy: pos.cy, size: rand((cat.minSize + cat.maxSize) / 2, cat.maxSize), - rot: rand(-25, 25), + rot: rand(-16, 16), }); } } @@ -185,7 +185,7 @@ export function generateScene(cat) { const item = isEmoji ? { kind: 'emoji', glyph: t.glyph, codepoint: t.codepoint, cx: pos.cx, cy: pos.cy, size, rot: rand(-12, 12) } - : { kind: 'image', assetUrl: t.url, cx: pos.cx, cy: pos.cy, size, rot: rand(-25, 25) }; + : { kind: 'image', assetUrl: t.url, cx: pos.cx, cy: pos.cy, size, rot: rand(-14, 14) }; items.push(item); legend.push(