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>
545 lines
25 KiB
JavaScript
545 lines
25 KiB
JavaScript
/* 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
|
||
}
|