Files
Webklar-Kundenbereich/public/neu/wasser-leiste.js
T
ClaudeandClaude Opus 5.5 1f350ca432 Kundenansicht neu: Wasser-Leiste breiter mit Wörtern, Unterseiten im Vollbild
Wunsch vom 29.10. nach dem ersten Blick am iPhone:
- Die Leiste nutzt die Breite (12 px Rand, höchstens 480 px), Symbol über
  dem Wort, und steht ganz über dem Wisch-Balken des iPhones
  (Safe-Area plus 6 px statt minus 10 px).
- Unterseiten öffnen sich ganz und werden mit Unschärfe eingeblendet statt
  hochzufahren. Oben links die Überschrift, oben rechts „Zurück“ als
  Glasknopf, der beim Scrollen stehen bleibt (neuer Platz
  #kn-blatt-zurueck).
- Reiter einer Funktion stehen unten in derselben Wasser-Leiste (Art
  'reiter', Kurzwörter, kleinere Schrift wenn es eng wird), ohne Zurück.
  Ohne Reiter bleibt unten nichts. Die alte Leiste gibt beim Neuaufbau
  ihren WebGL-Kontext frei.

Glas und Unschärfe stehen weiter allein in wasser-leiste.css; huelle.css
bleibt ohne Glas. Desktop unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 18:09:42 +00:00

545 lines
25 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).
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
}