Files
Webklar-Kundenbereich/public/neu/wasser-leiste.js
T
ClaudeandClaude Opus 5.5 1725aaa91c Kundenansicht neu: Wasser-Leiste am Handy (Glas-Pille mit Wassertropfen)
Die Handy-Leiste ist jetzt eine schwebende Glas-Pille mit vier Symbolen
(Website, Funktionen, Chat, Verträge) nach der Komponente „Nimbus UI ·
Liquid Navbar – Waterline“ (guglielmogiannattasio.it), ohne React
nachgebaut: Wassertropfen per WebGL-Shader unter dem aktiven Bereich,
Tröpfchen bei langen Sprüngen, Wellen beim Tippen, eine Szene je Symbol.
Ohne WebGL ein CSS-Tropfen, bei „Bewegung reduzieren“ ohne Bewegung.
Das Wasser ruht außerhalb des Bildes und 6 s nach der letzten Bedienung.

Unterseiten zeigen „Zurück“ und Reiter als gläserne Pille, der aktive
Reiter im Tropfen. Abstände (Blatt, Chat, Streifen, Inhalt) rechnen mit
der neuen Zone über --kn-wl-zone. Desktop unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 17:39:11 +00:00

534 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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).
Rollen wie die Fahrstuhl-Zeile davor: tablist mit tab, aria-selected und aria-current; Zähler als .kn-zahl am Symbol, 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, symbol?, fx?, einheit?, ausrufText? }; symbol und fx folgen ohne Angabe dem key (SYMBOLE, SZENEN).
* bei(key) wird bei jedem Tipp gerufen, auch auf den aktiven Bereich (die Hülle scrollt dann nach oben).
* Liefert das Element mit setzeAktiv(key), setzeAnzeige(key, { zahl, ausruf }), messen() und beende().
*/
export function baueWasserLeiste({ eintraege, aktiv = '', ariaLabel = 'Bereiche', bei = () => {}, warte = (fn, ms) => setTimeout(fn, ms) }) {
const win = globalThis.window
const doc = globalThis.document
const s = neuerZustand()
const liste = eintraege.map((e) => ({ ...e, fx: e.fx ?? SZENEN[e.key] ?? 'pop', 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 = h('span', { class: 'kn-wl-ico' }, baueSymbol(e.symbol ?? e.key, doc))
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.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-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()
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?.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]
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) 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
}
wurzel.setzeAktiv = setzeAktiv
wurzel.setzeAnzeige = setzeAnzeige
wurzel.messen = messen
wurzel.beende = beende
setzeAktiv(aktiv)
return wurzel
}