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>
This commit is contained in:
1 parent
83ac903ab3
commit
1725aaa91c
6 files changed
+794
-4
No files matched your search
@@ -23,6 +23,7 @@
|
||||
<link rel="stylesheet" href="/neu/ansichten.css">
|
||||
<link rel="stylesheet" href="/neu/funktionen.css">
|
||||
<link rel="stylesheet" href="/neu/module.css">
|
||||
<link rel="stylesheet" href="/neu/wasser-leiste.css">
|
||||
</head>
|
||||
<body class="kn">
|
||||
<a class="kn-skip" href="#kn-inhalt">Zum Inhalt</a>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
(Leiste und Streifen verschwinden nur bei Texteingabe) und seine Verdrahtung mit visualViewport, focusin und focusout. */
|
||||
|
||||
import { h, leeren } from './dom.js'
|
||||
import { baueReiter, anzeigeText } from './reiter.js'
|
||||
import { anzeigeText } from './reiter.js'
|
||||
import { baueWasserLeiste } from './wasser-leiste.js'
|
||||
|
||||
// Genau einmal in JS; das CSS führt dieselbe Abfrage (basis.css, huelle.css).
|
||||
export const HANDY_ABFRAGE = '(max-width: 880px), (pointer: coarse) and (max-height: 500px)'
|
||||
@@ -22,9 +23,10 @@ const beschriftung = (wort, anzeige) => {
|
||||
return `${wort.label}, ${text}${wort.einheit ? ` ${wort.einheit}` : ''}`
|
||||
}
|
||||
|
||||
/** Fahrstuhl-Zeile in `haupt` und Menü in `menue` bauen. onWahl(bereich) wird bei Klick auf ein Wort gerufen. */
|
||||
/** Handy-Leiste in `haupt` und Menü in `menue` bauen. onWahl(bereich) wird bei Klick auf ein Wort gerufen.
|
||||
* Am Handy ist die Hauptzeile die Wasser-Leiste (wasser-leiste.js): eine Glas-Pille mit Symbolen statt der vier Wörter mit Balken. */
|
||||
export function baueNavigation({ haupt, menue, onWahl }) {
|
||||
const reiter = baueReiter({ eintraege: WOERTER, aktiv: '', art: 'balken', ariaLabel: 'Bereiche', bei: (key) => onWahl(key) })
|
||||
const reiter = baueWasserLeiste({ eintraege: WOERTER, aktiv: '', ariaLabel: 'Bereiche', bei: (key) => onWahl(key) })
|
||||
leeren(haupt).append(reiter)
|
||||
|
||||
const knoepfe = Object.fromEntries(WOERTER.map((w) => [w.key, h('button', { class: 'kn-nav-item', type: 'button', 'data-bereich': w.key, on: { click: () => onWahl(w.key) } }, h('span', { class: 'kn-nav-text' }, w.label))]))
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/* Kundenansicht neu: Wasser-Leiste (wasser-leiste.js). Pille aus Glas mit Wassertropfen, nach der Komponente „Nimbus UI · Liquid Navbar“
|
||||
(guglielmogiannattasio.it). Die Regeln der Pille sind die der Vorlage mit kn-wl-Namen; darunter der Einbau in die Hülle am Handy:
|
||||
Die Leiste ist keine weiße Zeile mehr, sondern eine schwebende Pille über dem Inhalt. Unterseiten (Etage 2) zeigen statt der Bereiche eine
|
||||
gläserne Pille mit „Zurück“ und Reitern; der aktive Reiter liegt in einem Tropfen wie der CSS-Tropfen der Pille. Lädt nach huelle.css. */
|
||||
|
||||
/* ---------- Pille ---------- */
|
||||
|
||||
.kn-wl {
|
||||
--wl-tint: rgba(255,255,255,.14); --wl-fg: var(--kn-text); --wl-fg-soft: rgba(26,31,54,.6); --wl-rim: rgba(255,255,255,.7);
|
||||
position: relative; display: inline-flex; align-items: center; gap: 2px; padding: 6px; border-radius: 999px; isolation: isolate;
|
||||
-webkit-font-smoothing: antialiased; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.kn-wl-filter { position: absolute; }
|
||||
.kn-wl-glas {
|
||||
position: absolute; inset: 0; z-index: -2; overflow: hidden; border-radius: inherit; background: var(--wl-tint);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.2) brightness(1.08); backdrop-filter: blur(10px) saturate(1.2) brightness(1.08);
|
||||
box-shadow: inset 0 1px 1px var(--wl-rim), inset 0 0 0 1px rgba(255,255,255,.2), 0 12px 32px -14px rgba(0,40,90,.5), 0 2px 6px rgba(0,40,90,.1);
|
||||
}
|
||||
.kn-wl[data-brechung="an"] .kn-wl-glas { -webkit-backdrop-filter: url(#kn-wl-brechung) blur(2px) saturate(1.2) brightness(1.08); backdrop-filter: url(#kn-wl-brechung) blur(2px) saturate(1.2) brightness(1.08); }
|
||||
.kn-wl-wasser { position: absolute; left: -14px; top: -14px; z-index: -1; pointer-events: none; }
|
||||
.kn-wl[data-gl="off"] .kn-wl-wasser { display: none; }
|
||||
.kn-wl-tropfen {
|
||||
display: none; position: absolute; top: 6px; bottom: 6px; left: 0; z-index: -1; border-radius: 999px; pointer-events: none; will-change: transform, width;
|
||||
background: radial-gradient(120% 140% at 30% 0%, rgba(255,255,255,.75), rgba(255,255,255,0) 55%), rgba(255,255,255,.38);
|
||||
box-shadow: inset 0 1px 1px rgba(255,255,255,.95), inset 0 -2px 6px rgba(0,162,255,.22), inset 0 0 0 1px rgba(255,255,255,.55), 0 6px 16px -6px rgba(0,70,140,.45);
|
||||
}
|
||||
.kn-wl[data-gl="off"] .kn-wl-tropfen { display: block; }
|
||||
.kn-wl[data-leer] .kn-wl-tropfen { opacity: 0; }
|
||||
|
||||
.kn .kn-wl-item {
|
||||
position: relative; z-index: 1; display: inline-flex; align-items: center; gap: 8px; height: 42px; padding: 0 16px; border-radius: 999px;
|
||||
color: var(--wl-fg-soft); font-size: 14px; font-weight: 500; line-height: 1; letter-spacing: -.01em; white-space: nowrap; transition: color .25s;
|
||||
}
|
||||
.kn .kn-wl-item:hover, .kn .kn-wl-item[aria-selected="true"] { color: var(--wl-fg); }
|
||||
.kn .kn-wl-item:focus-visible { outline-offset: 2px; }
|
||||
|
||||
.kn-wl-ico { position: relative; display: inline-flex; flex: none; }
|
||||
.kn-wl-ico svg { transition: transform .45s cubic-bezier(.3,1.6,.5,1); }
|
||||
.kn-wl-item[aria-selected="true"] .kn-wl-ico svg { transform: translateY(-1px) scale(1.08); }
|
||||
.kn-wl-tuer { fill: transparent; }
|
||||
/* Zähler am Symbol: oben rechts, wie .kn-zahl in huelle.css */
|
||||
.kn-wl-ico .kn-zahl { top: -8px; margin-left: -9px; }
|
||||
|
||||
/* Szenen der Symbole */
|
||||
.kn-wl-ico[data-fx="pop"] svg { animation: kn-wl-pop .5s cubic-bezier(.3,1.6,.5,1) both; }
|
||||
@keyframes kn-wl-pop { 0% { transform: scale(.72); } 60% { transform: scale(1.2); } 100% { transform: translateY(-1px) scale(1.08); } }
|
||||
|
||||
.kn-wl-ico[data-fx="plane"] svg { animation: kn-wl-flug 1.15s cubic-bezier(.55,0,.3,1) both; }
|
||||
@keyframes kn-wl-flug { 0% { transform: none; } 12% { transform: translate(-3px,2px) rotate(7deg); } 42% { transform: translate(64px,-26px) rotate(-24deg) scale(.9); opacity: 0; } 43% { transform: translate(-60px,16px) rotate(-12deg) scale(.9); opacity: 0; } 58% { opacity: 1; } 80% { transform: translate(3px,-1px) rotate(2deg); } 100% { transform: translateY(-1px) scale(1.08); } }
|
||||
.kn-wl-ico[data-fx="plane"]::after { content: ""; position: absolute; left: 55%; top: 48%; width: 46px; height: 2px; border-radius: 2px; transform-origin: 0 50%; background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.95)); animation: kn-wl-spur 1.15s ease-out both; pointer-events: none; }
|
||||
@keyframes kn-wl-spur { 0%, 8% { opacity: 0; transform: rotate(-22deg) scaleX(0); } 30% { opacity: .9; transform: rotate(-22deg) scaleX(1); } 55%, 100% { opacity: 0; transform: rotate(-22deg) translateX(30px) scaleX(.2); } }
|
||||
|
||||
.kn-wl-ico[data-fx="drop"] svg { animation: kn-wl-tropf .95s cubic-bezier(.4,0,.3,1) both; transform-origin: 50% 100%; }
|
||||
@keyframes kn-wl-tropf { 0% { transform: translateY(-1px); } 26% { transform: translateY(-13px) scale(.88,1.12); } 46% { transform: translateY(3px) scale(1.3,.68); } 62% { transform: translateY(-3px) scale(.92,1.08); } 80% { transform: translateY(0) scale(1.04,.97); } 100% { transform: translateY(-1px) scale(1.08); } }
|
||||
.kn-wl-ico[data-fx="drop"]::before, .kn-wl-ico[data-fx="drop"]::after { content: ""; position: absolute; bottom: 0; left: 50%; width: 4px; height: 4px; margin-left: -2px; border-radius: 50%; background: currentColor; opacity: 0; pointer-events: none; }
|
||||
.kn-wl-ico[data-fx="drop"]::before { animation: kn-wl-spritzer-l .95s ease-out both; }
|
||||
.kn-wl-ico[data-fx="drop"]::after { animation: kn-wl-spritzer-r .95s ease-out both; }
|
||||
@keyframes kn-wl-spritzer-l { 0%, 44% { opacity: 0; transform: none; } 50% { opacity: .9; transform: translate(-5px,-6px); } 75%, 100% { opacity: 0; transform: translate(-11px,1px) scale(.4); } }
|
||||
@keyframes kn-wl-spritzer-r { 0%, 44% { opacity: 0; transform: none; } 50% { opacity: .9; transform: translate(5px,-7px); } 75%, 100% { opacity: 0; transform: translate(12px,1px) scale(.4); } }
|
||||
|
||||
.kn-wl-ico[data-fx="spin"] svg { animation: kn-wl-dreh 1s cubic-bezier(.2,.75,.25,1) both; }
|
||||
@keyframes kn-wl-dreh { 0% { transform: perspective(80px) rotateY(0); } 100% { transform: perspective(80px) rotateY(360deg) translateY(-1px) scale(1.08); } }
|
||||
|
||||
.kn-wl-ico[data-fx="glow"] svg { animation: kn-wl-hops .8s cubic-bezier(.3,1.4,.5,1) both; transform-origin: 50% 100%; }
|
||||
@keyframes kn-wl-hops { 0% { transform: none; } 30% { transform: translateY(-5px) scale(1.04,.96); } 55% { transform: translateY(0) scale(1.12,.88); } 75% { transform: translateY(-2px) scale(.98,1.04); } 100% { transform: translateY(-1px) scale(1.08); } }
|
||||
.kn-wl-ico[data-fx="glow"] .kn-wl-tuer { animation: kn-wl-licht 1.3s ease both; }
|
||||
@keyframes kn-wl-licht { 0% { fill: transparent; } 30% { fill: #fff3a8; } 100% { fill: rgba(126,232,255,.6); } }
|
||||
|
||||
.kn-wl-ico[data-fx="wave"] svg { animation: kn-wl-wink .9s ease-in-out both; transform-origin: 50% 92%; }
|
||||
@keyframes kn-wl-wink { 0% { transform: none; } 20% { transform: rotate(-15deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(4deg); } 100% { transform: translateY(-1px) scale(1.08); } }
|
||||
|
||||
/* Schmal (wie die Vorlage unter 520 px): nur Symbole, die Wörter bleiben für Screenreader im Knopf */
|
||||
@media (max-width: 520px) {
|
||||
.kn .kn-wl-item { padding: 0 13px; }
|
||||
.kn-wl-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
}
|
||||
|
||||
/* ---------- Einbau in die Hülle (Handy) ----------
|
||||
Die Pille schwebt: --kn-wl-luft Abstand zum unteren Rand (über dem Home-Balken des iPhones), darüber 10 px Luft zum Inhalt.
|
||||
--kn-wl-zone ist die Höhe dieser Zone; --kn-unten (Blatt, Chat) und der Abstand unter dem Inhalt rechnen damit statt mit der alten Zeile. */
|
||||
|
||||
.kn { --kn-wl-h: 54px; }
|
||||
|
||||
@media (max-width: 880px), (pointer: coarse) and (max-height: 500px) {
|
||||
.kn { --kn-zeile-h: calc(var(--kn-wl-h) + 10px); --kn-wl-luft: max(10px, calc(env(safe-area-inset-bottom) - 10px)); --kn-wl-zone: calc(var(--kn-zeile-h) + var(--kn-wl-luft)); --kn-unten: var(--kn-wl-zone); }
|
||||
.kn.kn-hat-streifen { --kn-unten: calc(var(--kn-wl-zone) + 44px); }
|
||||
.kn.kn-tastatur { --kn-unten: 0px; }
|
||||
|
||||
.kn-haupt { padding-bottom: calc(var(--kn-wl-zone) + 28px); }
|
||||
.kn-hat-streifen .kn-haupt { padding-bottom: calc(var(--kn-wl-zone) + 28px + 44px); }
|
||||
.kn-streifen { bottom: var(--kn-wl-zone); }
|
||||
/* Das Blatt reicht bis unten, sein Inhalt läuft unter der Pille durch */
|
||||
.kn-blatt { bottom: 0; padding-bottom: calc(var(--kn-unten) + 24px); }
|
||||
|
||||
.kn-leiste { border-top: 0; background: none; padding-bottom: var(--kn-wl-luft); pointer-events: none; }
|
||||
.kn-leiste-reel { position: relative; overflow: visible; }
|
||||
.kn-leiste .kn-rw {
|
||||
position: absolute; right: 12px; bottom: 0; left: 12px; width: fit-content; max-width: calc(100% - 24px); height: var(--kn-wl-h); margin: 0 auto; padding: 0;
|
||||
pointer-events: auto; transition: transform 450ms var(--kn-feder), opacity 250ms ease, visibility 0s;
|
||||
}
|
||||
/* huelle.css schiebt beide Zeilen eine Etage hoch; hier liegen die Pillen übereinander und tauschen mit kurzem Auf- und Abtauchen */
|
||||
.kn-leiste-reel[data-etage="offen"] .kn-rw { transform: none; }
|
||||
.kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage {
|
||||
opacity: 0; transform: translateY(18px) scale(.94); transition: transform 300ms var(--kn-bremse), opacity 200ms ease, visibility 0s 300ms;
|
||||
}
|
||||
|
||||
/* Etage 2: gläserne Pille mit „Zurück“ und Reitern */
|
||||
#kn-zeile-etage {
|
||||
min-width: 132px; padding: 5px; border-radius: 999px; isolation: isolate; background: rgba(255,255,255,.66);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.3) brightness(1.06); backdrop-filter: blur(12px) saturate(1.3) brightness(1.06);
|
||||
box-shadow: inset 0 1px 1px rgba(255,255,255,.7), inset 0 0 0 1px rgba(255,255,255,.2), 0 12px 32px -14px rgba(0,40,90,.5), 0 2px 6px rgba(0,40,90,.1);
|
||||
}
|
||||
#kn-zeile-etage .kn-zurueck { height: 44px; padding: 0 14px 0 8px; border-radius: 999px; }
|
||||
#kn-zeile-etage .kn-zurueck--voll { justify-content: center; }
|
||||
#kn-zeile-etage .kn-balken { height: 44px; padding-right: 4px; border-radius: 999px; }
|
||||
#kn-zeile-etage .kn-t { padding: 0 12px; }
|
||||
#kn-zeile-etage .kn-gleiter {
|
||||
z-index: 0; top: 4px; bottom: auto; height: 36px; border-radius: 999px; outline-offset: -3px;
|
||||
background: radial-gradient(120% 140% at 30% 0%, rgba(255,255,255,.75), rgba(255,255,255,0) 55%), rgba(255,255,255,.38);
|
||||
box-shadow: inset 0 1px 1px rgba(255,255,255,.95), inset 0 -2px 6px rgba(0,162,255,.22), inset 0 0 0 1px rgba(255,255,255,.55), 0 6px 16px -6px rgba(0,70,140,.45);
|
||||
}
|
||||
#kn-zeile-etage .kn-overlay { display: none; }
|
||||
#kn-zeile-etage .kn-t[aria-selected="true"] { color: var(--kn-text); }
|
||||
.kn #kn-zeile-etage .kn-balken:has(.kn-t:focus-visible) .kn-gleiter { outline-color: var(--kn-accent); }
|
||||
}
|
||||
|
||||
/* Querformat: niedrigere Pille */
|
||||
@media (max-height: 500px) and (max-width: 880px), (max-height: 500px) and (pointer: coarse) {
|
||||
.kn { --kn-wl-h: 48px; }
|
||||
.kn .kn-wl-item { height: 36px; }
|
||||
#kn-zeile-etage { padding: 2px; }
|
||||
}
|
||||
|
||||
/* Bewegung reduziert: keine Szenen, Pillen tauschen ohne Fahrt */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kn-wl-ico svg, .kn-wl-ico::before, .kn-wl-ico::after, .kn-wl-tuer { animation: none !important; transition: none; }
|
||||
.kn-leiste .kn-rw,
|
||||
.kn-leiste-reel[data-etage="offen"] #kn-zeile-haupt, .kn-leiste-reel:not([data-etage="offen"]) #kn-zeile-etage { transform: none; transition: opacity 150ms, visibility 0s; }
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
/* 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
|
||||
}
|
||||
Reference in new issue
Block a user