Liquid Glass mit Refraktion
Eine ziehbare Glaslinse bricht Titel und Bilder an ihrer Kante wie echtes Glas: SVG-Displacement-Map aus einem Squircle-Profil nach Snellius, als backdrop-filter, mit Rim-Light.
Glas, das den Hintergrund an seiner Kante bricht wie eine echte Linse. Ein Höhenprofil beschreibt die Glaskante, z. B. ein konvexer Squircle y = (1 - (1 - x)^4)^(1/4). Aus der Steigung folgt per Snellius (Luft n = 1, Glas n = 1,5) die Ablenkung jedes Strahls. Die Verschiebungen werden für 127 Punkte auf dem Radius vorberechnet, um die Form gedreht und auf das Maximum normalisiert. Eine Displacement-Map kodiert sie als Farbe: R = 128 + x·127, G = 128 + y·127, B = 128. Ein SVG-Filter lädt die Map per feImage und verschiebt mit feDisplacementMap den Hintergrund. Ein zweites feImage legt ein Rim-Light (Lichtwinkel um -60°) per feBlend darüber.
Die Kante verbiegt, was darunter liegt, wie echtes Glas. Das liest sich sofort als hochwertig und neu, auch wegen der Nähe zu Apples Liquid Glass. Es unterscheidet sich klar vom flachen Milchglas.
Wann einsetzen
Für einzelne Hero-Elemente: Lupe über Bild, Such- oder CTA-Pill, Schalter, Player-Steuerung, schwebendes Navigations-Dock.
Wann nicht
Als Flächenstil für ganze Layouts. Wenn die Zielgruppe überwiegend Safari oder iOS nutzt und kein gutes Fallback da ist. Bei Formen, die ihre Größe laufend ändern, denn die Map muss für jede Größe neu gerechnet werden.
Varianten
- Konvexe Linse mit Squircle-Profil
- Lip-Profil für Schalter: Rand erhaben, Mitte vertieft
- Chromatische Aberration: je Farbkanal ein leicht anderer scale
- Welle: fließendes Glasband über Bild oder Headline, die Rillen-Map wandert per feImage x
- Elastische Reaktion auf Ziehen, Hover oder Klick
- Lupe mit zweiter Map für die Vergrößerung
- WebGL-Shader auf Canvas-Hintergrund
Umsetzung
Map einmal pro Größe in einem Offscreen-Canvas erzeugen und cachen. scale lässt sich ohne neue Map animieren, so blendet die Refraktion ein und aus. color-interpolation-filters="sRGB" setzen, sonst stimmt die Verschiebung nicht. feImage muss exakt so groß sein wie das Element, backdrop-filter passt die Größe nicht an (filterUnits und primitiveUnits userSpaceOnUse, Breite und Höhe in px). In Chromium als backdrop-filter: url(#glas) anwenden. Für einen weichen Frost-Anteil vor der Verschiebung feGaussianBlur (z. B. 2,5px, edgeMode duplicate) einfügen. Safari und Firefox zeigen die Verschiebung nicht: per Feature-Erkennung auf Frosted Glass mit Kantenlicht zurückfallen.
Robust umsetzen
Telefon. iOS Safari kann keinen SVG-Filter in backdrop-filter: dort Frosted Glass als Fallback. Android Chrome kann es, die Map aber klein halten.
Ohne Bewegung. Elastisches Nachfedern und animierten scale abschalten. Statische Refraktion ist unproblematisch.
Barrierefrei. Die Refraktion verzerrt Text darunter, also nie über Fließtext legen. Inhalt im Glas braucht ausreichend Kontrast. prefers-reduced-transparency respektieren und dann einen deckenden Grund zeigen. Eine ziehbare Linse auch per Tastatur bedienbar machen.
Code
<!DOCTYPE html>
<!--
Liquid Glass mit Refraktion
Fundus · Glas & Material · https://cecodesign.de/lab/fundus/glas-material/liquid-glass-refraktion/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Liquid Glass mit Refraktion · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; }
:root {
--papier: #ebe7de; --tinte: #131312; --leise: rgb(19 19 18 / .66); --linie: rgb(19 19 18 / .12);
--kobalt: #2236c8;
--kurve: cubic-bezier(0.32, 0.72, 0, 1); /* Ebenenwechsel 420 ms (gemessen) */
--ui: 150ms;
--rand: clamp(16px, 3.4vw, 48px);
--schatten: 0 10px 30px #00000014; /* gemessen, hell */
--schatten-gehoben: 0 16px 44px #00000026;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); -webkit-font-smoothing: antialiased; }
body {
min-height: 100svh; display: grid; grid-template-rows: auto 1fr; gap: clamp(12px, 2vw, 24px);
padding: var(--rand) var(--rand) calc(var(--rand) + 92px);
font: 400 16px/1.5 'Instrument Sans', system-ui, sans-serif;
/* feines Spaltenraster: gerade Linien zeigen die Brechung am deutlichsten */
background:
repeating-linear-gradient(90deg, var(--linie) 0 1px, transparent 1px calc((100vw - 2 * var(--rand)) / 12)) var(--rand) 0 / calc(100vw - 2 * var(--rand)) 100% no-repeat,
var(--papier);
overflow-x: hidden;
}
img { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
em { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; }
.mono { font: 500 11px/1.35 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
:focus-visible { outline: 2px solid var(--kobalt); outline-offset: 3px; }
/* ---------- Seite ---------- */
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; }
.kopf .marke { color: var(--tinte); }
.umschalter { display: flex; gap: 2px; padding: 3px; border: 1px solid var(--linie); border-radius: 999px; background: rgb(255 255 255 / .35); }
.umschalter button {
font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise);
background: none; border: 0; border-radius: 999px; padding: 7px 12px; cursor: pointer;
transition: background-color var(--ui) linear, color var(--ui) linear;
}
.umschalter button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); }
.buehne { display: grid; grid-template-rows: auto auto; align-content: center; gap: clamp(18px, 3vw, 40px); }
.titel {
font-size: clamp(64px, 21.5vw, 330px); font-weight: 600; line-height: .8; letter-spacing: -.06em;
margin-left: -.04em; overflow-wrap: anywhere; /* passt auf eine Zeile; bei Textabständen (1.4.12) bricht das Wort um statt quer zu scrollen */
}
.titel em { letter-spacing: -.035em; font-size: 1.08em; color: var(--kobalt); }
.unter { display: grid; grid-template-columns: 1.15fr 1fr 1fr 1fr; gap: clamp(12px, 1.6vw, 24px); align-items: end; }
.intro p:not(.mono) { font-size: clamp(15px, 1.25vw, 19px); line-height: 1.35; max-width: 26ch; letter-spacing: -.01em; }
.intro .mono { margin-bottom: 14px; }
.werk figure { display: grid; gap: 8px; }
.werk img { aspect-ratio: 3 / 2; object-fit: cover; width: 100%; border-radius: 3px; }
.werk figcaption { display: flex; justify-content: space-between; gap: 8px; }
.werte { margin-top: 18px; display: grid; gap: 3px; }
.werte b { font-weight: 500; color: var(--tinte); }
/* ---------- Das Glas ---------- */
.glas { position: fixed; z-index: 20; isolation: isolate; }
.linse {
--d: clamp(124px, 17vw, 230px);
left: 0; top: 0; width: var(--d); height: var(--d); border-radius: 50%;
cursor: grab; touch-action: none; will-change: transform;
box-shadow: var(--schatten), inset 0 0 0 1px rgb(255 255 255 / .28), inset 0 0 0 .5px rgb(0 0 0 / .08);
transition: box-shadow .42s var(--kurve);
}
.linse.gehoben { cursor: grabbing; box-shadow: var(--schatten-gehoben), inset 0 0 0 1px rgb(255 255 255 / .28); }
.linse:focus-visible { outline-offset: 6px; }
.dock {
left: 50%; bottom: calc(var(--rand) + 4px); translate: -50% 0;
display: flex; align-items: center; gap: 4px; padding: 7px 7px 7px 22px; height: 58px; border-radius: 999px;
background: rgb(255 255 255 / .16);
box-shadow: var(--schatten), inset 0 0 0 1px rgb(255 255 255 / .3);
}
.dock nav { display: flex; gap: 2px; }
.dock nav a { padding: 10px 14px; border-radius: 999px; font-size: 15px; font-weight: 500; letter-spacing: -.01em; transition: background-color var(--ui) linear; }
.dock nav a:hover { background: rgb(255 255 255 / .35); }
.dock .karte { margin-left: 8px; padding: 0 20px; height: 44px; display: grid; place-items: center; border-radius: 999px; background: var(--tinte); color: var(--papier); font-size: 15px; font-weight: 500; white-space: nowrap; }
/* echte Refraktion: SVG-Filter als backdrop-filter (nur Chromium) */
[data-modus="echt"] .linse { backdrop-filter: url(#glas-linse); }
[data-modus="echt"] .dock { backdrop-filter: url(#glas-dock); }
/* Fallback Safari/Firefox: Frosted Glass mit Kantenlicht */
[data-modus="frost"] .glas { backdrop-filter: blur(4px) saturate(1.6); -webkit-backdrop-filter: blur(4px) saturate(1.6); background: rgb(255 255 255 / .14); }
[data-modus="frost"] .glas { box-shadow: var(--schatten), inset 1px 1px 0 rgb(255 255 255 / .7), inset -1px -1px 0 rgb(255 255 255 / .35), inset 0 0 0 1px rgb(255 255 255 / .2); }
@media (prefers-reduced-transparency: reduce) {
.glas, [data-modus] .glas { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgb(246 244 239 / .94); }
}
.filter { position: absolute; width: 0; height: 0; overflow: hidden; }
/* ---------- Variante Welle: fließendes Glasband quer über Titel und Bilder ---------- */
[data-form="welle"] .linse {
width: calc(100vw - 2 * var(--rand)); height: clamp(84px, 10.5vw, 168px); border-radius: clamp(22px, 2.6vw, 40px);
cursor: ns-resize;
}
[data-form="welle"] .linse.gehoben { cursor: grabbing; }
/* ohne Refraktion (Safari, Firefox): Frost plus wandernde Lichtrillen, damit das Band als Welle lesbar bleibt */
[data-modus="frost"][data-form="welle"] .linse {
background-image: repeating-linear-gradient(100deg, rgb(255 255 255 / 0) 0 46px, rgb(255 255 255 / .2) 64px, rgb(255 255 255 / 0) 84px, rgb(255 255 255 / 0) 150px);
background-size: 150px 100%; animation: rillen 3.6s linear infinite;
}
@keyframes rillen { to { background-position: 150px 0; } }
[data-halt-an] .linse, .reduziert .linse { animation-play-state: paused; }
.halt {
color: var(--tinte); cursor: pointer;
border: 1px solid var(--linie); border-radius: 999px; background: rgb(255 255 255 / .35); padding: 7px 12px;
transition: background-color var(--ui) linear;
}
.halt[aria-pressed="true"] { background: var(--tinte); color: var(--papier); }
.halt[hidden], .vorschau .halt, .reduziert .halt { display: none; }
/* ---------- Vorschau: nur die Bühne ---------- */
.vorschau body { overflow: hidden; height: 100svh; }
.vorschau .umschalter, .vorschau .werte { display: none; }
/* ---------- Telefon ---------- */
@media (max-width: 720px) {
body { padding-bottom: calc(var(--rand) + 84px); }
.kopf .datum { display: none; }
.titel { font-size: 21vw; line-height: .84; }
.unter { grid-template-columns: repeat(3, 1fr); }
.intro { grid-column: 1 / -1; }
.werk figcaption .mono:last-child { display: none; }
.werk figcaption .mono { font-size: 10px; letter-spacing: .1em; }
.dock { padding-left: 10px; height: 54px; }
.dock nav a { padding: 10px 9px; font-size: 14px; }
.dock nav a:nth-child(3) { display: none; }
.dock .karte { padding: 0 14px; height: 40px; margin-left: 2px; font-size: 14px; }
}
@media (hover: none) {
.umschalter button { padding: 10px 12px; }
}
.reduziert .linse, .reduziert .umschalter button { transition: none; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<p class="mono marke">Kunsthalle am Kanal · Bonn</p>
<div class="umschalter mono" role="group" aria-label="Glasprofil">
<button type="button" data-variante="linse" aria-pressed="true">Linse</button>
<button type="button" data-variante="lip" aria-pressed="false">Lip</button>
<button type="button" data-variante="prisma" aria-pressed="false">Prisma</button>
<button type="button" data-variante="welle" aria-pressed="false">Welle</button>
</div>
<button type="button" class="halt mono" data-halt aria-pressed="false" hidden>Bewegung anhalten</button>
<p class="mono datum">07.11. – 21.12.2026</p>
</header>
<main class="buehne">
<h1 class="titel">Durch<em>sicht</em></h1>
<div class="unter">
<div class="intro">
<p class="mono">Ausstellung · Grafikdesign</p>
<p>Zwölf Arbeiten aus dem Rheinland, gezeigt hinter, unter und durch Glas. Ziehen Sie <span data-zieh>die Linse</span> über die Bilder.</p>
<div class="werte mono" data-werte aria-live="polite"></div>
</div>
<div class="werk"><figure><img src="bilder/typo-plakat.jpg" alt="Ausstellungsplakat in Neongelb: großes schwarzes LICHT, ein orangefarbener Kreis und die Kursive und was es übrig lässt"><figcaption><span class="mono">Licht · Plakat</span><span class="mono">2026</span></figcaption></figure></div>
<div class="werk"><figure><img src="bilder/magazin-doppelseite.jpg" alt="Aufgeschlagenes Magazin: links ein Keramik-Stillleben auf Terrakotta, rechts der Titel Die Ruhe der Dinge"><figcaption><span class="mono">Die Ruhe der Dinge</span><span class="mono">2025</span></figcaption></figure></div>
<div class="werk"><figure><img src="bilder/marke-roestkontor.jpg" alt="Wortmarke Röstkontor in breiten weißen Versalien auf Ziegelrot, darüber eine Kaffeebohne"><figcaption><span class="mono">Röstkontor</span><span class="mono">2024</span></figcaption></figure></div>
</div>
</main>
<div class="glas linse" data-linse tabindex="0" role="img" aria-label="Glaslinse. Mit der Maus ziehen oder mit den Pfeiltasten verschieben."></div>
<div class="glas dock" data-dock>
<nav aria-label="Ausstellung"><a href="#ausstellung">Ausstellung</a><a href="#programm">Programm</a><a href="#besuch">Besuch</a></nav>
<a class="karte" href="#tickets">Tickets</a>
</div>
<svg class="filter" aria-hidden="true" color-interpolation-filters="sRGB"><defs data-defs></defs></svg>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const linse = document.querySelector('[data-linse]');
const dock = document.querySelector('[data-dock]');
const defs = document.querySelector('[data-defs]');
const werteAnzeige = document.querySelector('[data-werte]');
const knoepfe = [...document.querySelectorAll('[data-variante]')];
/* Echte Refraktion braucht SVG-Filter in backdrop-filter: das kann nur Chromium. */
const ua = navigator.userAgent;
const chromium = (navigator.userAgentData && navigator.userAgentData.brands.some(b => /Chromium/.test(b.brand)))
|| (/Chrom(e|ium)\//.test(ua) && !/Edg\/|OPR\/|CriOS|FxiOS|Firefox/.test(ua)) || /Edg\//.test(ua);
html.dataset.modus = chromium ? 'echt' : 'frost';
/* Kurven */
function bezier(x1, y1, x2, y2) {
const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx, cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
const sx = t => ((ax * t + bx) * t + cx) * t, sy = t => ((ay * t + by) * t + cy) * t, dx = t => (3 * ax * t + 2 * bx) * t + cx;
return x => { let t = x; for (let i = 0; i < 8; i++) { const e = sx(t) - x; if (Math.abs(e) < 1e-5) break; const d = dx(t); if (Math.abs(d) < 1e-6) break; t -= e / d; } return sy(Math.min(1, Math.max(0, t))); };
}
const WECHSEL = bezier(0.32, 0.72, 0, 1); // 420 ms
const UEBERBLENDEN = bezier(0.42, 0, 0.58, 1); // ease-in-out, 1100 ms
/* ---------- Physik: Höhenprofil → Brechung (Snellius) → 127 Verschiebungen pro Radius ---------- */
const N_GLAS = 1.5, STRAHLEN = 127;
const squircle = x => Math.pow(1 - Math.pow(1 - x, 4), 0.25);
const glatt = x => x * x * x * (x * (x * 6 - 15) + 10); // smootherstep
const lipProfil = x => squircle(x) * (1 - glatt(x)) + (1 - squircle(x)) * glatt(x) * 0.6; // Rand erhaben, Mitte vertieft
function tabelle(profil) {
const f = profil === 'lip' ? lipProfil : squircle, t = new Float32Array(STRAHLEN);
let max = 0;
for (let i = 0; i < STRAHLEN; i++) {
const x = i / (STRAHLEN - 1), e = 1e-3, a = Math.max(0, x - e), b = Math.min(1, x + e);
const steigung = (f(b) - f(a)) / (b - a);
const ein = Math.atan(Math.abs(steigung)), aus = Math.asin(Math.sin(ein) / N_GLAS);
const v = Math.tan(ein - aus) * (f(x) + 0.5) * Math.sign(steigung); // seitlicher Versatz bis zum Grund
t[i] = v; max = Math.max(max, Math.abs(v));
}
return t.map(v => v / max);
}
/* Map als RGBA: R = 128 + x·127, G = 128 + y·127, B = 128, A = 255; dazu eine Rim-Light-Map (Licht −60°) */
const LICHT = -60 * Math.PI / 180, LX = Math.cos(LICHT), LY = Math.sin(LICHT);
const cache = new Map();
function karten(w, h, r, rand, profil, licht) {
const key = [w, h, r, rand, profil, licht].join('|');
if (cache.has(key)) return cache.get(key);
const tab = tabelle(profil);
const c = document.createElement('canvas'); c.width = w; c.height = h;
const s = document.createElement('canvas'); s.width = w; s.height = h;
const cx = c.getContext('2d'), sx = s.getContext('2d');
const d1 = cx.createImageData(w, h), d2 = sx.createImageData(w, h);
const hw = w / 2 - r, hh = h / 2 - r;
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4, px = x + 0.5 - w / 2, py = y + 0.5 - h / 2;
const qx = Math.abs(px) - hw, qy = Math.abs(py) - hh;
const abst = Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
d1.data[i] = 128; d1.data[i + 1] = 128; d1.data[i + 2] = 128; d1.data[i + 3] = 255;
if (abst > 0) continue;
let nx, ny;
if (qx > 0 && qy > 0) { const l = Math.hypot(qx, qy) || 1; nx = qx / l * Math.sign(px); ny = qy / l * Math.sign(py); }
else if (qx > qy) { nx = Math.sign(px); ny = 0; } else { nx = 0; ny = Math.sign(py); }
const t = Math.min(1, -abst / rand), m = tab[Math.round(t * (STRAHLEN - 1))];
d1.data[i] = 128 - nx * m * 127; d1.data[i + 1] = 128 - ny * m * 127; // Strahl knickt nach innen
const kante = Math.pow(Math.max(0, 1 - (-abst) / (rand * 0.32)), 1.6);
const dot = nx * LX + ny * LY, k = Math.max(0, dot) + Math.max(0, -dot) * 0.45;
d2.data[i] = d2.data[i + 1] = d2.data[i + 2] = 255; d2.data[i + 3] = 255 * licht * kante * (0.2 + 0.8 * k);
}
cx.putImageData(d1, 0, 0); sx.putImageData(d2, 0, 0);
const out = { disp: c.toDataURL(), licht: s.toDataURL() };
cache.set(key, out); return out;
}
/* ---------- Varianten ---------- */
const VARIANTEN = {
linse: { name: 'Linse', profil: 'squircle', scale: 70, rand: 0.4, saettigung: 1.4, licht: 0.5 },
lip: { name: 'Lip', profil: 'lip', scale: 70, rand: 0.72, saettigung: 1.4, licht: 0.4 },
prisma: { name: 'Prisma', profil: 'squircle', scale: 70, rand: 0.4, saettigung: 1.4, licht: 0.5, rgb: [76, 70, 64] },
// Welle: Glasband mit Squircle-Rand (randPx) und Sinusrillen; die Rillen-Map ist eine Periode breiter und wandert per feImage x
welle: { name: 'Welle', profil: 'squircle', scale: 44, randPx: 22, saettigung: 1.3, licht: 0.45, lambda: 180, tempo: 42, anteil: 0.5, biegung: 1.1 }
};
const DOCK = { profil: 'squircle', scale: 48, randPx: 20, vorblur: 2.5, saettigung: 1.4, licht: 0.35 };
let variante = 'linse', faktor = 0, disps = [];
function filterLinse(w) {
const v = VARIANTEN[variante], rand = Math.round(w / 2 * v.rand), k = karten(w, w, w / 2, rand, v.profil, v.licht);
const bild = (href, res) => `<feImage href="${href}" x="0" y="0" width="${w}" height="${w}" preserveAspectRatio="none" result="${res}"/>`;
let kette;
if (v.rgb) {
const kanal = ['1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0', '0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0', '0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 0'];
kette = v.rgb.map((s, i) => `<feDisplacementMap in="SourceGraphic" in2="karte" scale="${s}" xChannelSelector="R" yChannelSelector="G" result="d${i}"/><feColorMatrix in="d${i}" type="matrix" values="${kanal[i]}" result="k${i}"/>`).join('')
+ `<feComposite in="k0" in2="k1" operator="arithmetic" k2="1" k3="1" result="rg"/><feComposite in="rg" in2="k2" operator="arithmetic" k2="1" k3="1" result="gebrochen"/>`;
} else {
kette = `<feDisplacementMap in="SourceGraphic" in2="karte" scale="${v.scale}" xChannelSelector="R" yChannelSelector="G" result="gebrochen"/>`;
}
return `<filter id="glas-linse" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x="0" y="0" width="${w}" height="${w}" color-interpolation-filters="sRGB">`
+ bild(k.disp, 'karte') + kette
+ `<feColorMatrix in="gebrochen" type="saturate" values="${v.saettigung}" result="satt"/>`
+ bild(k.licht, 'licht') + `<feBlend in="licht" in2="satt" mode="screen"/></filter>`;
}
/* Rillen-Map der Welle: Höhenfeld h = sin(2πx/λ + b·sin(2πy/(1,3·H))), Verschiebung = Steigung; periodisch in x */
function wellenKarte(w, h) {
const v = VARIANTEN.welle, key = ['welle', w, h].join('|');
if (cache.has(key)) return cache.get(key);
const c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d'), d = g.createImageData(w, h);
for (let y = 0; y < h; y++) {
const wy = 2 * Math.PI * y / (h * 1.3), biege = v.biegung * Math.sin(wy), biegeD = Math.cos(wy);
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4, c0 = Math.cos(2 * Math.PI * x / v.lambda + biege);
d.data[i] = 128 + 127 * c0; d.data[i + 1] = 128 + 127 * c0 * biegeD * 0.45; d.data[i + 2] = 128; d.data[i + 3] = 255;
}
}
g.putImageData(d, 0, 0);
const url = c.toDataURL(); cache.set(key, url); return url;
}
function filterWelle(w, h) {
const v = VARIANTEN.welle, r = Math.min(h / 2, parseFloat(getComputedStyle(linse).borderTopLeftRadius) || 28);
const k = karten(w, h, r, v.randPx, v.profil, v.licht), lw = w + v.lambda;
return `<filter id="glas-linse" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x="0" y="0" width="${w}" height="${h}" color-interpolation-filters="sRGB">`
+ `<feImage href="${k.disp}" x="0" y="0" width="${w}" height="${h}" preserveAspectRatio="none" result="rand"/>`
+ `<feImage data-welle href="${wellenKarte(lw, h)}" x="${-wellePhase.toFixed(1)}" y="0" width="${lw}" height="${h}" preserveAspectRatio="none" result="rillen"/>`
+ `<feComposite in="rand" in2="rillen" operator="arithmetic" k2="1" k3="${v.anteil}" k4="${(-0.5 * v.anteil).toFixed(3)}" result="karte"/>`
+ `<feDisplacementMap in="SourceGraphic" in2="karte" scale="${v.scale}" xChannelSelector="R" yChannelSelector="G" result="gebrochen"/>`
+ `<feColorMatrix in="gebrochen" type="saturate" values="${v.saettigung}" result="satt"/>`
+ `<feImage href="${k.licht}" x="0" y="0" width="${w}" height="${h}" preserveAspectRatio="none" result="licht"/><feBlend in="licht" in2="satt" mode="screen"/></filter>`;
}
/* Fließen: feImage x wandert von 0 bis −λ und springt zurück (nahtlos, die Map ist periodisch) */
let wellePhase = 0, wellenBild = null, fliesst = false, fliessZeit = 0, angehalten = false;
function fliessen(t) {
if (variante !== 'welle' || angehalten || reduziert() || html.dataset.modus !== 'echt' || document.hidden) { fliesst = false; return; }
const dt = Math.min(64, t - fliessZeit); fliessZeit = t;
wellePhase = (wellePhase + dt * VARIANTEN.welle.tempo / 1000) % VARIANTEN.welle.lambda;
if (wellenBild) wellenBild.setAttribute('x', (-wellePhase).toFixed(1));
requestAnimationFrame(fliessen);
}
function fliessenStarten() { if (!fliesst) { fliesst = true; fliessZeit = performance.now(); requestAnimationFrame(fliessen); } }
document.addEventListener('visibilitychange', () => { if (!document.hidden) fliessenStarten(); });
function filterDock(w, h) {
const k = karten(w, h, h / 2, DOCK.randPx, DOCK.profil, DOCK.licht);
const bild = (href, res) => `<feImage href="${href}" x="0" y="0" width="${w}" height="${h}" preserveAspectRatio="none" result="${res}"/>`;
return `<filter id="glas-dock" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" x="0" y="0" width="${w}" height="${h}" color-interpolation-filters="sRGB">`
+ `<feGaussianBlur in="SourceGraphic" stdDeviation="${DOCK.vorblur}" edgeMode="duplicate" result="frost"/>`
+ bild(k.disp, 'karte') + `<feDisplacementMap in="frost" in2="karte" scale="${DOCK.scale}" xChannelSelector="R" yChannelSelector="G" result="gebrochen"/>`
+ `<feColorMatrix in="gebrochen" type="saturate" values="${DOCK.saettigung}" result="satt"/>`
+ bild(k.licht, 'licht') + `<feBlend in="licht" in2="satt" mode="screen"/></filter>`;
}
let groesse = 0;
function bauen() {
groesse = Math.round(linse.offsetWidth);
const dw = Math.round(dock.offsetWidth), dh = Math.round(dock.offsetHeight);
defs.innerHTML = (variante === 'welle' ? filterWelle(groesse, Math.round(linse.offsetHeight)) : filterLinse(groesse)) + filterDock(dw, dh);
disps = [...defs.querySelectorAll('#glas-linse feDisplacementMap')].map(el => ({ el, s: +el.getAttribute('scale') }));
wellenBild = defs.querySelector('[data-welle]');
if (variante === 'welle') fliessenStarten();
setzeFaktor(faktor);
// Chromium neu zeichnen lassen
if (html.dataset.modus === 'echt') { linse.style.backdropFilter = 'none'; dock.style.backdropFilter = 'none'; linse.offsetWidth; linse.style.backdropFilter = ''; dock.style.backdropFilter = ''; }
anzeigen();
}
function setzeFaktor(f) { faktor = f; disps.forEach(d => d.el.setAttribute('scale', (d.s * f).toFixed(2))); }
function anzeigen() {
const v = VARIANTEN[variante], rand = Math.round(groesse / 2 * v.rand);
if (variante === 'welle') {
werteAnzeige.innerHTML = html.dataset.modus === 'echt'
? `<span><b>Welle</b> · Glasband · Rand Squircle ${v.randPx} px</span><span>scale ${v.scale} · Rillen λ ${v.lambda} px · Anteil ${String(v.anteil).replace('.', ',')}</span><span>feImage x 0 → −λ · ${v.tempo} px/s${reduziert() ? ' · reduziert: steht' : ''}</span>`
: `<span><b>Fallback</b> · Frost + Lichtrillen</span><span>blur 4px · Rillen 150 px · 3,6 s</span><span>Echte Refraktion nur in Chromium</span>`;
return;
}
werteAnzeige.innerHTML = html.dataset.modus === 'echt'
? `<span><b>${v.name}</b> · Profil ${v.profil === 'lip' ? 'Lip' : 'Squircle'} · n 1,5</span>`
+ `<span>scale ${v.rgb ? v.rgb.join(' / ') + ' (R/G/B)' : v.scale} · Rand ${rand} px</span>`
+ `<span>${STRAHLEN} Strahlen · Licht −60° · saturate ${v.saettigung}</span>`
: `<span><b>Fallback</b> · Frosted Glass</span><span>blur 4px · saturate 1,6</span><span>Echte Refraktion nur in Chromium</span>`;
}
/* Tween für die Stärke der Brechung (scale ist animierbar ohne neue Map) */
let tweenId = 0;
function tween(von, nach, ms, kurve) {
const id = ++tweenId;
return new Promise(fertig => {
if (reduziert() || html.dataset.modus !== 'echt') { setzeFaktor(nach); return fertig(); }
const t0 = performance.now();
const schritt = t => {
if (id !== tweenId) return fertig();
const p = Math.min(1, (t - t0) / ms);
setzeFaktor(von + (nach - von) * kurve(p));
if (p < 1) requestAnimationFrame(schritt); else fertig();
};
requestAnimationFrame(schritt);
});
}
// Form der Glasfläche: Linse (Kreis) oder Band (Welle); Halt-Knopf nur, solange die Welle fließt
const haltKnopf = document.querySelector('[data-halt]');
function formSetzen() {
const welle = variante === 'welle';
if (welle) html.dataset.form = 'welle'; else delete html.dataset.form;
haltKnopf.hidden = !welle;
document.querySelector('[data-zieh]').textContent = welle ? 'das Glasband' : 'die Linse';
linse.setAttribute('aria-label', welle ? 'Glasband. Mit der Maus nach oben oder unten ziehen oder mit den Pfeiltasten verschieben.' : 'Glaslinse. Mit der Maus ziehen oder mit den Pfeiltasten verschieben.');
}
async function waehle(name) {
if (name === variante) return;
knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === name)));
await tween(faktor, 0, 420, WECHSEL);
const formWechsel = (name === 'welle') !== (variante === 'welle');
variante = name; formSetzen(); bauen();
if (formWechsel) setzeZiel(vorf ? ziel : startPos(), !vorf);
await tween(0, 1, 420, WECHSEL);
}
// Variante in der URL (?v=), andere Parameter bleiben erhalten
function urlSetzen(name) {
const u = new URL(location.href);
name === 'linse' ? u.searchParams.delete('v') : u.searchParams.set('v', name);
try { history.replaceState(null, '', u); } catch (e) { /* ohne History */ }
}
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); waehle(b.dataset.variante); urlSetzen(b.dataset.variante); }));
function anhalten(an) {
angehalten = an;
haltKnopf.setAttribute('aria-pressed', String(an));
haltKnopf.textContent = an ? 'Abspielen' : 'Bewegung anhalten';
if (an) { html.dataset.haltAn = ''; pause(); } else { delete html.dataset.haltAn; fliessenStarten(); }
}
haltKnopf.addEventListener('click', () => anhalten(!angehalten));
/* ---------- Bewegung der Linse: Ziehen, Lerp, elastische Streckung (elasticity 0.15) ---------- */
const ELASTIZITAET = 0.15, LERP = 0.28;
const ziel = { x: 0, y: 0 }, ist = { x: 0, y: 0 }, streck = { s: 1, a: 0 };
let laeuft = false, letzte = 0;
function startPos() {
const t = document.querySelector('.titel').getBoundingClientRect(), d = linse.offsetWidth;
if (variante === 'welle') return { x: (innerWidth - d) / 2, y: t.top + t.height * 0.52 - linse.offsetHeight / 2 };
return { x: t.left + t.width * 0.47 - d / 2, y: t.top + t.height * 0.5 - d / 2 };
}
function begrenzen(p) {
const d = linse.offsetWidth, dh = linse.offsetHeight;
if (variante === 'welle') return { x: (innerWidth - d) / 2, y: Math.max(4, Math.min(innerHeight - dh - 4, p.y)) }; // Band: nur senkrecht
return { x: Math.max(4, Math.min(innerWidth - d - 4, p.x)), y: Math.max(4, Math.min(innerHeight - d - 4, p.y)) };
}
function malen() {
if (variante === 'welle') { linse.style.transform = `translate3d(${ist.x.toFixed(2)}px, ${ist.y.toFixed(2)}px, 0)`; return; }
const a = streck.a * 180 / Math.PI, s = streck.s;
linse.style.transform = `translate3d(${ist.x.toFixed(2)}px, ${ist.y.toFixed(2)}px, 0) rotate(${a}deg) scale(${s.toFixed(4)}, ${(1 / s).toFixed(4)}) rotate(${-a}deg)` + (linse.classList.contains('gehoben') && !reduziert() ? ' scale(1.06)' : '');
}
function bild(t) {
const dt = Math.min(64, t - letzte) / 16.667; letzte = t;
const k = reduziert() ? 1 : 1 - Math.pow(1 - LERP, dt);
const vx = (ziel.x - ist.x) * k, vy = (ziel.y - ist.y) * k;
ist.x += vx; ist.y += vy;
const tempo = Math.hypot(vx, vy) / Math.max(dt, 0.001); // px pro 60-Hz-Bild
const sZiel = reduziert() ? 1 : 1 + Math.min(0.22, tempo * ELASTIZITAET * 0.05);
if (tempo > 0.3) streck.a = Math.atan2(vy, vx);
streck.s += (sZiel - streck.s) * (1 - Math.pow(1 - 0.18, dt));
malen();
const ruhig = Math.abs(ziel.x - ist.x) < 0.05 && Math.abs(ziel.y - ist.y) < 0.05 && Math.abs(streck.s - 1) < 0.001;
if (ruhig && !vorf) { laeuft = false; return; }
requestAnimationFrame(bild);
}
function starten() { if (!laeuft) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); } }
function setzeZiel(p, sofort) { const q = begrenzen(p); ziel.x = q.x; ziel.y = q.y; if (sofort) { ist.x = q.x; ist.y = q.y; streck.s = 1; malen(); } starten(); }
let griff = null;
linse.addEventListener('pointerdown', e => {
stopVorfuehrung();
griff = { dx: e.clientX - ist.x, dy: e.clientY - ist.y };
linse.setPointerCapture(e.pointerId); linse.classList.add('gehoben'); starten();
});
linse.addEventListener('pointermove', e => { if (griff) setzeZiel({ x: e.clientX - griff.dx, y: e.clientY - griff.dy }); });
const loslassen = () => { if (!griff) return; griff = null; linse.classList.remove('gehoben'); starten(); };
linse.addEventListener('pointerup', loslassen); linse.addEventListener('pointercancel', loslassen);
linse.addEventListener('keydown', e => {
const s = e.shiftKey ? 72 : 24, m = { ArrowLeft: [-s, 0], ArrowRight: [s, 0], ArrowUp: [0, -s], ArrowDown: [0, s] }[e.key];
if (!m) return; e.preventDefault(); stopVorfuehrung(); setzeZiel({ x: ziel.x + m[0], y: ziel.y + m[1] });
});
document.querySelectorAll('.dock a').forEach(a => a.addEventListener('click', e => e.preventDefault()));
let breiteAlt = innerWidth;
addEventListener('resize', () => {
if (Math.round(linse.offsetWidth) !== groesse || innerWidth !== breiteAlt) { breiteAlt = innerWidth; bauen(); if (!vorf) setzeZiel(startPos(), true); }
});
/* ---------- Vorführung: die Linse wandert in einer Acht über Titel und Bilder ---------- */
let vorf = false, uhr = null, t0 = 0, runde = -1, pausiertBei = 0;
const FOLGE = ['linse', 'lip', 'prisma'], RUNDE_MS = 8000;
function vorfuehren() {
stopVorfuehrung();
vorf = true; html.classList.add('vorfuehrung');
t0 = performance.now() - pausiertBei; pausiertBei = 0; runde = -1;
// Ohne ?v= wechselt die Vorführung Linse, Lip, Prisma; mit ?v= bleibt sie in der gewählten Variante
const fest = VARIANTEN[new URLSearchParams(location.search).get('v')] ? new URLSearchParams(location.search).get('v') : null;
const folge = fest ? [fest] : FOLGE;
fliessenStarten();
const schritt = t => {
if (!vorf) return;
const lauf = (t - t0) / RUNDE_MS, r = Math.floor(lauf), p = lauf - r;
if (r !== runde) { runde = r; waehle(folge[r % folge.length]); }
const ti = document.querySelector('.titel').getBoundingClientRect(), un = document.querySelector('.unter').getBoundingClientRect();
const d = linse.offsetWidth;
if (variante === 'welle' && !reduziert()) {
// Band gleitet langsam vom Titel über die Bilder und zurück
const dh = linse.offsetHeight, oben = ti.top + ti.height * 0.5, unten = Math.min(un.top + un.height * 0.55, innerHeight - dh * 0.6);
const q = 0.5 - 0.5 * Math.cos(p * Math.PI * 2);
ziel.y = oben + (unten - oben) * q - dh / 2; ziel.x = (innerWidth - d) / 2;
const b = begrenzen(ziel); ziel.y = b.y;
} else if (reduziert()) {
// keine Wanderung: Linse steht still, nur die Profile wechseln
const s = startPos(); ziel.x = s.x; ziel.y = s.y;
} else {
const cx = (ti.left + ti.right) / 2, cy = (ti.top + un.bottom) / 2 - d * 0.1;
const ax = Math.min(ti.width, innerWidth) * 0.36, ay = (un.bottom - ti.top) * 0.3;
const w = p * Math.PI * 2;
ziel.x = cx + ax * Math.sin(w) - d / 2; ziel.y = cy + ay * Math.sin(2 * w) - d / 2;
const q = begrenzen(ziel); ziel.x = q.x; ziel.y = q.y;
}
starten();
uhr = requestAnimationFrame(schritt);
};
uhr = requestAnimationFrame(schritt);
}
function stopVorfuehrung() { vorf = false; cancelAnimationFrame(uhr); uhr = null; html.classList.remove('vorfuehrung'); }
function pause() { if (vorf) { pausiertBei = performance.now() - t0; vorf = false; cancelAnimationFrame(uhr); uhr = null; html.dataset.pausiert = ''; } }
function weiter() { if ('pausiert' in html.dataset) { delete html.dataset.pausiert; const p = pausiertBei; vorfuehren(); t0 = performance.now() - p; } }
function init() {
const v = new URLSearchParams(location.search).get('v');
variante = VARIANTEN[v] ? v : 'linse'; knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === variante)));
formSetzen(); setzeFaktor(0); bauen(); setzeZiel(startPos(), true);
tween(0, 1, 1100, UEBERBLENDEN); // Refraktion blendet ein
}
function neustart() { stopVorfuehrung(); delete html.dataset.pausiert; pausiertBei = 0; init(); }
init();
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => { if (!vorf) setzeZiel(startPos(), true); });
})();
</script>
</body>
</html>