Atmosphärische Partikel
Boutique-Hotel am Ratzeburger Domsee: Staub leuchtet nur im Sonnenfleck auf, Glut steigt aus dem Kamin und verlischt, unscharfe Lichterketten atmen über dem See.
Hotel, Manufaktur, Atelier: ein Innenraum mit Sonnenfleck oder Streiflicht; der Staub leuchtet nur dort, wo das Bild hell ist.
Wenige, ruhige Partikel liegen als Luft über einem Bild: Staub, der nur im Lichtfleck aufleuchtet, Glut, die über einer Feuerstelle aufsteigt und verlischt, oder unscharfe Lichtpunkte (Bokeh) im Vordergrund. Die Partikel gehören zum Motiv. Sie machen ein Standbild lebendig, ohne es zu verändern. Keine Netzwerk-Linien zwischen den Punkten.
Das Auge liest feine, langsame Bewegung als Raum und Luft. Ein Foto wirkt dadurch wie ein Blick in einen echten Raum. Weil die Partikel physikalisch plausibel sind (Staub nur im Licht, Glut steigt und kühlt ab, Bokeh ist unscharf und nah), wirken sie wie Teil der Aufnahme und nicht wie Deko.
Wann einsetzen
Hero oder Stimmungsbild eines Hotels, einer Manufaktur oder eines Restaurants, wenn das Bild selbst Licht, Feuer oder Abendlicht zeigt. Als Experiment oder Eigenprojekt. Nur ein Partikelfeld je Seite.
Wann nicht
Netzwerk-Linien zwischen Punkten (particles.js-Look der 2010er). Konfetti oder Schnee als Dauerdeko. Partikel über Fließtext oder Formularen. Branchen, die Ruhe und Sachlichkeit verlangen (Gesundheit, Kanzlei, Verein). Bilder ohne Lichtquelle: Staub ohne Lichtstrahl und Glut ohne Feuer wirken aufgesetzt. Glut nur mit echtem Feuer im Bild (Kamin, Holzofen, Grill).
Varianten
- staub: helle Staubkörner treiben langsam im Curl-Noise-Feld und leuchten nur dort auf, wo das Bild hell ist (Lichtmaske aus dem Bild selbst)
- glut: Funken steigen mit Auftrieb und Flackern auf, kühlen von Gelbweiß über Orange zu Rot ab und verlöschen
- bokeh: wenige große, unscharfe Lichtscheiben in drei Tiefenebenen, sehr langsam, leichte Parallaxe zum Zeiger
- schnee: nur saisonal und zeitlich begrenzt, nie als Dauerdeko
Umsetzung
Ein Canvas 2D über dem Bild, pointer-events: none, aria-hidden. Jede Partikelart wird einmal als kleines Sprite (radialer Verlauf) in ein Offscreen-Canvas gezeichnet und dann nur noch per drawImage gestempelt, mit globalCompositeOperation 'lighter' für Licht. Bewegung über ein Curl-Noise-Feld (Rotation eines Rauschfelds, daher wirbelfrei und ohne Klumpen), Tiefe über Größe, Deckkraft und Tempo. Staub: Helligkeit des Bildes an der Partikelstelle aus einer 64er-Kopie des Bildes lesen und als Lichtmaske nutzen. Glut: Auftrieb nach oben, seitliches Flackern, Farbe über die Lebenszeit. Bokeh: wenige große Scheiben mit weichem Rand, drei Ebenen. Auflösung höchstens 1,5 × devicePixelRatio, Anzahl nach Fläche skaliert und auf Telefonen gedeckelt. requestAnimationFrame nur, solange die Bühne sichtbar ist (IntersectionObserver) und der Tab sichtbar ist (visibilitychange). Ab etwa 1000 Partikeln WebGL-Points statt Canvas 2D.
Robust umsetzen
Telefon. Hohe Bildausschnitte, 60 % der Partikel, Pixelverhältnis höchstens 1,5, keine Zeigerreaktion. Pause-Knopf bleibt sichtbar. Bei Glut liegt der Text auf dem Telefon über dem Feuer: dort einen Ausschnitt wählen, in dem das Feuer oben sitzt.
Ohne Bewegung. Partikel stehen als Standbild: einmal gezeichnet, keine Schleife. Der Pause-Knopf entfällt dann.
Barrierefrei. Rein dekorativ: Canvas mit aria-hidden. Läuft länger als 5 s, also sichtbarer Pause-Knopf (WCAG 2.2.2) als echter button mit aria-pressed. Text liegt nie über dem Partikelfeld ohne Kontrastschicht (WCAG 1.4.3). Keine schnellen Blitze (WCAG 2.3.1): Glut flackert weich, nicht hell-dunkel im Takt.
Code
<!DOCTYPE html>
<!--
Atmosphärische Partikel
Fundus · 3D & WebGL · https://cecodesign.de/lab/fundus/3d-webgl/atmosphaere-partikel/
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>Atmosphärische Partikel · 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: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; font-display: swap; }
@font-face { font-family: 'Mona Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/mona-sans:vf@latest/latin-wdth-normal.woff2') format('woff2-variations'); font-weight: 200 900; font-stretch: 75% 125%; 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; }
:root {
/* Rezept boutique-hotel-gastgeber: Sand, Creme, warmes Dunkel, ein Hauston aus Holz */
--sand: #f3eee7; --creme: #fef2e3; --dunkel: #3e3a37; --linie: #d5d1c8; --meta: #5d5f59; --holz: #6e5a47;
--hell: #fef2e3; --hell-leise: rgba(254, 242, 227, .8);
--weich: cubic-bezier(0.37, 0, 0.63, 1); /* token-luxus-weich: Bilder, Ebenen */
--ui-auf: cubic-bezier(0.16, 1, 0.3, 1); /* token-toggle-asymmetrisch: öffnen */
--rand: clamp(20px, 4vw, 64px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--sand); color: var(--dunkel); font-family: 'Mona Sans', system-ui, sans-serif; font-stretch: 100%; -webkit-font-smoothing: antialiased; }
:root:has(:focus-visible) { scroll-behavior: auto; }
body { overflow-x: hidden; }
button, input, select { font: inherit; color: inherit; }
a { color: inherit; }
:focus-visible { outline: 2px solid var(--holz); outline-offset: 3px; }
.nur-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.label { font: 600 11.5px/1.3 'Mona Sans', sans-serif; font-stretch: 125%; letter-spacing: .06em; text-transform: uppercase; }
/* ---------- Hero: Bild, Partikel, Kontrastschicht, Text ---------- */
.hero { position: relative; height: calc(100svh - 64px); min-height: 560px; max-height: 980px; overflow: hidden; color: var(--hell); background: #1d1813; isolation: isolate; }
.szene { position: absolute; inset: 0; margin: 0; }
.szene img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .9s var(--weich); }
.szene img.an { opacity: 1; }
[data-v=staub] .szene img { filter: brightness(.9); } /* etwas Luft nach oben, damit die Körner im Licht aufleuchten */
.partikel { position: absolute; inset: 0; width: 100%; height: 100%; display: block; pointer-events: none; opacity: 0; transition: opacity 1.2s var(--weich); }
.partikel.an { opacity: 1; }
/* Kontrastschicht: links unten dunkel für Text, oben für den Kopf */
.hero::after { content: ''; position: absolute; inset: 0; pointer-events: none; z-index: 1;
background:
linear-gradient(180deg, rgba(24, 19, 15, .55) 0, rgba(24, 19, 15, 0) 150px),
linear-gradient(72deg, rgba(24, 19, 15, .78) 0%, rgba(24, 19, 15, .5) 34%, rgba(24, 19, 15, 0) 62%); }
[data-v=staub] .hero::after { background:
linear-gradient(180deg, rgba(24, 19, 15, .5) 0, rgba(24, 19, 15, 0) 150px),
linear-gradient(72deg, rgba(28, 26, 18, .84) 0%, rgba(28, 26, 18, .58) 36%, rgba(28, 26, 18, 0) 64%); }
.kopf { position: relative; z-index: 3; display: flex; align-items: center; gap: 28px; padding: 22px var(--rand); }
.marke { font: 400 25px/1 'Newsreader', serif; font-variation-settings: 'opsz' 36; letter-spacing: -.01em; text-decoration: none; margin-right: auto; }
.marke i { font-style: italic; }
.kopf nav { display: flex; gap: 26px; }
.kopf nav a, .sprache a { text-decoration: none; color: var(--hell-leise); transition: color .15s linear; }
.kopf nav a:hover, .kopf nav a[aria-current], .sprache a[aria-current] { color: var(--hell); }
.sprache { display: flex; gap: 8px; }
.kopf .buchen { display: inline-flex; align-items: center; min-height: 44px; padding: 0 18px; border-radius: 2px; background: var(--creme); color: var(--dunkel); text-decoration: none; transition: background-color .15s linear; }
.kopf .buchen:hover { background: #fff; }
.hero :focus-visible { outline-color: var(--hell); }
.text { position: absolute; z-index: 3; left: var(--rand); right: var(--rand); bottom: clamp(96px, 13vh, 132px); max-width: 720px; }
.text .label { color: var(--hell-leise); display: flex; gap: 12px; align-items: center; margin-bottom: 18px; }
.text .label i { width: 28px; height: 1px; background: currentColor; }
h1 { font: 300 clamp(2.5rem, 1.5rem + 4vw, 5.5rem)/1.05 'Newsreader', serif; font-variation-settings: 'opsz' 72; letter-spacing: -.015em; text-wrap: balance; }
h1 em { font-style: italic; font-weight: 300; }
.text p.unter { margin-top: 18px; max-width: 46ch; font-size: 1.0625rem; line-height: 1.6; color: var(--hell-leise); }
.wechsel { transition: opacity .6s var(--weich); }
.wechsel.raus { opacity: 0; }
/* Szenen-Notiz und Pause-Knopf rechts unten im Hero */
.ecke { position: absolute; z-index: 3; right: var(--rand); bottom: clamp(96px, 13vh, 132px); display: grid; justify-items: end; gap: 12px; }
.ecke .label { color: var(--hell-leise); font-size: 11px; }
.pause { display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 16px 0 12px; border-radius: 999px; cursor: pointer;
background: rgba(24, 19, 15, .5); border: 1px solid rgba(254, 242, 227, .34); color: var(--hell); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
transition: background-color .15s linear, border-color .15s linear; }
.pause:hover { background: rgba(24, 19, 15, .72); border-color: rgba(254, 242, 227, .6); }
.pause:active { background: rgba(24, 19, 15, .85); }
.pause svg { width: 16px; height: 16px; flex: none; }
.pause .spiel { display: none; }
.pause[aria-pressed=true] .halt { display: none; }
.pause[aria-pressed=true] .spiel { display: block; }
.pause .zustand { font: 500 11px/1 'Mona Sans', sans-serif; font-stretch: 125%; letter-spacing: .06em; text-transform: uppercase; }
.reduziert .pause { display: none; }
/* ---------- Buchungsleiste halb über dem Hero ---------- */
.buchung { position: relative; z-index: 4; margin: -64px var(--rand) 0; background: var(--creme); color: var(--dunkel); border-radius: 2px; box-shadow: 0 18px 50px rgba(30, 24, 18, .16);
display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto; align-items: stretch; }
.feld { display: grid; gap: 6px; padding: 18px 22px; border-right: 1px solid var(--linie); min-width: 0; }
.feld label { color: var(--meta); }
.feld input, .feld select { border: 0; background: none; padding: 0; font-size: 1.0625rem; min-height: 28px; width: 100%; min-width: 0; color: var(--dunkel); }
.feld input:focus-visible, .feld select:focus-visible { outline: 2px solid var(--holz); outline-offset: 4px; }
.feld:focus-within { box-shadow: inset 0 -3px 0 var(--holz); } /* auch für die Teilfelder und den Kalenderknopf von type=date */
.feld input:focus { outline: 2px solid var(--holz); outline-offset: 4px; }
.buchung button { border: 0; padding: 0 30px; min-height: 64px; background: var(--holz); color: var(--creme); cursor: pointer; border-radius: 0 2px 2px 0; transition: background-color .15s linear; }
.buchung button:hover { background: #5b4939; }
.buchung button:active { background: #4c3c2f; }
.buchung button:focus-visible { outline: 2px solid var(--dunkel); outline-offset: 3px; }
.antwort { grid-column: 1 / -1; padding: 0 22px; max-height: 0; overflow: hidden; font-size: .95rem; transition: max-height .3s var(--ui-auf), padding .3s var(--ui-auf); }
.antwort.da { max-height: 80px; padding: 14px 22px 16px; border-top: 1px solid var(--linie); }
/* ---------- Unter dem Hero ---------- */
.haus { display: grid; grid-template-columns: minmax(0, 1.1fr) repeat(3, minmax(0, 1fr)); gap: clamp(24px, 4vw, 56px); padding: clamp(56px, 8vw, 112px) var(--rand); }
.haus h2 { font: 300 clamp(2rem, 1.4rem + 2vw, 3rem)/1.05 'Newsreader', serif; font-variation-settings: 'opsz' 60; letter-spacing: -.01em; }
.haus h2 em { font-style: italic; }
.haus h3 { font: 400 1.5rem/1.15 'Newsreader', serif; margin: 10px 0 10px; }
.haus p:not(.label) { line-height: 1.6; color: #4a4642; max-width: 34ch; }
.haus .label { color: var(--meta); }
.fuss { display: flex; flex-wrap: wrap; gap: 8px 28px; justify-content: space-between; padding: 26px var(--rand) 40px; border-top: 1px solid var(--linie); color: var(--meta); }
/* ---------- Pult (Demo-Steuerung) ---------- */
.pult { position: fixed; z-index: 20; top: 84px; right: var(--rand); display: grid; gap: 8px; justify-items: end; max-width: min(460px, calc(100vw - 2 * var(--rand)));
font: 500 10.5px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--hell); }
.pult .knoepfe { display: flex; gap: 4px; padding: 4px; border-radius: 999px; background: rgba(24, 19, 15, .62); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); flex-wrap: wrap; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; border: 0; background: none; color: rgba(254, 242, 227, .78); height: 30px; padding: 0 12px; border-radius: 999px; cursor: pointer; transition: background-color .15s linear, color .15s linear; }
.pult button:hover { color: var(--hell); }
.pult button[aria-pressed=true] { background: var(--creme); color: var(--dunkel); }
.pult button:focus-visible { outline: 2px solid var(--hell); outline-offset: 2px; }
.pult .werte { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px 14px; padding: 7px 12px; border-radius: 10px; background: rgba(24, 19, 15, .62); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); color: rgba(254, 242, 227, .8); }
.pult .werte b { color: var(--hell); font-weight: 500; }
/* ---------- Vorschau 960 × 720: nur die Bühne ---------- */
.vorschau body { overflow: hidden; }
.vorschau .hero { height: 100vh; min-height: 0; }
.vorschau .buchung, .vorschau .haus, .vorschau .fuss, .vorschau .pause, .vorschau .kopf nav, .vorschau .sprache, .vorschau .text p.unter { display: none; }
.vorschau .text, .vorschau .ecke { bottom: 64px; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (max-width: 1080px) { .kopf nav { display: none; } .haus { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 760px) {
.hero { height: auto; min-height: 640px; height: 92svh; }
.sprache { display: none; }
.text { bottom: 140px; }
.ecke { left: var(--rand); right: auto; bottom: 74px; justify-items: start; grid-auto-flow: column; align-items: center; }
.ecke .label { display: none; }
.text p.unter { font-size: 1rem; }
.buchung { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: -40px; }
.feld:nth-child(2) { border-right: 0; }
.feld:nth-child(3) { grid-column: 1 / -1; border-right: 0; border-top: 1px solid var(--linie); }
.buchung button { grid-column: 1 / -1; border-radius: 0 0 2px 2px; min-height: 56px; }
.haus { grid-template-columns: minmax(0, 1fr); }
.pult { top: auto; bottom: 16px; }
}
@media (max-width: 360px) { .feld { padding: 14px 14px; } .kopf { gap: 12px; } .kopf .buchen { padding: 0 12px; } }
@media (hover: none) { .pult button { height: 36px; } }
@media (prefers-reduced-transparency: reduce) { .pause, .pult .knoepfe, .pult .werte { background: #18130f; -webkit-backdrop-filter: none; backdrop-filter: none; } }
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch], input, select, textarea { border: 1px solid ButtonText !important; }
.hero::after { display: none; }
}
</style>
</head>
<body>
<section class="hero" aria-labelledby="titel" data-hero>
<figure class="szene" aria-hidden="true">
<picture data-bild="staub"><source media="(max-width: 640px)" srcset="bilder/fenster-licht-hoch.jpg"><img src="bilder/fenster-licht.jpg" width="1200" height="800" alt="" fetchpriority="high"></picture>
<picture data-bild="glut"><source media="(max-width: 640px)" srcset="bilder/atmosphaere-partikel-kamin-hoch.jpg"><img src="bilder/atmosphaere-partikel-kamin.jpg" width="1440" height="900" alt="" loading="lazy"></picture>
<picture data-bild="bokeh"><source media="(max-width: 640px)" srcset="bilder/atmosphaere-partikel-see-hoch.jpg"><img src="bilder/atmosphaere-partikel-see.jpg" width="1440" height="900" alt="" loading="lazy"></picture>
</figure>
<canvas class="partikel" data-partikel aria-hidden="true"></canvas>
<p class="nur-sr" data-bildtext></p>
<header class="kopf">
<a class="marke" href="#titel">Haus <i>Ahrend</i></a>
<nav class="label" aria-label="Hauptmenü"><a href="#haus" aria-current="page">Das Haus</a><a href="#haus">Zimmer</a><a href="#haus">Kaminzimmer</a><a href="#haus">Anreise</a></nav>
<p class="sprache label"><a href="#titel" aria-current="true" lang="de">DE</a><a href="#titel" lang="en">EN</a></p>
<a class="buchen label" href="#buchung">Buchen</a>
</header>
<div class="text">
<p class="label wechsel" data-ort><span>Ratzeburg</span><i></i><span data-raum>Gartenzimmer</span></p>
<h1 id="titel" class="wechsel" data-titel>Am Nachmittag wird das Licht im Gartenzimmer <em>ganz still</em>.</h1>
<p class="unter wechsel" data-unter>Elf Zimmer in einem Kapitänshaus von 1871, zwei Minuten vom Domsee. Frühstück bis halb zwölf, Bücher auf jedem Flur.</p>
</div>
<div class="ecke">
<p class="label wechsel" data-notiz>Gartenzimmer · 16 Uhr</p>
<button class="pause" type="button" aria-pressed="false" data-pause>
<svg class="halt" viewBox="0 0 16 16" aria-hidden="true"><rect x="3" y="2" width="3.4" height="12" rx="1" fill="currentColor"/><rect x="9.6" y="2" width="3.4" height="12" rx="1" fill="currentColor"/></svg>
<svg class="spiel" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 2.2v11.6c0 .6.6.9 1.1.6l9-5.8c.5-.3.5-.9 0-1.2l-9-5.8C4.6 1.3 4 1.6 4 2.2z" fill="currentColor"/></svg>
<span class="zustand">Bewegung anhalten</span>
</button>
</div>
</section>
<form class="buchung" id="buchung" aria-label="Zimmer anfragen" data-buchung>
<div class="feld"><label class="label" for="an">Anreise</label><input id="an" type="date" value="2026-11-14"></div>
<div class="feld"><label class="label" for="ab">Abreise</label><input id="ab" type="date" value="2026-11-16"></div>
<div class="feld"><label class="label" for="gaeste">Gäste</label>
<select id="gaeste"><option>1 Erwachsener</option><option selected>2 Erwachsene</option><option>2 Erwachsene, 1 Kind</option><option>3 Erwachsene</option></select></div>
<button type="submit" class="label">Verfügbarkeit prüfen</button>
<p class="antwort" role="status" data-antwort></p>
</form>
<section class="haus" id="haus" aria-labelledby="haus-titel">
<h2 id="haus-titel">Ein Haus, das <em>langsam</em> macht.</h2>
<div><p class="label">01 · Zimmer</p><h3>Elf Zimmer, keins gleich</h3><p>Dielen aus dem Speicher, Kalkputz in Salbei und Sand. Ab 138 € pro Nacht für zwei, inklusive Frühstück.</p></div>
<div><p class="label">02 · Kaminzimmer</p><h3>Ab fünf brennt Holz</h3><p>Buchenholz aus dem Lauenburger Forst, Tee und ein Regal voller Bücher. Für Hausgäste, ohne Anmeldung.</p></div>
<div><p class="label">03 · Anreise</p><h3>Mit dem Zug bis Ratzeburg</h3><p>Ab Lübeck 20 Minuten, vom Bahnhof holen wir Sie mit dem Lastenrad ab. Parkplätze im Hof.</p></div>
</section>
<footer class="fuss label"><span>Haus Ahrend · Domstraße 9 · 23909 Ratzeburg</span><span>Erklärung zur Barrierefreiheit · Impressum · Datenschutz</span></footer>
<div class="pult">
<div class="knoepfe" role="group" aria-label="Partikel-Variante">
<button type="button" data-variante="staub" aria-pressed="true">Staub</button>
<button type="button" data-variante="glut" aria-pressed="false">Glut</button>
<button type="button" data-variante="bokeh" aria-pressed="false">Bokeh</button>
</div>
<p class="werte" data-werte></p>
</div>
<p class="nur-sr" aria-live="polite" data-ansage></p>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const hero = $('[data-hero]'), canvas = $('[data-partikel]'), ctx = canvas.getContext('2d');
const pauseKnopf = $('[data-pause]'), werteEl = $('[data-werte]'), ansage = $('[data-ansage]');
const knoepfe = [...document.querySelectorAll('[data-variante]')];
const bilder = Object.fromEntries([...document.querySelectorAll('[data-bild]')].map(p => [p.dataset.bild, p.querySelector('img')]));
/* ---------- Varianten: Inhalt und Werte (exakt die verwendeten) ---------- */
const V = {
staub: {
raum: 'Gartenzimmer', notiz: 'Gartenzimmer · 16 Uhr',
titel: 'Am Nachmittag wird das Licht im Gartenzimmer <em>ganz still</em>.',
unter: 'Elf Zimmer in einem Kapitänshaus von 1871, zwei Minuten vom Domsee. Frühstück bis halb zwölf, Bücher auf jedem Flur.',
bild: 'Ruhiger Raum mit salbeigrüner Wand, auf die die Nachmittagssonne ein Fensterbild wirft; davor eine Eichenbank mit dunkler Vase und trockenem Zweig.',
anzahl: 180, werte: [['Partikel', '180 / 1440×900'], ['Tempo', '4–14 px/s'], ['Kern', '1–4 px, 28 % unscharf'], ['Licht', 'Bildhelligkeit^1.2'], ['Zeiger', 'Verdrängung 120 px']],
},
glut: {
raum: 'Kaminzimmer', notiz: 'Kaminzimmer · ab 17 Uhr',
titel: 'Ab fünf brennt im Kaminzimmer <em>das Holz vom Hof</em>.',
unter: 'Buchenholz aus dem Lauenburger Forst, Tee aus der Kanne und ein Regal voller Bücher. Für Hausgäste, ohne Anmeldung.',
bild: 'Dunkles Kaminzimmer: ein breiter, niedriger Kamin mit brennenden Scheiten in einer Putzwand, davor eine Steinbank, rechts eine Nische mit gestapeltem Holz.',
anzahl: 130, werte: [['Partikel', '130 / 1440×900'], ['Leben', '2.4–4.8 s'], ['Auftrieb', '40–90 px/s, ×0.93/s'], ['Farbe', 'Gelbweiß → Orange → Rot'], ['Knistern', 'alle 3–6 s, 6–12 Funken']],
},
bokeh: {
raum: 'Terrasse', notiz: 'Terrasse am Domsee · blaue Stunde',
titel: 'Zur blauen Stunde liegt <em>die Insel</em> im Licht.',
unter: 'Die Terrasse geht bis ans Wasser. Bis Ende Oktober mit Decken und heißem Apfelsaft, danach drinnen am Fenster.',
bild: 'Unscharfer Blick über den See zur Blauen Stunde auf eine Inselstadt mit Dom und erleuchteten Fenstern, vorn ein hölzerner Handlauf.',
anzahl: 28, werte: [['Scheiben', '28 in 3 Ebenen'], ['Radius', '8–14 / 18–30 / 40–70 px'], ['Tempo', '3–8 px/s'], ['Atmen', '6–10 s'], ['Parallaxe', '±4 / 8 / 14 px']],
},
};
const NAMEN = { staub: 'Staub im Licht', glut: 'Glut', bokeh: 'Bokeh' };
const q = new URLSearchParams(location.search);
let variante = V[q.get('v')] ? q.get('v') : 'staub';
/* ---------- Leinwand ---------- */
let B = 0, H = 0, dpr = 1, mobil = false;
function groesse() {
const r = hero.getBoundingClientRect();
B = r.width; H = r.height;
dpr = Math.min(devicePixelRatio || 1, 1.5);
canvas.width = Math.round(B * dpr); canvas.height = Math.round(H * dpr);
mobil = matchMedia('(max-width: 640px), (hover: none)').matches;
lichtmaske();
}
/* Sprites: einmal gezeichnet, dann nur gestempelt */
function sprite(r, stops) {
const c = document.createElement('canvas'); c.width = c.height = r * 2;
const g = c.getContext('2d'), v = g.createRadialGradient(r, r, 0, r, r, r);
stops.forEach(([o, f]) => v.addColorStop(o, f));
g.fillStyle = v; g.fillRect(0, 0, r * 2, r * 2);
return c;
}
const S = {
staub: sprite(32, [[0, 'rgba(255,248,226,1)'], [.22, 'rgba(255,244,214,.85)'], [.5, 'rgba(255,236,196,.22)'], [1, 'rgba(255,230,190,0)']]),
staubWeich: sprite(32, [[0, 'rgba(255,246,224,.62)'], [.55, 'rgba(255,242,214,.3)'], [.8, 'rgba(255,238,206,.1)'], [1, 'rgba(255,236,200,0)']]),
glut: [],
bokeh: [],
};
/* Glut: 10 Stufen von Gelbweiß über Orange zu Rot */
const GLUT = [[255, 246, 214], [255, 214, 140], [255, 178, 84], [255, 140, 48], [246, 106, 30], [222, 78, 22], [190, 56, 18], [150, 38, 14], [110, 26, 10], [70, 16, 8]];
GLUT.forEach(([r, g, b]) => S.glut.push(sprite(24, [[0, `rgba(255,252,236,1)`], [.12, `rgba(${r},${g},${b},1)`], [.34, `rgba(${r},${g},${b},.35)`], [1, `rgba(${r},${g},${b},0)`]])));
/* Bokeh: Scheibe mit leicht hellerem Rand und weicher Kante (wie eine offene Blende) */
function scheibe(farbe) {
const r = 64, c = document.createElement('canvas'); c.width = c.height = r * 2;
const g = c.getContext('2d'), v = g.createRadialGradient(r, r, 0, r, r, r);
v.addColorStop(0, `rgba(${farbe},.55)`); v.addColorStop(.72, `rgba(${farbe},.62)`); v.addColorStop(.86, `rgba(${farbe},.8)`); v.addColorStop(.93, `rgba(${farbe},.35)`); v.addColorStop(1, `rgba(${farbe},0)`);
g.fillStyle = v; g.beginPath(); g.arc(r, r, r, 0, Math.PI * 2); g.fill();
return c;
}
['255,190,112', '255,222,168', '255,170,120', '246,206,150'].forEach(f => S.bokeh.push(scheibe(f)));
/* ---------- Lichtmaske: Helligkeit des Bildes an jeder Stelle (für Staub) ---------- */
const MW = 72; let maske = null, MH = 0;
function abdeckung(img) { // object-fit: cover → Bild-UV ↔ Bühne
const iw = img.naturalWidth || 1440, ih = img.naturalHeight || 900;
const s = Math.max(B / iw, H / ih);
return { s, ox: (B - iw * s) / 2, oy: (H - ih * s) / 2, iw, ih };
}
function lichtmaske() {
maske = null;
const img = bilder.staub;
if (!img.complete || !img.naturalWidth || !B) return;
MH = Math.max(1, Math.round(MW * H / B));
try {
const c = document.createElement('canvas'); c.width = MW; c.height = MH;
const g = c.getContext('2d', { willReadFrequently: true }), a = abdeckung(img), k = MW / B;
g.drawImage(img, a.ox * k, a.oy * k, a.iw * a.s * k, a.ih * a.s * k);
const d = g.getImageData(0, 0, MW, MH).data;
maske = new Float32Array(MW * MH);
for (let i = 0; i < MW * MH; i++) {
const l = (0.2126 * d[i * 4] + 0.7152 * d[i * 4 + 1] + 0.0722 * d[i * 4 + 2]) / 255;
maske[i] = Math.min(1, Math.pow(Math.max(0, (l - .28) / .45), 1.2)); // Schatten fast dunkel, Sonnenfleck hell
}
} catch (e) { maske = null; } // z. B. file:// → gleichmäßig schwach
}
function licht(x, y) {
if (!maske) return .55;
const i = Math.min(MW - 1, Math.max(0, (x / B * MW) | 0)), j = Math.min(MH - 1, Math.max(0, (y / H * MH) | 0));
return maske[j * MW + i];
}
/* ---------- Curl-Noise: Rotation eines glatten Feldes, wirbelt ohne zu klumpen ---------- */
function psi(x, y, t) {
return Math.sin(x * .0042 + t * .11) * Math.cos(y * .0051 - t * .07)
+ .55 * Math.sin((x * .8 + y * .6) * .0093 + t * .17)
+ .3 * Math.cos((-x * .5 + y * .9) * .017 - t * .13);
}
function curl(x, y, t) {
const e = 1.5;
return [(psi(x, y + e, t) - psi(x, y - e, t)) / (2 * e) * 220, -(psi(x + e, y, t) - psi(x - e, y, t)) / (2 * e) * 220];
}
/* ---------- Partikel je Variante ---------- */
let P = [], zeit = 0, naechstesKnistern = 0;
const zz = (a, b) => a + Math.random() * (b - a);
function anzahl() {
const n = V[variante].anzahl * (B * H) / (1440 * 900);
return Math.max(12, Math.round(Math.min(V[variante].anzahl * 1.4, n) * (mobil ? .6 : 1)));
}
function feuer() { // Flammenbereich im Bild (Bild-UV) → Bühne
const img = bilder.glut, a = abdeckung(img), hoch = img.naturalWidth < img.naturalHeight;
const u0 = hoch ? (.36 - .25) / .5 : .36, u1 = hoch ? (.68 - .25) / .5 : .68;
return { x0: a.ox + u0 * a.iw * a.s, x1: a.ox + u1 * a.iw * a.s, y: a.oy + .66 * a.ih * a.s, oben: a.oy + .5 * a.ih * a.s, s: a.s * a.ih / 900 };
}
function glutNeu(alt) {
const f = feuer();
return Object.assign(alt || {}, { x: zz(f.x0, f.x1), y: f.y - zz(0, 18) * f.s, vx: zz(-6, 6), vy: -zz(40, 90) * Math.max(.6, f.s),
leben: 0, dauer: zz(2.4, 4.8), r: zz(1.2, 3), phase: zz(0, 6.28), z: zz(.4, 1) });
}
function aufbau() {
P = [];
const n = anzahl();
if (variante === 'staub') {
for (let i = 0; i < n; i++) {
const z = Math.random();
P.push({ x: zz(0, B), y: zz(0, H), z, r: 1 + z * 3, tempo: 4 + z * 10, phase: zz(0, 6.28), funkel: zz(.4, 1.1), weich: z > .72, dx: 0, dy: 0 });
}
} else if (variante === 'glut') {
for (let i = 0; i < n; i++) { const p = glutNeu(); p.leben = zz(0, p.dauer); p.y += p.vy * p.leben * .6; P.push(p); }
naechstesKnistern = zeit + zz(3, 6);
} else {
/* zwei unscharfe Lichterketten vorn (große Scheiben), dazu ferne Lichter am Horizont */
const ketten = [{ x0: -.04, x1: .62, y0: .07, tief: .16, n: 9, ebene: 2 }, { x0: .48, x1: 1.06, y0: .03, tief: .12, n: 7, ebene: 1 }];
ketten.forEach(k => {
for (let i = 0; i < k.n; i++) {
const t = (i + .5) / k.n, x = (k.x0 + (k.x1 - k.x0) * t) * B;
const y = (k.y0 + k.tief * (1 - Math.pow(2 * t - 1, 2))) * H + zz(-6, 6);
P.push(bokehNeu(x, y, k.ebene, i));
}
});
const fern = Math.max(6, n - P.length);
for (let i = 0; i < fern; i++) P.push(bokehNeu(zz(.05, .95) * B, zz(.48, .66) * H, 0, i));
}
}
function bokehNeu(x, y, ebene, i) {
const R = [[8, 14], [18, 30], [40, 70]][ebene], A = [.42, .28, .2][ebene], PX = [4, 8, 14][ebene];
return { x0: x, y0: y, x, y, ebene, r: zz(R[0], R[1]) * (mobil ? .75 : 1), a: A * zz(.75, 1.15), par: PX, atem: zz(6, 10), phase: zz(0, 6.28),
tempo: zz(3, 8), farbe: (i + ebene) % S.bokeh.length, dx: 0, dy: 0 };
}
/* ---------- Zeiger: leichte Verdrängung (nur mit Maus) ---------- */
const zeiger = { x: -1e4, y: -1e4, da: false, px: 0, py: 0 };
hero.addEventListener('pointermove', e => {
if (e.pointerType === 'touch') return;
if (vorfuehrung) stopVorfuehrung();
const r = hero.getBoundingClientRect(); zeiger.x = e.clientX - r.left; zeiger.y = e.clientY - r.top; zeiger.da = true;
}, { passive: true });
hero.addEventListener('pointerleave', () => { if (!vorfuehrung) zeiger.da = false; });
/* ---------- Schritt und Zeichnen ---------- */
function schritt(dt) {
zeit += dt;
const zx = zeiger.x, zy = zeiger.y, zd = zeiger.da && !mobil;
zeiger.px += ((zd ? (zx / B - .5) : 0) - zeiger.px) * Math.min(1, dt * 2.5);
zeiger.py += ((zd ? (zy / H - .5) : 0) - zeiger.py) * Math.min(1, dt * 2.5);
if (variante === 'staub') {
for (const p of P) {
const [cx, cy] = curl(p.x, p.y, zeit), l = Math.hypot(cx, cy) || 1;
p.x += (cx / l * p.tempo + .8) * dt; p.y += (cy / l * p.tempo + 1.1) * dt; // leicht sinkend, Luftzug nach rechts
if (zd) {
const dx = p.x - zx, dy = p.y - zy, d = Math.hypot(dx, dy);
if (d < 120 && d > .1) { const k = (1 - d / 120) ** 2 * 60 * dt; p.dx += dx / d * k; p.dy += dy / d * k; }
}
p.x += p.dx; p.y += p.dy; p.dx *= Math.pow(.04, dt); p.dy *= Math.pow(.04, dt);
if (p.x < -20) p.x += B + 40; if (p.x > B + 20) p.x -= B + 40;
if (p.y < -20) p.y += H + 40; if (p.y > H + 20) p.y -= H + 40;
}
} else if (variante === 'glut') {
const f = feuer();
if (zeit > naechstesKnistern) { // Holz knackt: kurzer Funkenflug
const x = zz(f.x0 + 40, f.x1 - 40), m = 6 + Math.floor(Math.random() * 7);
for (let i = 0; i < m; i++) { const p = glutNeu(); p.x = x + zz(-24, 24); p.vy *= 1.5; p.vx = zz(-30, 30); p.dauer *= .7; P.push(p); }
naechstesKnistern = zeit + zz(3, 6);
}
const soll = anzahl();
for (let i = P.length - 1; i >= 0; i--) {
const p = P[i];
p.leben += dt;
if (p.leben > p.dauer || p.y < -10) { if (P.length > soll) P.splice(i, 1); else glutNeu(p); continue; }
const [cx] = curl(p.x, p.y, zeit * 1.6);
p.vx += (cx * 30 - p.vx) * Math.min(1, dt * 1.4) + Math.sin(zeit * 7 + p.phase) * 18 * dt;
p.vy *= Math.pow(.93, dt); // Auftrieb lässt nach
p.x += p.vx * dt; p.y += p.vy * dt;
}
} else {
for (const p of P) {
const [cx, cy] = curl(p.x0 + 400, p.y0, zeit * .6), l = Math.hypot(cx, cy) || 1;
p.dx += cx / l * p.tempo * dt * .35; p.dy += cy / l * p.tempo * dt * .2;
p.dx *= Math.pow(.6, dt); p.dy *= Math.pow(.6, dt); // pendelt um die Ruhelage (Wind in der Kette)
p.x = p.x0 + p.dx * 6 + Math.sin(zeit * .35 + p.phase) * (2 + p.ebene * 3) - zeiger.px * p.par * 2;
p.y = p.y0 + p.dy * 6 + Math.cos(zeit * .27 + p.phase) * (1 + p.ebene) - zeiger.py * p.par * 2;
}
}
}
function zeichnen() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, B, H);
ctx.globalCompositeOperation = 'lighter';
if (variante === 'staub') {
for (const p of P) {
const l = licht(p.x, p.y), f = .55 + .45 * Math.sin(zeit * p.funkel + p.phase); // Körner drehen sich im Licht: langsames Aufblitzen
const a = Math.min(1, (.1 + l * .9) * (.4 + .6 * f) * (.55 + p.z * .45));
if (a < .02) continue;
ctx.globalAlpha = p.weich ? a * .6 : a;
const r = p.weich ? p.r * 6.5 : p.r * 3;
ctx.drawImage(p.weich ? S.staubWeich : S.staub, p.x - r, p.y - r, r * 2, r * 2);
}
} else if (variante === 'glut') {
for (const p of P) {
const t = p.leben / p.dauer, stufe = Math.min(GLUT.length - 1, (t * GLUT.length) | 0);
const a = (t < .08 ? t / .08 : 1) * (1 - Math.pow(t, 2.2)) * (.8 + .2 * Math.sin(zeit * 11 + p.phase)) * (.5 + .5 * p.z); // weich flackern, kein Blitzen
if (a < .02) continue;
ctx.globalAlpha = a;
const r = p.r * (4.6 - t * 1.8);
ctx.drawImage(S.glut[stufe], p.x - r, p.y - r, r * 2, r * 2);
}
} else {
ctx.globalCompositeOperation = 'screen';
for (const p of P) {
ctx.globalAlpha = p.a * (.78 + .22 * Math.sin(zeit * 6.283 / p.atem + p.phase));
ctx.drawImage(S.bokeh[p.farbe], p.x - p.r, p.y - p.r, p.r * 2, p.r * 2);
}
}
ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over';
}
/* ---------- Schleife: nur sichtbar, nur Tab sichtbar, nicht pausiert ---------- */
let raf = 0, letzte = 0, imBild = true, angehalten = false;
function soll() { return imBild && !document.hidden && !angehalten && !reduziert(); }
function bild(t) {
raf = 0;
if (!soll()) return;
const dt = Math.min(.05, (t - (letzte || t)) / 1000); letzte = t;
if (vorfuehrung) vorfuehrSchritt(t);
schritt(dt); zeichnen();
raf = requestAnimationFrame(bild);
}
function laufen() {
if (!soll()) { cancelAnimationFrame(raf); raf = 0; return; }
if (!raf) { letzte = 0; raf = requestAnimationFrame(bild); }
}
function standbild() { // reduzierte Bewegung: Partikel als ruhendes Bild, ohne Schleife
cancelAnimationFrame(raf); raf = 0;
for (let i = 0; i < 40; i++) schritt(.05);
zeichnen();
}
new IntersectionObserver(e => { imBild = e[0].isIntersecting; laufen(); }, { threshold: 0 }).observe(hero);
document.addEventListener('visibilitychange', laufen);
function setzeAngehalten(an) {
angehalten = an;
pauseKnopf.setAttribute('aria-pressed', an ? 'true' : 'false');
laufen();
}
pauseKnopf.addEventListener('click', () => { stopVorfuehrung(); setzeAngehalten(!angehalten); });
/* ---------- Variante wechseln: Bild und Ebene weich überblenden ---------- */
function werteZeigen() {
werteEl.innerHTML = reduziert()
? '<span>Reduziert <b>Standbild, keine Schleife</b></span>'
: V[variante].werte.map(([a, b]) => `<span>${a} <b>${b}</b></span>`).join('');
}
function textSetzen() {
const v = V[variante];
document.querySelector('[data-raum]').textContent = v.raum;
document.querySelector('[data-titel]').innerHTML = v.titel;
document.querySelector('[data-unter]').textContent = v.unter;
document.querySelector('[data-notiz]').textContent = v.notiz;
document.querySelector('[data-bildtext]').textContent = 'Bild: ' + v.bild;
}
let wechselTimer = 0;
function setzeVariante(v, sofort) {
variante = v;
html.dataset.v = v;
knoepfe.forEach(b => b.setAttribute('aria-pressed', b.dataset.variante === v ? 'true' : 'false'));
const u = new URL(location.href); u.searchParams.set('v', v); history.replaceState(null, '', u);
Object.entries(bilder).forEach(([k, img]) => { if (k === v) img.loading = 'eager'; img.classList.toggle('an', k === v); });
const texte = document.querySelectorAll('.text .wechsel, .ecke .wechsel');
clearTimeout(wechselTimer);
canvas.classList.remove('an');
const fertig = () => {
textSetzen(); texte.forEach(t => t.classList.remove('raus'));
aufbau(); werteZeigen();
if (reduziert()) standbild(); else { for (let i = 0; i < 30; i++) schritt(1 / 30); zeichnen(); laufen(); }
canvas.classList.add('an');
};
if (sofort || reduziert()) fertig();
else { texte.forEach(t => t.classList.add('raus')); wechselTimer = setTimeout(fertig, 600); }
ansage.textContent = 'Stimmung: ' + V[v].raum + ', ' + NAMEN[v];
}
knoepfe.forEach(b => b.addEventListener('click', () => { stopVorfuehrung(); setzeVariante(b.dataset.variante); }));
/* Buchungsleiste: glaubwürdige Antwort statt Leerlauf */
const antwort = $('[data-antwort]');
$('[data-buchung]').addEventListener('submit', e => {
e.preventDefault();
const an = new Date($('#an').value), ab = new Date($('#ab').value), n = Math.round((ab - an) / 864e5);
const fmt = new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long' });
antwort.textContent = !(n > 0) ? 'Bitte eine Abreise nach der Anreise wählen.'
: `${fmt.format(an)} bis ${fmt.format(ab)}: noch 3 Zimmer frei, ab ${138 * n} € für ${n} ${n === 1 ? 'Nacht' : 'Nächte'} inkl. Frühstück, Kurtaxe extra.`;
antwort.classList.add('da');
});
/* ---------- Vorführung: gedachter Zeiger zieht durch die Luft ---------- */
let vorfuehrung = false, vStart = 0, vRunde = -1;
function vorfuehrSchritt(t) {
const s = (t - vStart) / 1000;
zeiger.da = true;
zeiger.x = B * (.52 + .3 * Math.sin(s * .5));
zeiger.y = H * (.42 + .18 * Math.sin(s * .9 + .7));
const runde = Math.floor(s / 4.2);
if (variante === 'glut' && runde !== vRunde) { vRunde = runde; naechstesKnistern = 0; } // Funkenflug in jeder Runde
}
function stopVorfuehrung() {
if (!vorfuehrung) return;
vorfuehrung = false; html.classList.remove('vorfuehrung'); zeiger.da = false;
}
function neustart() {
stopVorfuehrung();
setzeAngehalten(false);
setzeVariante(variante, true);
}
function vorfuehren() {
neustart();
vorfuehrung = true; vStart = performance.now(); vRunde = -1; html.classList.add('vorfuehrung');
naechstesKnistern = zeit + .8;
laufen();
}
function pause() { stopVorfuehrung(); setzeAngehalten(true); }
function weiter() { setzeAngehalten(false); }
new MutationObserver(() => { werteZeigen(); if (reduziert()) standbild(); else laufen(); }).observe(html, { attributes: true, attributeFilter: ['class'] });
let rz = 0;
addEventListener('resize', () => { clearTimeout(rz); rz = setTimeout(() => { groesse(); aufbau(); if (reduziert()) standbild(); }, 120); });
bilder.staub.addEventListener('load', lichtmaske);
Object.values(bilder).forEach(img => img.addEventListener('load', () => { if (variante === 'glut' && img === bilder.glut) aufbau(); }));
groesse();
setzeVariante(variante, true);
})();
</script>
</body>
</html>