/* Kundenansicht neu: Wasser-Leiste (Handy). Nachbau der Komponente „Nimbus UI · Liquid Navbar – Waterline“ von guglielmogiannattasio.it, ohne React und ohne Abhängigkeiten: eine Pille aus Glas, darin ein Wassertropfen unter dem aktiven Bereich. Der Tropfen dehnt sich zum gewählten Bereich, reißt bei langen Sprüngen in Tröpfchen und fließt mit einer Welle wieder zusammen; jeder Tipp schickt eine Welle durch die Pille, jedes Symbol spielt eine kleine Szene. Das Wasser zeichnet ein kleiner WebGL-Shader (nur in der Pille). Er ruht, solange die Pille nicht zu sehen ist, und kommt einige Sekunden nach der letzten Bedienung zur Ruhe (Akku). Ohne WebGL zeigt die Pille einen CSS-Tropfen (wasser-leiste.css). Zwei Arten: 'bereiche' (Hauptleiste: Symbol über dem Wort, Zähler als .kn-zahl am Symbol) und 'reiter' (Reiter einer Unterseite: nur Wörter, Kurzwort `kurz` falls vorhanden). Rollen wie die Fahrstuhl-Zeile davor: tablist mit tab, aria-selected und aria-current, Beschriftung per aria-label. Die Rechenfunktionen (Feder, Tröpfchen, Wellen) sind rein und werden mit node --test geprüft; ohne Browser baut das Modul nur die Knöpfe. */ import { h } from './dom.js' import { anzeigeText } from './reiter.js' export const PAD = 14 // Rand der Zeichenfläche um die Pille, für Schatten und Wellen const FEDER_MS = 260 // so lange bleibt eine ausgeblendete Zahl im Baum (wie reiter.js) export const WACH_MS = 6000 // so lange bewegt sich das Wasser nach der letzten Bedienung von selbst const SVG_NS = 'http://www.w3.org/2000/svg' /* ---------- Symbole (24er Raster, Strich 1,8 wie die Vorlage) ---------- */ export const SYMBOLE = { website: [['circle', { cx: 12, cy: 12, r: 8.5 }], ['path', { d: 'M3.5 12h17M12 3.5c2.6 2.4 2.6 14.6 0 17M12 3.5c-2.6 2.4-2.6 14.6 0 17' }]], funktionen: [ ['rect', { x: 4, y: 4, width: 6.5, height: 6.5, rx: 1.8 }], ['rect', { x: 13.5, y: 4, width: 6.5, height: 6.5, rx: 1.8 }], ['rect', { x: 4, y: 13.5, width: 6.5, height: 6.5, rx: 1.8 }], ['rect', { x: 13.5, y: 13.5, width: 6.5, height: 6.5, rx: 1.8, class: 'kn-wl-tuer' }], // leuchtet bei der Szene 'glow' auf ], chat: [['path', { d: 'M20 11.5c0 4.1-3.6 7.5-8 7.5a8.7 8.7 0 0 1-3.3-.6L4 19.5l1.2-3.6A7.1 7.1 0 0 1 4 11.5C4 7.4 7.6 4 12 4s8 3.4 8 7.5Z' }]], vertraege: [['path', { d: 'M14 3.5H7.5A1.5 1.5 0 0 0 6 5v14a1.5 1.5 0 0 0 1.5 1.5h9A1.5 1.5 0 0 0 18 19V7.5Z' }], ['path', { d: 'M14 3.5v4h4M9 12.5h6M9 16h4' }]], } /** Szene je Bereich (Namen der Vorlage): der Globus dreht sich, eine Kachel leuchtet auf, die Blase winkt, das Blatt springt und spritzt. */ export const SZENEN = { website: 'spin', funktionen: 'glow', chat: 'wave', vertraege: 'drop' } function baueSymbol(name, doc) { const teile = SYMBOLE[name] if (!teile || typeof doc?.createElementNS !== 'function') return null // ohne Browser (node --test) nur das Wort const neu = (tag, attrs) => { const el = doc.createElementNS(SVG_NS, tag) for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)) return el } const svg = neu('svg', { viewBox: '0 0 24 24', width: 18, height: 18, fill: 'none', stroke: 'currentColor', 'stroke-width': 1.8, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false' }) for (const [tag, attrs] of teile) svg.append(neu(tag, attrs)) return svg } /** SVG-Filter für die Lichtbrechung des Glases (nur Chrome kann backdrop-filter mit url()). */ function baueFilter(doc) { if (typeof doc?.createElementNS !== 'function') return null const neu = (tag, attrs, ...kinder) => { const el = doc.createElementNS(SVG_NS, tag) for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)) el.append(...kinder) return el } return neu('svg', { class: 'kn-wl-filter', width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' }, neu('filter', { id: 'kn-wl-brechung', x: 0, y: 0, width: '100%', height: '100%', 'color-interpolation-filters': 'sRGB' }, neu('feTurbulence', { type: 'fractalNoise', baseFrequency: '0.006 0.02', numOctaves: 2, seed: 7, result: 'noise' }), neu('feGaussianBlur', { in: 'noise', stdDeviation: 2, result: 'soft' }), neu('feDisplacementMap', { in: 'SourceGraphic', in2: 'soft', scale: 60, xChannelSelector: 'R', yChannelSelector: 'G' }))) } /* ---------- Der Wasser-Shader (wörtlich aus der Vorlage) ---------- */ const VERT = 'attribute vec2 a;void main(){gl_Position=vec4(a,0.,1.);}' const FRAG = `precision highp float; uniform vec2 uRes;uniform float uDpr;uniform float uTime;uniform vec4 uPill; uniform vec4 uBlob[6];uniform float uSy[6];uniform vec4 uRip[6];uniform vec3 uMouse;uniform float uDark;uniform float uAmb; float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;} float sdCap(vec2 p,vec4 B,float sy){p-=B.xy;p.y/=sy;p.x=max(abs(p.x)-B.z,0.);return(length(p)-B.w)*min(1.,sy);} float smin(float a,float b,float k){float h=clamp(.5+.5*(b-a)/k,0.,1.);return mix(b,a,h)-k*h*(1.-h);} float dropD(vec2 p){float d=1e4;for(int i=0;i<6;i++){if(uBlob[i].w>.5){d=smin(d,sdCap(p,uBlob[i],uSy[i]),13.);}}return d;} float pillD(vec2 p){vec2 h=uPill.zw*.5;return sdRB(p-uPill.xy-h,h,h.y);} float lens(float d,float w){float t=clamp(-d/w,0.,1.);return sqrt(1.-(1.-t)*(1.-t));} float waves(vec2 p){float w=0.; for(int i=0;i<6;i++){vec4 R=uRip[i];if(R.w>0.){float x=length(p-R.xy)-R.z*240.;w+=R.w*exp(-R.z*1.7)*sin(x*.3)*exp(-x*x/700.);}} vec2 m=p-uMouse.xy;w+=uMouse.z*3.6*exp(-dot(m,m)/1000.); w+=uAmb*(sin(p.x*.05+uTime*1.2)*.45+sin(p.x*.13-uTime*.85+p.y*.2)*.25);return w;} float H(vec2 p){float dp=pillD(p);return lens(dp,uPill.w*.36)*2.4+lens(dropD(p),11.)*7.5+waves(p)*smoothstep(0.,7.,-dp);} float caus(vec2 p,float t){vec2 q=p;float c=0.;for(int i=0;i<3;i++){q+=vec2(sin(q.y*1.1+t),cos(q.x*1.3-t*.7))*.6;c+=1./(1.+16.*abs(sin(q.x)*sin(q.y)));}return c/3.;} void main(){ vec2 p=vec2(gl_FragCoord.x,uRes.y*uDpr-gl_FragCoord.y)/uDpr; float dp=pillD(p);float dd=dropD(p); float h0=H(p);vec3 n=normalize(vec3(h0-H(p+vec2(1.,0.)),h0-H(p+vec2(0.,1.)),1.)); vec3 V=vec3(0.,0.,1.); float spec=pow(max(dot(reflect(-normalize(vec3(-.45,-.8,.85)),n),V),0.),70.); float spec2=pow(max(dot(reflect(-normalize(vec3(.6,.75,.7)),n),V),0.),22.)*.3; float fres=clamp((1.-n.z)*4.,0.,1.); vec3 irid=.5+.5*cos(6.2831*(vec3(0.,.33,.67)+fres*.85+p.x*.0028+uTime*.035)); float pm=smoothstep(1.,-1.,dp);float dm=smoothstep(1.,-1.,dd)*smoothstep(1.,-2.,dp); float sh=smoothstep(9.,-9.,dropD(p-vec2(0.,5.)))*(1.-dm)*pm*(.11+.07*uDark); vec3 c=vec3(0.,.14,.36)*sh;float a=sh; float pa=clamp(pm*(fres*.5+spec*.9+spec2),0.,1.); c=mix(vec3(1.),irid,.5*fres)*pa+c*(1.-pa);a=pa+a*(1.-pa); float th=lens(dd,11.); float cs=caus((p+n.xy*th*18.)*.085,uTime*.9); vec3 tint=mix(vec3(.80,.95,1.),vec3(.55,.82,1.),uDark); float rim=dm*(1.-smoothstep(0.,8.,-dd)); float cres=dm*smoothstep(.12,.55,dot(n.xy,normalize(vec2(.55,.85))))*smoothstep(0.,3.,-dd); float body=.5+.5*clamp(-n.y*3.,-1.,1.); float da=dm*(.26+.14*body)*(1.-.35*uDark);vec3 dc=mix(tint,vec3(1.),body*.6); float la=rim*.22;vec3 lc=mix(vec3(0.,.42,.78),vec3(.1,.55,.95),uDark);dc=lc*la+dc*da*(1.-la);da=la+da*(1.-la); la=clamp(cres*.7+smoothstep(.6,1.,cs)*.28*th*dm,0.,1.);dc=vec3(1.)*la+dc*(1.-la);da=la+da*(1.-la); la=clamp(dm*(spec*1.2+spec2*.5),0.,1.);dc=vec3(1.)*la+dc*(1.-la);da=la+da*(1.-la); la=dm*smoothstep(-2.,0.,dd)*.4;dc=mix(vec3(1.),irid,.35)*la+dc*(1.-la);da=la+da*(1.-la); c=dc+c*(1.-da);a=da+a*(1.-da); gl_FragColor=vec4(c,a); }` function initGL(canvas) { let gl = null try { gl = canvas.getContext('webgl', { premultipliedAlpha: true, alpha: true, antialias: false }) } catch { return null } if (!gl) return null const shader = (typ, quelle) => { const s = gl.createShader(typ) gl.shaderSource(s, quelle) gl.compileShader(s) return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null } const vs = shader(gl.VERTEX_SHADER, VERT) const fs = shader(gl.FRAGMENT_SHADER, FRAG) if (!vs || !fs) return null const p = gl.createProgram() gl.attachShader(p, vs) gl.attachShader(p, fs) gl.linkProgram(p) if (!gl.getProgramParameter(p, gl.LINK_STATUS)) return null gl.useProgram(p) gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()) gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW) const ort = gl.getAttribLocation(p, 'a') gl.enableVertexAttribArray(ort) gl.vertexAttribPointer(ort, 2, gl.FLOAT, false, 0, 0) const u = {} for (const n of ['uRes', 'uDpr', 'uTime', 'uPill', 'uBlob', 'uSy', 'uRip', 'uMouse', 'uDark', 'uAmb']) u[n] = gl.getUniformLocation(p, n) return { gl, u } } function kannBrechen(win) { const ua = win?.navigator?.userAgent ?? '' return /Chrome\/|Chromium\/|Edg\//.test(ua) && !/Firefox\//.test(ua) && Boolean(globalThis.CSS?.supports?.('backdrop-filter', 'url(#a)')) } /* ---------- Rechnen: Zustand, Ziel, Feder (rein) ---------- */ /** Zustand des Wassers. a und b sind Kopf und Schwanz des Tropfens (Federn auf der x-Achse), sy die Stauchung, sats die Tröpfchen, rips die Wellen. */ export function neuerZustand() { return { a: { x: 0, v: 0 }, b: { x: 0, v: 0 }, sy: { s: 1, v: 0 }, lead: 0, lagFrom: 0, sats: [], rips: [], t: { x: 0, cy: 0, q: 0, r: 21 }, q: 0, maus: { x: 0, y: 0, s: 0, on: false }, moving: false, split: false, ready: false, leer: true, w: 0, h: 0, visible: true, reduce: false, amb: 0, wachBis: 0, time: 0, } } /** Ziel aus den Maßen des aktiven Knopfs (relativ zur Pille). Die erste Messung setzt den Tropfen sofort, spätere lassen ihn hinüberfließen. */ export function setzeZiel(s, { links, oben, breite, hoehe }, jetzt) { const T = s.t T.r = hoehe / 2 T.q = Math.max(0, breite / 2 - T.r) T.cy = PAD + oben + hoehe / 2 const nx = PAD + links + breite / 2 if (!s.ready) { s.a.x = s.b.x = nx s.a.v = s.b.v = 0 s.q = T.q s.ready = true } else if (Math.abs(nx - T.x) > 0.5) { s.lead = Math.abs(nx - s.a.x) <= Math.abs(nx - s.b.x) ? 0 : 1 // der nähere Körper führt, der andere folgt mit Verzug s.lagFrom = jetzt + 70 s.moving = true s.split = false } T.x = nx } /** Ein Physikschritt (dt in Sekunden, jetzt in ms wie performance.now). zufall ist für Tests einsetzbar. */ export function schritt(s, dt, jetzt, zufall = Math.random) { const T = s.t s.time += dt s.q += (T.q - s.q) * Math.min(1, dt * 12) const koerper = [s.a, s.b] let still = true if (s.reduce) { s.a.x = s.b.x = T.x s.a.v = s.b.v = 0 s.sy.s = 1 s.sats = [] } else { koerper.forEach((B, i) => { const fuehrt = i === s.lead const k = fuehrt ? 210 : 72 const c = fuehrt ? 25 : 13 const f = !fuehrt && jetzt < s.lagFrom ? 0.08 : 1 B.v += (k * (T.x - B.x) - c * B.v) * dt * f B.x += B.v * dt const lo = PAD + 6 + s.q + T.r - 3 const hi = PAD + s.w - 6 - s.q - T.r + 3 if (B.x < lo || B.x > hi) { // an die Wand: abprallen und stauchen s.sy.v -= Math.min(Math.abs(B.v), 900) * 0.0016 B.x = Math.max(lo, Math.min(hi, B.x)) B.v *= -0.25 } if (Math.abs(T.x - B.x) > 0.3 || Math.abs(B.v) > 3) still = false }) const L = koerper[s.lead] const syZiel = 1 - 0.2 * Math.min(Math.abs(L.v) / 1500, 1) s.sy.v += (280 * (syZiel - s.sy.s) - 9 * s.sy.v) * dt s.sy.s += s.sy.v * dt if (Math.abs(s.sy.s - 1) > 0.002 || Math.abs(s.sy.v) > 0.02) still = false // Der Hals reißt: zwischen Kopf und Schwanz entstehen Tröpfchen const sep = Math.abs(s.a.x - s.b.x) const r = T.r * (1 - 0.3 * Math.min(sep / (2 * (T.q + T.r) || 1), 1)) if (!s.split && s.moving && sep - 2 * T.q - 2 * r > -6) { s.split = true const lo = koerper[1 - s.lead] const hi = koerper[s.lead] ;[0.3, 0.5, 0.72].forEach((t, j) => { s.sats.push({ x: lo.x + (hi.x - lo.x) * t + (zufall() - 0.5) * 6, y: T.cy + (zufall() - 0.5) * 10, vx: (lo.v + hi.v) * 0.45, vy: (zufall() - 0.5) * 60, r: 5 + zufall() * 3 - j * 0.5, k: 70 + zufall() * 90, }) }) } s.sats = s.sats.filter((d) => { d.vx += (d.k * (T.x - d.x) - 9 * d.vx) * dt d.vy += (90 * (T.cy - d.y) - 7 * d.vy) * dt d.x += d.vx * dt d.y += d.vy * dt d.x = Math.max(PAD + 8 + d.r, Math.min(PAD + s.w - 8 - d.r, d.x)) d.y = Math.max(T.cy - T.r + d.r, Math.min(T.cy + T.r - d.r, d.y)) if (Math.abs(T.x - d.x) < T.q + T.r * 0.5) d.r -= dt * 26 // am Ziel angekommen: einsaugen return d.r > 0.6 }) if (s.sats.length) still = false } if (s.moving && still) { // angekommen: eine Welle, bei gerissenem Tropfen eine kräftigere s.moving = false if (s.split || !s.reduce) s.rips.push({ x: T.x, y: T.cy, age: 0, amp: s.split ? 1.2 : 0.7 }) } for (const p of s.rips) p.age += dt s.rips = s.rips.filter((p) => p.age < 2.4) s.maus.s += ((s.maus.on ? 1 : 0) - s.maus.s) * Math.min(1, dt * 7) // Eigenbewegung des Wassers: nach der letzten Bedienung blendet sie weich aus const ambZiel = !s.reduce && jetzt < s.wachBis ? 1 : 0 s.amb += (ambZiel - s.amb) * Math.min(1, dt * 2) if (s.amb < 0.005 && ambZiel === 0) s.amb = 0 } /** Muss die Schleife weiterlaufen? Bewegung, Tröpfchen, Wellen, Maus oder noch nicht ganz beruhigtes Wasser. */ export const istBeschaeftigt = (s, jetzt) => Boolean(s.moving || s.rips.length || s.sats.length || s.maus.s > 0.01 || s.amb > 0 || (!s.reduce && jetzt < s.wachBis)) /** Daten für den Shader: Kopf und Schwanz des Tropfens (ohne aktiven Bereich keiner), bis zu vier Tröpfchen und die letzten sechs Wellen. */ export function shaderDaten(s) { const T = s.t const sep = Math.abs(s.a.x - s.b.x) const duenn = Math.min(sep / (2 * (s.q + T.r) || 1), 1) const blobs = new Float32Array(24) const sy = new Float32Array(6).fill(1) if (!s.leer) { const kopf = s.lead === 0 ? s.a : s.b const schwanz = s.lead === 0 ? s.b : s.a blobs.set([kopf.x, T.cy, s.q * (1 - 0.25 * duenn), T.r * (1 - 0.12 * duenn), schwanz.x, T.cy, s.q * (1 - 0.8 * duenn), T.r * (1 - 0.5 * duenn)], 0) sy[0] = sy[1] = s.sy.s } s.sats.slice(0, 4).forEach((d, i) => blobs.set([d.x, d.y, 0, d.r], 8 + i * 4)) const rips = new Float32Array(24) s.rips.slice(-6).forEach((p, i) => rips.set([p.x, p.y, p.age, p.amp], i * 4)) return { blobs, sy, rips } } /** Lage und Breite des CSS-Tropfens (ohne WebGL), relativ zur Pille. */ export function tropfenMasse(s) { const T = s.t const sep = Math.abs(s.a.x - s.b.x) const r = T.r * (1 - 0.3 * Math.min(sep / (2 * (s.q + T.r) || 1), 1)) return { links: Math.min(s.a.x, s.b.x) - s.q - r - PAD, breite: sep + 2 * (s.q + r), skalaY: s.sy.s } } /* ---------- Bauen ---------- */ const beschriftung = (e) => { const text = anzeigeText({ zahl: e.zahl, ausruf: e.ausruf }) if (!text) return e.label if (e.ausruf) return `${e.label}, ${e.ausrufText ?? 'Hinweis'}` return `${e.label}, ${text}${e.einheit ? ` ${e.einheit}` : ''}` } /** * Wasser-Leiste bauen. Eintrag { key, label, kurz?, symbol?, fx?, einheit?, ausrufText? }. Art 'bereiche': symbol und fx folgen ohne Angabe dem key * (SYMBOLE, SZENEN). Art 'reiter': kein Symbol, keine Szene, sichtbar ist `kurz` (sonst label), vorgelesen label. * bei(key) wird bei jedem Tipp gerufen, auch auf den aktiven Eintrag (die Hülle scrollt dann nach oben). * Liefert das Element mit setzeAktiv(key), setzeAnzeige(key, { zahl, ausruf }), messen() und beende() (gibt auch den WebGL-Kontext frei). */ export function baueWasserLeiste({ eintraege, aktiv = '', art = 'bereiche', ariaLabel = 'Bereiche', bei = () => {}, warte = (fn, ms) => setTimeout(fn, ms) }) { const win = globalThis.window const doc = globalThis.document const s = neuerZustand() const bereiche = art === 'bereiche' const liste = eintraege.map((e) => ({ ...e, fx: e.fx ?? (bereiche ? SZENEN[e.key] ?? 'pop' : 'none'), zahl: 0, ausruf: false })) let aktuell = aktiv let glc = null let glVersucht = false // WebGL erst, wenn die Pille zu sehen ist (am Desktop nie) let raf = 0 let zuletzt = 0 const jetzt = () => (globalThis.performance?.now ? globalThis.performance.now() : Date.now()) for (const e of liste) { e.ico = bereiche ? h('span', { class: 'kn-wl-ico' }, baueSymbol(e.symbol ?? e.key, doc)) : null e.tab = h('button', { class: 'kn-wl-item', role: 'tab', type: 'button', 'data-key': e.key, 'aria-selected': 'false', 'aria-label': e.label, tabindex: '-1', on: { click: () => waehle(e.key) } }, e.ico, h('span', { class: 'kn-wl-label' }, e.kurz ?? e.label)) } const tabs = liste.map((e) => e.tab) const canvas = h('canvas', { class: 'kn-wl-wasser', 'aria-hidden': 'true' }) const tropfen = h('span', { class: 'kn-wl-tropfen', 'aria-hidden': 'true' }) const wurzel = h('div', { class: 'kn-wl', role: 'tablist', 'aria-label': ariaLabel, 'data-art': bereiche ? 'bereiche' : 'reiter', 'data-gl': 'on', 'data-brechung': 'aus', on: { keydown: beiTaste, pointermove: beiMaus, pointerleave: () => { s.maus.on = false; schleife() }, pointerdown: beiDruck }, }, baueFilter(doc), h('span', { class: 'kn-wl-glas', 'aria-hidden': 'true' }), canvas, tropfen, tabs) /* -- Zeichnen -- */ function zeichne() { if (!(s.w > 0)) return if (glc) { const { gl: g, u } = glc const dpr = Math.min(win?.devicePixelRatio || 1, 2) const d = shaderDaten(s) g.viewport(0, 0, g.drawingBufferWidth, g.drawingBufferHeight) g.uniform2f(u.uRes, s.w + PAD * 2, s.h + PAD * 2) g.uniform1f(u.uDpr, dpr) g.uniform1f(u.uTime, s.time) g.uniform4f(u.uPill, PAD, PAD, s.w, s.h) g.uniform4fv(u.uBlob, d.blobs) g.uniform1fv(u.uSy, d.sy) g.uniform4fv(u.uRip, d.rips) g.uniform3f(u.uMouse, s.maus.x, s.maus.y, s.maus.s) g.uniform1f(u.uDark, 0) g.uniform1f(u.uAmb, s.amb) g.clearColor(0, 0, 0, 0) g.clear(g.COLOR_BUFFER_BIT) g.drawArrays(g.TRIANGLES, 0, 3) } else { const m = tropfenMasse(s) tropfen.style.width = `${m.breite}px` tropfen.style.transform = `translateX(${m.links}px) scaleY(${m.skalaY})` } } function schleife() { if (raf || typeof win?.requestAnimationFrame !== 'function') return zuletzt = jetzt() const tick = (nun) => { const dt = Math.min(Math.max(0, nun - zuletzt) / 1000, 1 / 30) zuletzt = nun schritt(s, dt, nun) zeichne() raf = s.visible && istBeschaeftigt(s, nun) ? win.requestAnimationFrame(tick) : 0 } raf = win.requestAnimationFrame(tick) } function wecke() { s.wachBis = jetzt() + WACH_MS schleife() } /* -- Messen -- */ function messen() { if (!(wurzel.offsetWidth > 0)) return // nicht zu sehen (Desktop, Tastatur offen) oder ohne Browser if (!glVersucht) starteGL() if (!bereiche) { // passen die Wörter nicht, wird die Schrift kleiner (wie schriftStufe in reiter.js) wurzel.removeAttribute('data-eng') if (wurzel.scrollWidth > wurzel.clientWidth + 1) wurzel.setAttribute('data-eng', '') } s.w = wurzel.offsetWidth s.h = wurzel.offsetHeight const el = liste.find((e) => e.key === aktuell)?.tab if (el) setzeZiel(s, { links: el.offsetLeft, oben: el.offsetTop, breite: el.offsetWidth, hoehe: el.offsetHeight }, jetzt()) const dpr = Math.min(win?.devicePixelRatio || 1, 2) const W = s.w + PAD * 2 const H = s.h + PAD * 2 if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) { canvas.width = Math.round(W * dpr) canvas.height = Math.round(H * dpr) canvas.style.width = `${W}px` canvas.style.height = `${H}px` } zeichne() schleife() } /* -- Bedienung -- */ const lokal = (x, y) => { const r = wurzel.getBoundingClientRect?.() return r ? { x: x - r.left + PAD, y: y - r.top + PAD } : null } function welle(x, y, amp) { if (s.reduce) return s.rips.push({ x, y, age: 0, amp }) wecke() } function symbolMitte(i) { const b = (liste[i]?.ico ?? liste[i]?.tab)?.getBoundingClientRect?.() return b ? lokal(b.left + b.width / 2, b.top + b.height / 2) : null } /** Szene des Symbols (neu) starten; die anderen Symbole kehren in die Ruhe zurück. */ function spiele(i) { liste.forEach((e, j) => { if (j !== i) e.ico?.removeAttribute('data-fx') }) const e = liste[i] if (!e.ico) return e.ico.removeAttribute('data-fx') if (e.fx === 'none') return void e.ico.offsetWidth // Stil auflösen, damit die Animation von vorn beginnt e.ico.setAttribute('data-fx', e.fx) } function waehle(key) { const i = liste.findIndex((e) => e.key === key) if (i === -1) return spiele(i) const c = symbolMitte(i) if (c && liste[i].fx === 'drop') warte(() => welle(c.x, s.t.cy, 1.4), 430) if (c && liste[i].fx === 'plane') welle(c.x, c.y, 0.6) setzeAktiv(key) bei(key) } function beiTaste(ev) { const i = tabs.indexOf(ev.target?.closest?.('[role=tab]')) if (i === -1) return const n = tabs.length const ziel = { ArrowRight: (i + 1) % n, ArrowLeft: (i + n - 1) % n, Home: 0, End: n - 1 }[ev.key] if (ziel === undefined) return ev.preventDefault() tabs[ziel].focus() if (ziel !== i) waehle(liste[ziel].key) } function beiMaus(ev) { if (ev.pointerType !== 'mouse') return // die Wölbung unter dem Zeiger gibt es nur mit der Maus const p = lokal(ev.clientX, ev.clientY) if (!p) return Object.assign(s.maus, { x: p.x, y: p.y, on: true }) wecke() } function beiDruck(ev) { const p = lokal(ev.clientX, ev.clientY) if (p) welle(p.x, p.y, 0.8) } /* -- Von außen -- */ function setzeAktiv(key) { const wechsel = key !== aktuell aktuell = key const treffer = liste.some((e) => e.key === key) for (const e of liste) { const an = e.key === key e.tab.setAttribute('aria-selected', String(an)) if (an) e.tab.setAttribute('aria-current', 'page') else e.tab.removeAttribute('aria-current') e.tab.setAttribute('tabindex', an || (!treffer && e === liste[0]) ? '0' : '-1') } s.leer = !treffer if (treffer) wurzel.removeAttribute('data-leer') else wurzel.setAttribute('data-leer', '') if (wechsel && s.ready) s.wachBis = jetzt() + WACH_MS messen() } function setzeAnzeige(key, { zahl = 0, ausruf = false } = {}) { const e = liste.find((x) => x.key === key) if (!e?.ico) return e.zahl = zahl e.ausruf = ausruf const text = anzeigeText({ zahl, ausruf }) let punkt = e.ico.querySelector('.kn-zahl') if (text) { if (punkt?.classList.contains('kn-zahl--weg')) { punkt.remove(); punkt = null } // frisch erscheinen, damit die Feder noch einmal läuft if (!punkt) { punkt = h('span', { class: 'kn-zahl', 'aria-hidden': 'true' }); e.ico.append(punkt) } punkt.textContent = text punkt.classList.toggle('kn-zahl--ausruf', ausruf) } else if (punkt && !punkt.classList.contains('kn-zahl--weg')) { punkt.classList.add('kn-zahl--weg') warte(() => { if (punkt.classList.contains('kn-zahl--weg')) punkt.remove() }, FEDER_MS) } e.tab.setAttribute('aria-label', beschriftung(e)) } /* -- Browser: WebGL, Beobachter -- */ const aufraeumen = [] function starteGL() { glVersucht = true glc = initGL(canvas) if (!glc) { wurzel.setAttribute('data-gl', 'off'); return } const verloren = (ev) => { ev.preventDefault(); glc = null; wurzel.setAttribute('data-gl', 'off'); zeichne() } canvas.addEventListener('webglcontextlost', verloren) aufraeumen.push(() => canvas.removeEventListener('webglcontextlost', verloren)) } if (typeof canvas.getContext !== 'function') { glVersucht = true; wurzel.setAttribute('data-gl', 'off') } // ohne Browser gleich der CSS-Tropfen if (kannBrechen(win)) wurzel.setAttribute('data-brechung', 'an') s.reduce = Boolean(win?.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) if (typeof win?.IntersectionObserver === 'function') { const io = new win.IntersectionObserver(([e]) => { s.visible = e.isIntersecting; if (s.visible) wecke() }) io.observe(wurzel) aufraeumen.push(() => io.disconnect()) } if (typeof win?.ResizeObserver === 'function') { let breite = -1 const ro = new win.ResizeObserver(() => { const w = wurzel.offsetWidth if (w !== breite) { breite = w; s.ready = false; messen() } // neue Größe: der Tropfen springt an seinen Platz, statt zu fließen }) ro.observe(wurzel) aufraeumen.push(() => ro.disconnect()) } if (doc && typeof doc.addEventListener === 'function' && 'visibilityState' in doc) { const sicht = () => { s.visible = doc.visibilityState === 'visible'; if (s.visible) wecke() } doc.addEventListener('visibilitychange', sicht) aufraeumen.push(() => doc.removeEventListener('visibilitychange', sicht)) } doc?.fonts?.ready?.then?.(() => { s.ready = false; messen() }) function beende() { for (const fn of aufraeumen.splice(0)) fn() if (raf) win?.cancelAnimationFrame?.(raf) raf = 0 glc?.gl.getExtension('WEBGL_lose_context')?.loseContext() // Browser halten nur wenige Kontexte: jede Unterseite baut ihre Reiter neu glc = null glVersucht = true } wurzel.setzeAktiv = setzeAktiv wurzel.setzeAnzeige = setzeAnzeige wurzel.messen = messen wurzel.beende = beende setzeAktiv(aktiv) return wurzel }