WebGL-Fluid- und Formspur am Cursor
Der Zeiger als Zuluft: Eine Stable-Fluids-Simulation strömt durch die Produktansicht einer Lüftungs-Software, wahlweise als Rauch, als Vektorpfeile oder als verzerrter Grundriss.
Produkt-Hero einer technischen Software (Lüftung, Simulation, Daten), in dem der Besucher die Strömung selbst anstößt.
Ein Canvas liegt mit pointer-events: none über oder hinter dem Inhalt. Die Zeigerbewegung wird als Kraft in eine Simulation geschrieben: eine Fluid-Simulation, eine Ping-Pong-Textur mit Abklingen oder Partikel. Ein Shader macht daraus die sichtbare Spur, etwa flüssige Farbe, eine Verzerrung des Hintergrunds, Lichtbrechung oder eine Spur in eigener Form statt eines Kreises. Ohne neue Eingabe klingt die Spur ab.
Jede Bewegung hinterlässt eine einmalige Spur, die sich physikalisch glaubwürdig verhält. Das Feedback ist unmittelbar und wirkt handwerklich und materiell. Mit WebGPU und TSL wird der Effekt günstiger und bleibt 2026 in Kreativ-Portfolios präsent.
Wann einsetzen
Portfolios von Kreativ-Entwicklern, Tech- und 3D-Studios, Kunst und Kultur, Beauty und Getränke, Lab-Seiten, Hero-Hintergründe. Auf Produktseiten nur, wenn die Strömung selbst zum Produkt gehört, etwa bei Simulation oder Datenvisualisierung.
Wann nicht
Auf Inhaltsseiten und hinter Fließtext. Auf schwachen Geräten. Wenn niemand im Team Shader warten kann. Nicht als Dauer-Effekt auf Business-Seiten.
Varianten
- Rauch (Dye): Stable Fluids auf der GPU, der Zeiger stößt die Strömung an
- Vektoren: die Spur in eigener Form, ein Pfeil je Rasterzelle
- Verzerrung: ein unsichtbares Geschwindigkeitsfeld verschiebt Bild, Plan oder Typo
- Autonom (Nebel): Quellen am Rand blasen nach Zeitplan, ohne Zeiger; für Touch und ruhige Hero-Flächen
- Pixel- oder Dithering-Spur
- WebGPU mit Compute-Shadern: Partikelfluid oder musikreaktives Goo
Umsetzung
Grundform: zwei Render-Targets im Ping-Pong. Pro Bild wird die vorige Textur mit 0,96 multipliziert (Abklingen), dazu kommen neue Stempel entlang der Strecke vom letzten zum aktuellen Zeigerpunkt. Die Strecke interpolieren oder als Kapsel stempeln, sonst entstehen bei schneller Bewegung Lücken. Das Ergebnis dient im finalen Shader als Farbe, Maske oder Displacement.
Echte Fluid-Simulation (Stable Fluids): Advektion, Kraft, Divergenz, Druck über Jacobi-Iterationen, Projektion. Beobachtete Werte bei Lando Norris: dissipation 0,96, 4 Poisson-Iterationen, dt 0,014, mouse_force 50, cursor_size 18, Auflösung 0,1 × Viewport, BFECC. Bei Unseen Studio: Auflösung 256, force 50, 2 Iterationen, mouseRadius 0,008. Die Geschwindigkeit in niedriger Auflösung rechnen (128 bis 256), Farbe (Dye) feiner. Half-Float-Texturen verwenden und auf iOS prüfen.
Fallstricke: Pixeldichte begrenzen. Die Simulation bei Inaktivität, im Hintergrund-Tab und außerhalb des Viewports pausieren. WebGPU nur mit WebGL-Fallback, ohne WebGL ein statisches Bild oder einen Verlauf zeigen.
Robust umsetzen
Telefon. Touch-Ziehen zeichnet die Spur, Scrollen muss möglich bleiben. Ohne Eingabe eine ruhige Leerlauf-Bewegung oder ein statisches Bild. Niedrigere Simulationsauflösung, bei schwacher GPU abschalten.
Ohne Bewegung. Abschalten und durch ein statisches Bild ersetzen. Höchstens bei aktiver Eingabe ohne Nachlauf zeigen.
Barrierefrei. Canvas mit aria-hidden. Die Spur darf den Kontrast von Text nicht verschlechtern, deshalb nicht hinter Fließtext legen. Keine Information nur über den Effekt vermitteln.
Code
<!DOCTYPE html>
<!--
WebGL-Fluid- und Formspur am Cursor
Fundus · Cursor · https://cecodesign.de/lab/fundus/cursor/webgl-fluid-cursor/
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>WebGL-Fluid- und Formspur am Cursor · 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: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; 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 {
--grund: #08090a; --panel: #111214; --panel-2: #161718; --sicht: #0c0d0f;
--text: #f7f8f8; --text-2: #d0d6e0; --leise: #8a8f98; --linie: #242728;
--marke: #5fb4ff; /* der eine Markenton */
--ui: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* easeOutQuad wie im Rezept, 160 ms */
--ui-dauer: 160ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Geist', system-ui, sans-serif; font-weight: 400; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; overflow-x: clip; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.zahl { font-family: 'Geist Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; border-radius: 6px; }
/* Die Simulation füllt nur die Produktansicht (kein Effekt hinter Fließtext) und nimmt keine Klicks */
.gl { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; display: block; background: var(--sicht); }
.kein-gl .gl, .reduziert .gl { display: none; }
.seite { position: relative; z-index: 1; min-height: 100svh; height: 100svh; display: flex; flex-direction: column; width: min(1200px, 100% - 64px); margin-inline: auto; }
/* Kopfzeile */
.nav { height: 64px; flex: none; display: flex; align-items: center; gap: 36px; }
.marke { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; letter-spacing: -.012em; }
.marke svg { width: 22px; height: 22px; }
.nav ul { display: flex; gap: 26px; list-style: none; font-size: 14px; color: var(--leise); }
.nav ul a { transition: color var(--ui-dauer) var(--ui); }
.nav ul a:hover { color: var(--text); }
.nav .rechts { margin-left: auto; display: flex; align-items: center; gap: 18px; font-size: 14px; }
.nav .rechts > a:first-child { color: var(--leise); }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 40px; padding: 0 16px; border-radius: 8px; font-size: 14px; font-weight: 500; white-space: nowrap; transition: background-color var(--ui-dauer) var(--ui), border-color var(--ui-dauer) var(--ui), color var(--ui-dauer) var(--ui); }
.knopf.klein { height: 32px; padding: 0 12px; font-size: 13px; }
.knopf.primaer { background: var(--marke); color: #06121d; }
.knopf.primaer:hover { background: #86c6ff; }
.knopf.zweit { border: 1px solid var(--linie); background: rgba(17, 18, 20, .72); color: var(--text-2); }
.knopf.zweit:hover { border-color: #3a3e44; color: var(--text); }
/* Hero */
.hero { flex: none; display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr); align-items: end; gap: 24px 56px; padding: clamp(28px, 6vh, 72px) 0 clamp(28px, 5vh, 52px); }
.pille { grid-column: 1 / -1; justify-self: start; display: inline-flex; align-items: center; gap: 10px; height: 30px; padding: 0 12px 0 4px; border: 1px solid var(--linie); border-radius: 999px; background: rgba(17, 18, 20, .72); font-size: 13px; color: var(--text-2); }
.pille b { font: 500 10.5px/1 'Geist Mono', monospace; letter-spacing: .1em; text-transform: uppercase; color: #06121d; background: var(--marke); border-radius: 999px; padding: 5px 8px; }
.pille span:last-child { color: var(--leise); }
h1 { font-size: clamp(2.5rem, 1.6rem + 3.6vw, 4.5rem); font-weight: 560; letter-spacing: -.022em; line-height: 1; }
.zusatz { display: grid; gap: 22px; padding-bottom: 6px; }
.zusatz p { font-size: 1.0625rem; line-height: 1.6; letter-spacing: -.006em; color: var(--text-2); max-width: 46ch; }
.ctas { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; }
.ctas .mono { margin-left: 6px; letter-spacing: .08em; text-transform: none; font-size: 12px; }
/* Produkt-Ausschnitt: der Hero zeigt die echte App */
.app { flex: 1; min-height: 320px; display: grid; grid-template-rows: auto 1fr; border: 1px solid var(--linie); border-bottom: 0; border-radius: 12px 12px 0 0; overflow: hidden; box-shadow: 0 -1px 0 rgba(255, 255, 255, .04) inset, 0 30px 80px rgba(0, 0, 0, .5); }
.leiste { display: flex; align-items: center; gap: 16px; height: 48px; padding: 0 12px 0 16px; background: var(--panel); border-bottom: 1px solid var(--linie); font-size: 13px; }
.pfad { display: flex; align-items: center; gap: 8px; color: var(--leise); white-space: nowrap; min-width: 0; }
.pfad b { font-weight: 500; color: var(--text); }
.pfad i { font-style: normal; color: #3a3e44; }
.pfad i::before { content: '/'; } /* Trenner rein dekorativ: per CSS, nicht als Text */
.pfad svg { width: 16px; height: 16px; flex: none; color: var(--leise); }
.segment { position: relative; margin-left: auto; display: flex; padding: 3px; border: 1px solid var(--linie); border-radius: 8px; background: var(--grund); }
.segment button { position: relative; z-index: 1; height: 26px; padding: 0 12px; border-radius: 5px; font-size: 12.5px; color: var(--leise); transition: color var(--ui-dauer) var(--ui); }
.segment button[aria-pressed='true'] { color: var(--text); }
.segment button:hover { color: var(--text-2); }
.segment .zeigerfeld { position: absolute; z-index: 0; top: 3px; bottom: 3px; left: 0; width: 0; border-radius: 5px; background: var(--panel-2); box-shadow: 0 0 0 1px #2c2f33 inset; transition: transform 180ms var(--ui), width 180ms var(--ui); }
.status { display: inline-flex; align-items: center; gap: 8px; height: 26px; padding: 0 10px; border-radius: 6px; background: var(--panel-2); font-size: 12px; color: var(--text-2); white-space: nowrap; }
.status::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: var(--marke); box-shadow: 0 0 0 3px rgba(95, 180, 255, .18); }
.koerper { display: grid; grid-template-columns: 232px 1fr; min-height: 0; }
.seitenleiste { background: var(--panel); border-right: 1px solid var(--linie); padding: 16px 12px; display: flex; flex-direction: column; gap: 18px; overflow: hidden; font-size: 13px; }
.seitenleiste h2 { font: 500 10.5px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); padding: 0 8px 8px; }
.seitenleiste ul { list-style: none; display: grid; gap: 1px; }
.seitenleiste li { display: flex; justify-content: space-between; gap: 10px; align-items: center; height: 30px; padding: 0 8px; border-radius: 6px; color: var(--text-2); white-space: nowrap; }
.seitenleiste li.an { background: var(--panel-2); color: var(--text); }
.seitenleiste li span:last-child { font: 400 12px/1 'Geist Mono', monospace; font-variant-numeric: tabular-nums; color: var(--leise); }
.seitenleiste li .punkt { display: inline-block; width: 7px; height: 7px; border-radius: 2px; margin-right: 8px; background: #3a3e44; }
.seitenleiste li .punkt.z { background: var(--marke); }
.seitenleiste .fuss { margin-top: auto; padding: 0 8px; font-size: 12px; color: var(--leise); }
/* Die Sicht ist ein Loch: dahinter rendert WebGL Grundriss und Strömung */
.sicht { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
.sicht canvas.statisch { position: absolute; inset: 0; width: 100%; height: 100%; display: none; background: var(--sicht); }
.kein-gl .sicht canvas.statisch, .reduziert .sicht canvas.statisch { display: block; }
.hud { position: absolute; display: grid; gap: 6px; padding: 10px 12px; border: 1px solid var(--linie); border-radius: 8px; background: rgba(12, 13, 15, .78); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); font-size: 12px; color: var(--text-2); }
.sonde { top: 14px; left: 14px; min-width: 176px; }
.sonde dl { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; }
.sonde dt { color: var(--leise); }
.sonde dd { text-align: right; color: var(--text); }
.legende { top: 14px; right: 14px; width: 172px; }
.legende .balken { height: 6px; border-radius: 3px; background: linear-gradient(90deg, #0c0d0f, #1c3a57 30%, var(--marke) 70%, #e6f3ff); }
.legende .skala { display: flex; justify-content: space-between; color: var(--leise); }
.hinweis { position: absolute; left: 16px; bottom: 14px; display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--leise); }
.hinweis kbd { font: 500 10.5px/1 'Geist Mono', monospace; letter-spacing: .08em; padding: 4px 6px; border: 1px solid var(--linie); border-radius: 4px; color: var(--text-2); background: var(--panel); }
.hinweis .touch, .hinweis .auto { display: none; }
[data-modus-an="autonom"] .hinweis .maus, [data-modus-an="autonom"] .hinweis .touch { display: none !important; }
[data-modus-an="autonom"] .hinweis .auto { display: inline; }
/* Bewegung anhalten (WCAG 2.2.2): friert die Strömung ein, gleiche Pause wie FUNDUS_DEMO.pause() */
.halt { position: absolute; right: 14px; bottom: 12px; height: 28px; padding: 0 11px; border: 1px solid var(--linie); border-radius: 8px; background: rgba(12, 13, 15, .78); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); font-size: 12px; color: var(--text-2); transition: color var(--ui-dauer) var(--ui), border-color var(--ui-dauer) var(--ui); }
.halt:hover, .halt[aria-pressed='true'] { color: var(--text); border-color: #3a3e44; }
.vorschau .halt, .reduziert .halt, .kein-gl .halt { display: none; }
/* Werte dezent unten rechts */
.werte { flex: none; display: flex; justify-content: space-between; gap: 8px 24px; flex-wrap: wrap; padding: 0 2px 10px; font-size: 10.5px; letter-spacing: .1em; color: var(--leise); }
.werte b { font-weight: 500; color: var(--text-2); }
/* gedachter Zeiger der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 6; width: 18px; height: 18px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
[data-modus-an="autonom"] .zeiger { display: none !important; }
/* Einblendung: nur Deckkraft und 6 px */
.js .ein { opacity: 0; translate: 0 6px; transition: opacity 500ms var(--ui), translate 500ms var(--ui); }
.js .ein.da { opacity: 1; translate: none; }
.reduziert .ein { opacity: 1; translate: none; transition: none; }
.reduziert .segment .zeigerfeld, .reduziert * { transition-duration: .01ms !important; }
/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .werte, .vorschau .nav ul, .vorschau .nav .rechts > a:first-child, .vorschau .ctas .mono, .vorschau .pille, .vorschau .zusatz p { display: none; }
.vorschau .hero { padding: 40px 0 32px; align-items: end; }
.vorschau .zusatz { padding-bottom: 4px; justify-self: end; }
@media (max-width: 1100px) {
.nav ul { display: none; }
.koerper { grid-template-columns: 196px 1fr; }
.legende { display: none; }
.halt { top: 14px; bottom: auto; } /* Platz der Legende, unten steht der Hinweis */
}
@media (max-width: 760px) {
.seite { height: auto; width: calc(100% - 32px); }
.hero { grid-template-columns: 1fr; padding: 28px 0 32px; gap: 20px; }
.nav { gap: 16px; }
.nav .rechts > a:first-child { display: none; }
.app { flex: none; height: 540px; border-radius: 12px; border-bottom: 1px solid var(--linie); margin-bottom: 20px; }
.leiste { height: auto; flex-wrap: wrap; padding: 10px 10px 10px 14px; gap: 10px; }
.pfad .lang, .status { display: none; }
.segment { margin-left: 0; width: 100%; }
.segment button { flex: 1; min-width: 0; padding: 0 4px; }
.koerper { grid-template-columns: 1fr; }
.seitenleiste { display: none; }
.sonde { top: 10px; left: 10px; min-width: 150px; padding: 8px 10px; }
.werte { order: 9; padding: 0 0 28px; }
}
/* Touch: Finger ziehen zeichnet die Spur, sonst bläst die Zuluft von selbst */
@media (hover: none) {
.hinweis .maus { display: none; }
.hinweis .touch { display: inline; }
}
@media (max-width: 380px) { .segment button { font-size: 11.5px; padding: 0 2px; } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<div class="seite">
<header class="nav ein">
<a class="marke" href="#start" aria-label="Nerostrom Startseite">
<svg viewBox="0 0 22 22" aria-hidden="true"><rect x=".5" y=".5" width="21" height="21" rx="6" fill="#161718" stroke="#2c2f33"/><path d="M5 8.2c2.2-1.6 4.1-1.6 6 0s3.8 1.6 6 0M5 12c2.2-1.6 4.1-1.6 6 0s3.8 1.6 6 0" fill="none" stroke="#5fb4ff" stroke-width="1.6" stroke-linecap="round"/><path d="M5 15.8c2.2-1.6 4.1-1.6 6 0s3.8 1.6 6 0" fill="none" stroke="#8a8f98" stroke-width="1.6" stroke-linecap="round"/></svg>
Nerostrom
</a>
<ul>
<li><a href="#produkt">Produkt</a></li>
<li><a href="#anwendungen">Anwendungen</a></li>
<li><a href="#preise">Preise</a></li>
<li><a href="#changelog">Changelog</a></li>
<li><a href="#doku">Dokumentation</a></li>
</ul>
<div class="rechts">
<a href="#anmelden">Anmelden</a>
<a class="knopf klein primaer" href="#testen">Kostenlos testen</a>
</div>
</header>
<section class="hero" id="start">
<a class="pille ein" href="#changelog"><b>Neu</b><span>Version 4.2: Thermik-Löser für Atrien</span><span aria-hidden="true">→</span></a>
<h1 class="ein">Luft sehen,<br> bevor sie strömt.</h1>
<div class="zusatz ein">
<p>Nerostrom simuliert Lüftung, Zugluft und Wärme direkt im Browser. IFC hochladen, Auslässe setzen, Ergebnis in Minuten. Für TGA-Planung, Architektur und Facility-Teams.</p>
<div class="ctas">
<a class="knopf primaer" href="#testen">Kostenlos testen</a>
<a class="knopf zweit" href="#demo">Demo buchen</a>
<span class="mono">Nach DIN EN 16798 · IFC 2x3 und 4</span>
</div>
</div>
</section>
<p class="werte mono ein" data-werte aria-hidden="true"></p>
<div class="app ein" role="region" aria-label="Produktansicht: Strömungssimulation im Gruppenraum einer Kita (Beispiel)">
<div class="leiste">
<div class="pfad">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 13.5v-8l5.5-3 5.5 3v8h-4v-4h-3v4z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
<span class="lang">Kita Biebrich</span><i class="lang" aria-hidden="true"></i><span class="lang">EG</span><i class="lang" aria-hidden="true"></i><b>2.04 Gruppenraum</b>
</div>
<div class="segment" role="group" aria-label="Darstellung der Strömung" data-segment>
<span class="zeigerfeld" data-zeigerfeld aria-hidden="true"></span>
<button type="button" data-modus="rauch" aria-pressed="true">Rauch</button>
<button type="button" data-modus="vektoren" aria-pressed="false">Vektoren</button>
<button type="button" data-modus="verzerrung" aria-pressed="false">Verzerrung</button>
<button type="button" data-modus="autonom" aria-pressed="false">Nebelprobe</button>
</div>
<span class="status zahl" data-status>Live</span>
</div>
<div class="koerper">
<aside class="seitenleiste" aria-label="Szene">
<div>
<h2>Szene</h2>
<ul>
<li><span>Geometrie</span><span>IFC 4</span></li>
<li class="an"><span><i class="punkt z"></i>Zuluft Z1</span><span>0,35 m/s</span></li>
<li><span><i class="punkt"></i>Abluft A1</span><span>120 m³/h</span></li>
<li><span><i class="punkt"></i>Fenster F1–F3</span><span>gekippt</span></li>
<li><span><i class="punkt"></i>Heizkörper H1</span><span>55 °C</span></li>
</ul>
</div>
<div>
<h2>Auswertung</h2>
<ul>
<li><span>Zugluftrisiko DR</span><span>12 %</span></li>
<li><span>Mittleres Luftalter</span><span>9,4 min</span></li>
<li><span>CO₂ nach 45 min</span><span>980 ppm</span></li>
</ul>
</div>
<p class="fuss">Berechnet 14:32 · 38 s Rechenzeit</p>
</aside>
<div class="sicht" data-sicht>
<canvas class="gl" data-gl aria-hidden="true"></canvas>
<canvas class="statisch" data-statisch aria-hidden="true"></canvas>
<div class="hud sonde zahl" aria-hidden="true">
<span class="mono" style="font-size:10px" data-sonde-ort>Sonde S1 · am Zeiger</span>
<dl>
<dt>v</dt><dd data-v>0,00 m/s</dd>
<dt>Richtung</dt><dd data-r>–</dd>
<dt>q</dt><dd data-q>0,00 Pa</dd>
</dl>
</div>
<div class="hud legende zahl" aria-hidden="true">
<span class="mono" style="font-size:10px">Geschwindigkeit</span>
<div class="balken"></div>
<div class="skala"><span>0</span><span>0,8</span><span>1,6 m/s</span></div>
</div>
<button type="button" class="halt" data-halt aria-pressed="false">Bewegung anhalten</button>
<p class="hinweis"><span class="maus"><kbd>Maus</kbd> bewegen: Ihr Zeiger ist die Zuluft.</span><span class="touch"><kbd>Finger</kbd> ziehen: Sie sind die Zuluft.</span><span class="auto">Nebelprobe ohne Zeiger: Zuluft Z1 und Fenster nach Zeitplan.</span></p>
</div>
</div>
</div>
</div>
<svg class="zeiger" data-zeiger viewBox="0 0 18 18" aria-hidden="true"><path d="M2 1.5 15.5 9.2l-6 1.3-2.9 5.6Z" fill="#f7f8f8" stroke="#08090a" stroke-width="1.2" stroke-linejoin="round"/></svg>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.0/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const canvas = $('[data-gl]'), sicht = $('[data-sicht]'), statisch = $('[data-statisch]'), zeiger = $('[data-zeiger]');
const segment = $('[data-segment]'), zeigerfeld = $('[data-zeigerfeld]'), knoepfe = [...segment.querySelectorAll('button')];
const anzeige = { v: $('[data-v]'), r: $('[data-r]'), q: $('[data-q]'), status: $('[data-status]'), werte: $('[data-werte]') };
const beruehrung = matchMedia('(hover: none)').matches;
/* ---------- Werte: Stable Fluids nach den Optionen von landonorris.com (OFF+BRAND) ---------- */
const W = {
dissipation: 0.96, // Geschwindigkeit × 0,96 je 60-Hz-Bild
poisson: 4, // Jacobi-Iterationen für den Druck
dt: 0.014, // Zeitschritt je 60-Hz-Bild
kraft: 50, // mouse_force
cursor: 18, // cursor_size in Simulationszellen
aufloesung: 0.1, // Simulation = 0,1 × Viewport
minZellen: 64, // mindestens 64 Zellen an der kurzen Seite (Telefon)
bfecc: true, // Advektion mit Fehlerkorrektur
dpr: 1.5, // Pixeldichte des Ausgabe-Canvas begrenzt
rauchAufl: 0.25, // Rauch (Dye) feiner als die Strömung: 0,25 × Sicht
rauchAbkl: 0.985, // Rauch × 0,985 je 60-Hz-Bild (Halbwertszeit ≈ 0,77 s)
rauchRadius: 0.5, // Rauchstempel = 0,5 × cursor_size
kraftMax: 4, // Kraftstoß begrenzt (Sprünge des Zeigers)
verzerrung: 0.045, // Versatz des Grundrisses je Geschwindigkeitseinheit (uv)
zelle: 22, // Raster der Vektor-Pfeile in CSS-px
ruhe: 4000, // ms ohne Eingabe, dann pausiert die Simulation (nicht in der Nebelprobe)
// Nebelprobe (autonom): Quellen nach Zeitplan statt Zeiger
nebelAbkl: 0.988, // Nebel × 0,988 je 60-Hz-Bild (Halbwertszeit ≈ 1 s)
strahl: 0.55, // Kraft des Zuluftstrahls Z1 je Bild
strahlFaecher: 35, // Grad: Strahl schwenkt ± 35° um die Raummitte
strahlTakt: 4800, // ms je Stoß (Shooting)
strahlMenge: 0.07, // Nebel je Bild am Auslass
boe: 0.28, // Kraft einer Fensterböe je Bild (Mist/Fog: weich und breit)
boeTakt: 3200, // ms: F1, F2, F3 reihum
boeDauer: 1400, // ms je Böe
boeMenge: 0.05, // Nebel je Bild an der Böe
boeRadius: 2.2, // × cursor_size
misch: 200, // ms Überblendung beim Wechsel der Darstellung
};
const MODI = ['rauch', 'vektoren', 'verzerrung', 'autonom'];
const FOLGE = ['rauch', 'vektoren', 'verzerrung']; // Vorführung ohne ?v=
const startModus = (v => MODI.includes(v) ? v : 'rauch')(new URLSearchParams(location.search).get('v'));
let modus = startModus, modusAlt = startModus, mischStart = -1e9;
/* ---------- Grundriss: Gruppenraum 2.04, 7,80 × 6,20 m (2D-Canvas, Textur und statisches Bild) ---------- */
const RAUM = { b: 7.8, h: 6.2 };
const Z1 = { x: 1.9, y: 4.5 }, A1 = { x: 6.7, y: 1.1 };
function einpassen(w, h) {
const pad = Math.max(30, Math.min(64, Math.min(w, h) * 0.11));
const oben = w < 520 ? 118 : pad; // Platz für die Sonde auf schmalen Sichten
const s = Math.min((w - 2 * pad) / RAUM.b, (h - oben - pad - 18) / RAUM.h);
return { s, ox: (w - RAUM.b * s) / 2, oy: oben + (h - oben - pad - 18 - RAUM.h * s) / 2 };
}
function feld(x, y) { // statisches Ersatzfeld: Quelle an Z1, Senke an A1, leichter Umlauf
let vx = 0, vy = 0;
for (const [q, k] of [[Z1, 1], [A1, -1.15]]) {
const dx = x - q.x, dy = y - q.y, r2 = dx * dx + dy * dy + 0.25;
vx += k * dx / r2; vy += k * dy / r2;
}
const cx = x - 3.9, cy = y - 3.1;
vx += -cy * 0.08; vy += cx * 0.08;
return [vx, vy];
}
function zeichnePlan(c, w, h, dpr, art) {
c.width = Math.max(2, Math.round(w * dpr)); c.height = Math.max(2, Math.round(h * dpr));
const g = c.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.fillStyle = '#0c0d0f'; g.fillRect(0, 0, w, h);
const { s, ox, oy } = einpassen(w, h);
const P = (x, y) => [ox + x * s, oy + y * s];
const linie = 'rgba(208,214,224,.26)', leise = 'rgba(208,214,224,.14)';
g.lineCap = 'round'; g.lineJoin = 'round';
// Bodenfelder
g.fillStyle = 'rgba(208,214,224,.025)';
g.beginPath(); g.roundRect(...P(3.0, 3.6), 2.6 * s, 1.9 * s, 0.5 * s); g.fill();
g.setLineDash([3, 4]); g.strokeStyle = leise; g.lineWidth = 1; g.stroke(); g.setLineDash([]);
// Wände mit Tür unten links und drei Fenstern oben
g.strokeStyle = 'rgba(208,214,224,.42)'; g.lineWidth = Math.max(3, s * 0.12);
g.beginPath();
g.moveTo(...P(1.6, RAUM.h)); g.lineTo(...P(RAUM.b, RAUM.h)); g.lineTo(...P(RAUM.b, 0));
g.lineTo(...P(0, 0)); g.lineTo(...P(0, RAUM.h)); g.lineTo(...P(0.6, RAUM.h));
g.stroke();
g.strokeStyle = '#0c0d0f'; g.lineWidth = Math.max(3, s * 0.12) + 1;
for (const fx of [0.9, 3.2, 5.5]) { g.beginPath(); g.moveTo(...P(fx, 0)); g.lineTo(...P(fx + 1.4, 0)); g.stroke(); }
g.strokeStyle = linie; g.lineWidth = 1;
for (const fx of [0.9, 3.2, 5.5]) for (const d of [-0.05, 0.05]) { g.beginPath(); g.moveTo(...P(fx, d)); g.lineTo(...P(fx + 1.4, d)); g.stroke(); }
// Tür: Blatt und Schwenkbogen
g.beginPath(); g.moveTo(...P(0.6, RAUM.h)); g.lineTo(...P(0.6, RAUM.h - 1.0)); g.stroke();
g.setLineDash([2, 3]); g.beginPath(); g.arc(...P(0.6, RAUM.h), 1.0 * s, -Math.PI / 2, 0); g.stroke(); g.setLineDash([]);
// Möbel: Regal, zwei Rundtische mit Stühlen, Bank am Fenster, Heizkörper
g.strokeStyle = linie;
g.strokeRect(...P(0.15, 1.2), 0.45 * s, 2.6 * s);
for (let i = 1; i < 4; i++) { g.beginPath(); g.moveTo(...P(0.15, 1.2 + i * 0.65)); g.lineTo(...P(0.6, 1.2 + i * 0.65)); g.stroke(); }
for (const t of [{ x: 2.7, y: 2.2 }, { x: 5.2, y: 2.6 }]) {
g.beginPath(); g.arc(...P(t.x, t.y), 0.62 * s, 0, Math.PI * 2); g.stroke();
for (let k = 0; k < 5; k++) {
const a = k / 5 * Math.PI * 2 + 0.3;
g.beginPath(); g.arc(...P(t.x + Math.cos(a) * 0.95, t.y + Math.sin(a) * 0.95), 0.17 * s, 0, Math.PI * 2); g.stroke();
}
}
g.strokeRect(...P(5.6, 5.55), 2.0 * s, 0.45 * s);
g.strokeStyle = leise; g.strokeRect(...P(3.3, 0.18), 1.0 * s, 0.14 * s);
for (let i = 1; i < 6; i++) { g.beginPath(); g.moveTo(...P(3.3 + i / 6, 0.18)); g.lineTo(...P(3.3 + i / 6, 0.32)); g.stroke(); }
// Maßketten
g.strokeStyle = leise; g.fillStyle = 'rgba(138,143,152,.9)';
g.font = `500 ${Math.max(9.5, Math.min(11, s * 0.16))}px "Geist Mono", ui-monospace, monospace`;
const [ax, ay] = P(0, -0.42), [bx] = P(RAUM.b, 0);
g.beginPath(); g.moveTo(ax, ay); g.lineTo(bx, ay); g.moveTo(ax, ay - 4); g.lineTo(ax, ay + 4); g.moveTo(bx, ay - 4); g.lineTo(bx, ay + 4); g.stroke();
g.textAlign = 'center'; g.textBaseline = 'bottom'; g.fillText('7,80', (ax + bx) / 2, ay - 4);
const [rx, ry0] = P(RAUM.b + 0.42, 0), [, ry1] = P(0, RAUM.h);
g.beginPath(); g.moveTo(rx, ry0); g.lineTo(rx, ry1); g.moveTo(rx - 4, ry0); g.lineTo(rx + 4, ry0); g.moveTo(rx - 4, ry1); g.lineTo(rx + 4, ry1); g.stroke();
g.save(); g.translate(rx + 4, (ry0 + ry1) / 2); g.rotate(Math.PI / 2); g.textBaseline = 'bottom'; g.fillText('6,20', 0, 0); g.restore();
// Raumstempel
g.textAlign = 'left'; g.textBaseline = 'alphabetic';
const [lx, ly] = P(3.25, 4.45);
g.fillStyle = 'rgba(247,248,248,.78)'; g.font = `500 ${Math.max(11, Math.min(14, s * 0.2))}px Geist, system-ui, sans-serif`;
g.fillText('2.04 Gruppenraum', lx, ly);
g.fillStyle = 'rgba(138,143,152,.95)'; g.font = `400 ${Math.max(9.5, Math.min(11, s * 0.15))}px "Geist Mono", ui-monospace, monospace`;
g.fillText('48,4 m² · LH 2,90 m', lx, ly + Math.max(15, s * 0.26));
// Statisches Ergebnis (reduzierte Bewegung, ohne WebGL)
if (art === 'rauch' || art === 'verzerrung') stromlinien(g, P, s, art === 'rauch' ? 1 : 0.45);
if (art === 'vektoren') pfeilfeld(g, s, ox, oy);
if (art === 'autonom') nebelPoster(g, P, s);
// Luftauslässe im Markenton
const auslass = (q, kreis, text) => {
const [x, y] = P(q.x, q.y), r = Math.max(7, s * 0.2);
g.fillStyle = '#0c0d0f'; g.strokeStyle = '#5fb4ff'; g.lineWidth = 1.4;
g.beginPath(); g.rect(x - r, y - r, 2 * r, 2 * r); g.fill(); g.stroke();
g.beginPath();
if (kreis) g.arc(x, y, r * 0.55, 0, Math.PI * 2); else { g.moveTo(x - r, y - r); g.lineTo(x + r, y + r); g.moveTo(x + r, y - r); g.lineTo(x - r, y + r); }
g.stroke();
g.fillStyle = 'rgba(95,180,255,.95)'; g.font = `500 ${Math.max(9.5, Math.min(11, s * 0.15))}px "Geist Mono", ui-monospace, monospace`;
g.fillText(text, x + r + 6, y + 4);
};
auslass(Z1, false, 'Z1');
g.textAlign = 'right'; auslass(A1, true, ''); g.fillText('A1', P(A1.x, A1.y)[0] - Math.max(7, s * 0.2) - 6, P(A1.x, A1.y)[1] + 4);
}
function stromlinien(g, P, s, staerke) {
g.lineWidth = 1.25;
for (let i = 0; i < 18; i++) {
const a = i / 18 * Math.PI * 2;
let x = Z1.x + Math.cos(a) * 0.32, y = Z1.y + Math.sin(a) * 0.32;
const pts = [];
for (let n = 0; n < 900; n++) {
const [vx, vy] = feld(x, y), l = Math.hypot(vx, vy) || 1;
x += vx / l * 0.03; y += vy / l * 0.03;
if (x < 0.1 || y < 0.1 || x > RAUM.b - 0.1 || y > RAUM.h - 0.1 || Math.hypot(x - A1.x, y - A1.y) < 0.3) break;
pts.push(P(x, y));
}
if (pts.length < 6) continue;
const grad = g.createLinearGradient(...pts[0], ...pts[pts.length - 1]);
grad.addColorStop(0, `rgba(95,180,255,${0.75 * staerke})`); grad.addColorStop(1, `rgba(95,180,255,${0.12 * staerke})`);
g.strokeStyle = grad; g.beginPath(); g.moveTo(...pts[0]); pts.forEach(p => g.lineTo(...p)); g.stroke();
const m = pts[Math.floor(pts.length * 0.4)], m2 = pts[Math.floor(pts.length * 0.4) + 2];
if (m2) { const w = Math.atan2(m2[1] - m[1], m2[0] - m[0]); pfeilspitze(g, m[0], m[1], w, 5, `rgba(95,180,255,${0.8 * staerke})`); }
}
}
function nebelPoster(g, P, s) { // Standbild der Nebelprobe: Strahl aus Z1, Böen unter den Fenstern
stromlinien(g, P, s, 0.55);
const wolke = (x, y, r, a) => { const [cx, cy] = P(x, y), gr = g.createRadialGradient(cx, cy, 0, cx, cy, r * s); gr.addColorStop(0, `rgba(204,219,237,${a})`); gr.addColorStop(1, 'rgba(204,219,237,0)'); g.fillStyle = gr; g.beginPath(); g.arc(cx, cy, r * s, 0, Math.PI * 2); g.fill(); };
[[2.3, 4.1, 1.3, .34], [3.0, 3.5, 1.5, .26], [3.9, 3.0, 1.6, .18], [1.6, 0.7, 0.9, .22], [3.9, 0.8, 1.0, .2], [6.2, 0.7, 0.9, .16]].forEach(w => wolke(...w));
}
function pfeilspitze(g, x, y, w, l, farbe) {
g.fillStyle = farbe; g.beginPath();
g.moveTo(x + Math.cos(w) * l, y + Math.sin(w) * l);
g.lineTo(x + Math.cos(w + 2.5) * l * .8, y + Math.sin(w + 2.5) * l * .8);
g.lineTo(x + Math.cos(w - 2.5) * l * .8, y + Math.sin(w - 2.5) * l * .8); g.fill();
}
function pfeilfeld(g, s, ox, oy) {
const z = W.zelle;
for (let py = oy + z / 2; py < oy + RAUM.h * s; py += z) for (let px = ox + z / 2; px < ox + RAUM.b * s; px += z) {
const [vx, vy] = feld((px - ox) / s, (py - oy) / s), sp = Math.hypot(vx, vy);
const l = Math.min(1, sp * 0.9) * z * 0.42, w = Math.atan2(vy, vx), a = Math.min(1, sp * 1.2);
if (a < 0.08) continue;
g.strokeStyle = `rgba(95,180,255,${0.25 + a * 0.6})`; g.lineWidth = 1.1;
g.beginPath(); g.moveTo(px - Math.cos(w) * l, py - Math.sin(w) * l); g.lineTo(px + Math.cos(w) * l, py + Math.sin(w) * l); g.stroke();
pfeilspitze(g, px + Math.cos(w) * l, py + Math.sin(w) * l, w, 4, `rgba(95,180,255,${0.25 + a * 0.6})`);
}
}
/* ---------- Renderer, robust ohne WebGL ---------- */
let renderer = null;
const probe = document.createElement('canvas');
if (probe.getContext('webgl2')) {
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
}
if (!renderer) html.classList.add('kein-gl');
const VERT = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;
const ADVEKTION = `precision highp float;
uniform sampler2D uVel; uniform vec2 uPx; uniform vec2 uVerh; uniform float uDt; uniform float uAbkl; uniform float uBfecc;
varying vec2 vUv;
vec2 v(vec2 p) { return texture2D(uVel, p).xy; }
void main() {
vec2 zurueck = vUv - v(vUv) * uDt * uVerh;
vec2 neu = v(zurueck);
if (uBfecc > 0.5) { /* BFECC: vor und zurück, halben Fehler abziehen */
vec2 vor = zurueck + neu * uDt * uVerh;
vec2 korr = vUv - (vor - vUv) * 0.5;
neu = v(korr - v(korr) * uDt * uVerh);
}
vec2 innen = step(uPx, vUv) * step(vUv, 1.0 - uPx); /* Wände: am Rand keine Geschwindigkeit */
gl_FragColor = vec4(neu * uAbkl * innen.x * innen.y, 0.0, 1.0);
}`;
const KRAFT = `precision highp float;
uniform sampler2D uVel; uniform vec2 uA; uniform vec2 uB; uniform vec2 uKraft; uniform float uRadius; uniform vec2 uSeite;
varying vec2 vUv;
void main() {
vec2 p = vUv * uSeite, a = uA * uSeite, ab = uB * uSeite - a;
float h = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-8), 0.0, 1.0); /* Kapsel: Strecke statt Punkt, keine Lücken */
float f = 1.0 - min(length(p - a - ab * h) / uRadius, 1.0);
gl_FragColor = vec4(texture2D(uVel, vUv).xy + uKraft * f * f, 0.0, 1.0);
}`;
const RAUCH_ADV = `precision highp float;
uniform sampler2D uVel; uniform sampler2D uRauch; uniform vec2 uVerh; uniform float uDt; uniform float uAbkl;
varying vec2 vUv;
void main() { /* Rauch wird von der Strömung mitgenommen und klingt ab */
vec2 zurueck = vUv - texture2D(uVel, vUv).xy * uDt * uVerh;
gl_FragColor = vec4(texture2D(uRauch, zurueck).r * uAbkl, 0.0, 0.0, 1.0);
}`;
const RAUCH_STEMPEL = `precision highp float;
uniform sampler2D uRauch; uniform vec2 uA; uniform vec2 uB; uniform float uMenge; uniform float uRadius; uniform vec2 uSeite;
varying vec2 vUv;
void main() {
vec2 p = vUv * uSeite, a = uA * uSeite, ab = uB * uSeite - a;
float h = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-8), 0.0, 1.0);
float f = 1.0 - min(length(p - a - ab * h) / uRadius, 1.0);
gl_FragColor = vec4(min(texture2D(uRauch, vUv).r + uMenge * f * f, 1.4), 0.0, 0.0, 1.0);
}`;
const DIVERGENZ = `precision highp float;
uniform sampler2D uVel; uniform vec2 uPx; uniform float uDt; varying vec2 vUv;
void main() {
float l = texture2D(uVel, vUv - vec2(uPx.x, 0.0)).x, r = texture2D(uVel, vUv + vec2(uPx.x, 0.0)).x;
float u = texture2D(uVel, vUv - vec2(0.0, uPx.y)).y, o = texture2D(uVel, vUv + vec2(0.0, uPx.y)).y;
gl_FragColor = vec4((r - l + o - u) * 0.5 / uDt, 0.0, 0.0, 1.0);
}`;
const DRUCK = `precision highp float;
uniform sampler2D uP; uniform sampler2D uDiv; uniform vec2 uPx; varying vec2 vUv;
void main() {
float s = texture2D(uP, vUv - vec2(uPx.x, 0.0)).r + texture2D(uP, vUv + vec2(uPx.x, 0.0)).r
+ texture2D(uP, vUv - vec2(0.0, uPx.y)).r + texture2D(uP, vUv + vec2(0.0, uPx.y)).r;
gl_FragColor = vec4((s - texture2D(uDiv, vUv).r) * 0.25, 0.0, 0.0, 1.0);
}`;
const PROJEKTION = `precision highp float;
uniform sampler2D uVel; uniform sampler2D uP; uniform vec2 uPx; uniform float uDt; varying vec2 vUv;
void main() {
float l = texture2D(uP, vUv - vec2(uPx.x, 0.0)).r, r = texture2D(uP, vUv + vec2(uPx.x, 0.0)).r;
float u = texture2D(uP, vUv - vec2(0.0, uPx.y)).r, o = texture2D(uP, vUv + vec2(0.0, uPx.y)).r;
gl_FragColor = vec4(texture2D(uVel, vUv).xy - vec2(r - l, o - u) * 0.5 * uDt, 0.0, 1.0);
}`;
const AUSGABE = `precision highp float;
uniform sampler2D uVel; uniform sampler2D uRauch; uniform sampler2D uPlan; uniform vec2 uRes; uniform vec4 uRect; uniform float uDpr;
uniform int uModA; uniform int uModB; uniform float uMisch; uniform float uVerz; uniform float uZelle; uniform vec4 uRaum;
const vec3 GRUND = vec3(0.031, 0.035, 0.039);
const vec3 MARKE = vec3(0.373, 0.706, 1.0);
const vec3 HELL = vec3(0.90, 0.953, 1.0);
vec2 vel(vec2 frag) { return texture2D(uVel, frag / uRes).xy; }
vec3 plan(vec2 frag, vec2 versatz, out float innen) {
vec2 p = (frag - uRect.xy) / uRect.zw;
innen = step(0.0, p.x) * step(p.x, 1.0) * step(0.0, p.y) * step(p.y, 1.0);
return mix(GRUND, texture2D(uPlan, clamp(p - versatz, 0.001, 0.999)).rgb, innen);
}
float pfeil(vec2 frag) { /* eigene Form der Spur: Vektorpfeil je Rasterzelle */
vec2 mitte = (floor(frag / uZelle) + 0.5) * uZelle;
vec2 v = vel(mitte); float s = length(v);
if (s < 0.015) return 0.0;
vec2 d = v / s, q = frag - mitte;
vec2 lq = vec2(dot(q, d), dot(q, vec2(-d.y, d.x)));
float L = min(s * 1.4, 1.0) * uZelle * 0.44, dick = 0.65 * uDpr;
float schaft = length(vec2(max(abs(lq.x) - L, 0.0), lq.y)) - dick;
float hl = min(L * 0.9, uZelle * 0.2), t = (L - lq.x) / hl;
float kopf = (t >= 0.0 && t <= 1.0) ? abs(lq.y) - hl * 0.55 * t : 1e3;
float a = 1.0 - smoothstep(-0.5 * uDpr, 0.8 * uDpr, min(schaft, kopf));
return a * smoothstep(0.015, 0.16, s);
}
vec3 farbe(int m, vec2 frag) {
vec2 v = vel(frag); float s = length(v), innen;
float k = smoothstep(0.0, 1.1, s);
if (m == 2) { /* Verzerrung: der Grundriss weicht der Strömung aus, Kanten brechen das Licht leicht farbig */
vec2 o = v * uVerz;
vec3 c = vec3(plan(frag, o * 1.18, innen).r, plan(frag, o, innen).g, plan(frag, o * 0.82, innen).b);
return c + MARKE * k * 0.08 * innen;
}
vec3 c = plan(frag, vec2(0.0), innen);
if (m == 3) { /* Nebelprobe: dichter, heller Nebel, der Markenton nur in schnellen Kernen */
float n = clamp(texture2D(uRauch, frag / uRes).r, 0.0, 1.4);
vec2 rin = smoothstep(uRaum.xy - 2.0, uRaum.xy + 6.0, frag) * (1.0 - smoothstep(uRaum.zw - 6.0, uRaum.zw + 2.0, frag));
float a = (1.0 - exp(-n * 1.7)) * mix(0.12, 1.0, rin.x * rin.y); /* Nebel bleibt im Raum, draußen nur ein Hauch */
return mix(c, vec3(0.80, 0.86, 0.93), a * 0.7) + MARKE * k * 0.07 + HELL * pow(n, 3.0) * 0.16;
}
if (m == 1) { /* Vektoren */
float a = pfeil(frag);
return mix(c + MARKE * k * 0.06, mix(MARKE, HELL, smoothstep(0.6, 1.6, s)), a);
}
/* Rauch: Dichte als Farbe, schnelle Kerne hellen auf, Richtung tönt leicht */
float d = clamp(texture2D(uRauch, frag / uRes).r, 0.0, 1.0);
vec3 ton = mix(MARKE, vec3(0.30, 0.52, 0.98), 0.5 + 0.5 * (v.x / max(s, 1e-4)));
return c * (1.0 - d * 0.35) + ton * (d * 0.62 + k * 0.10) + HELL * pow(d, 2.6) * 0.42;
}
void main() {
vec2 frag = gl_FragCoord.xy;
vec3 c = farbe(uModB, frag);
if (uMisch < 1.0) c = mix(farbe(uModA, frag), c, uMisch);
gl_FragColor = vec4(c, 1.0);
}`;
let sim = null;
if (renderer) {
try { sim = baueSimulation(); } catch (e) { console.warn(e); sim = null; renderer = null; html.classList.add('kein-gl'); }
}
function baueSimulation() {
const szene = new THREE.Scene(), kamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2));
quad.frustumCulled = false; szene.add(quad);
const ziel = () => new THREE.WebGLRenderTarget(2, 2, { type: THREE.HalfFloatType, format: THREE.RGBAFormat, minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, depthBuffer: false, stencilBuffer: false, wrapS: THREE.ClampToEdgeWrapping, wrapT: THREE.ClampToEdgeWrapping });
const t = { velA: ziel(), velB: ziel(), div: ziel(), pA: ziel(), pB: ziel(), rauchA: ziel(), rauchB: ziel() };
const mat = (frag, uniforms) => new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: frag, uniforms, depthTest: false, depthWrite: false });
const px = new THREE.Vector2(), verh = new THREE.Vector2(), seite = new THREE.Vector2();
const m = {
advektion: mat(ADVEKTION, { uVel: { value: null }, uPx: { value: px }, uVerh: { value: verh }, uDt: { value: W.dt }, uAbkl: { value: W.dissipation }, uBfecc: { value: W.bfecc ? 1 : 0 } }),
kraft: mat(KRAFT, { uVel: { value: null }, uA: { value: new THREE.Vector2() }, uB: { value: new THREE.Vector2() }, uKraft: { value: new THREE.Vector2() }, uRadius: { value: 0.1 }, uSeite: { value: seite } }),
rauchAdv: mat(RAUCH_ADV, { uVel: { value: null }, uRauch: { value: null }, uVerh: { value: verh }, uDt: { value: W.dt }, uAbkl: { value: W.rauchAbkl } }),
rauchStempel: mat(RAUCH_STEMPEL, { uRauch: { value: null }, uA: { value: new THREE.Vector2() }, uB: { value: new THREE.Vector2() }, uMenge: { value: 0 }, uRadius: { value: 0.1 }, uSeite: { value: seite } }),
divergenz: mat(DIVERGENZ, { uVel: { value: null }, uPx: { value: px }, uDt: { value: W.dt } }),
druck: mat(DRUCK, { uP: { value: null }, uDiv: { value: null }, uPx: { value: px } }),
projektion: mat(PROJEKTION, { uVel: { value: null }, uP: { value: null }, uPx: { value: px }, uDt: { value: W.dt } }),
ausgabe: mat(AUSGABE, { uVel: { value: null }, uRauch: { value: null }, uPlan: { value: null }, uRes: { value: new THREE.Vector2() }, uRect: { value: new THREE.Vector4() }, uDpr: { value: 1 }, uModA: { value: 0 }, uModB: { value: 0 }, uMisch: { value: 1 }, uVerz: { value: W.verzerrung }, uZelle: { value: W.zelle }, uRaum: { value: new THREE.Vector4() } }),
};
const planCanvas = document.createElement('canvas');
const planTex = new THREE.CanvasTexture(planCanvas);
planTex.minFilter = THREE.LinearFilter; planTex.generateMipmaps = false;
m.ausgabe.uniforms.uPlan.value = planTex;
const pass = (material, out) => { quad.material = material; renderer.setRenderTarget(out); renderer.render(szene, kamera); };
const tausch = (a, b) => { const x = t[a]; t[a] = t[b]; t[b] = x; };
return {
t, m, px, verh, seite, planCanvas, planTex, pass, tausch,
groesse(sx, sy, rx, ry) {
Object.entries(t).forEach(([n, r]) => n.startsWith('rauch') ? r.setSize(rx, ry) : r.setSize(sx, sy));
px.set(1 / sx, 1 / sy);
const lang = Math.max(sx, sy);
verh.set(lang / sx, lang / sy); seite.set(sx / lang, sy / lang);
m.kraft.uniforms.uRadius.value = W.cursor / lang;
m.rauchStempel.uniforms.uRadius.value = W.cursor * W.rauchRadius / lang;
this.leeren();
},
leeren() {
const alt = renderer.getRenderTarget();
renderer.setClearColor(0x000000, 0);
Object.values(t).forEach(r => { renderer.setRenderTarget(r); renderer.clear(); });
renderer.setRenderTarget(alt);
},
};
}
/* ---------- Größen ---------- */
let breite = 0, hoehe = 0, dpr = 1, sb = 2, sh = 2, groesseAlt = '';
function groesse() {
breite = innerWidth; hoehe = innerHeight;
dpr = Math.min(devicePixelRatio || 1, W.dpr);
const r = sicht.getBoundingClientRect();
sb = Math.max(2, Math.round(r.width)); sh = Math.max(2, Math.round(r.height));
const schluessel = `${sb}x${sh}@${dpr}`;
if (sim && schluessel !== groesseAlt) {
renderer.setPixelRatio(dpr); renderer.setSize(sb, sh, false);
const f = Math.max(W.aufloesung, W.minZellen / Math.min(sb, sh));
const sx = Math.round(sb * f), sy = Math.round(sh * f);
const fr = Math.max(f, W.rauchAufl);
sim.groesse(sx, sy, Math.round(sb * fr), Math.round(sh * fr));
gitter = `${sx} × ${sy} Zellen`; anzeige.status.dataset.an = '0';
const u = sim.m.ausgabe.uniforms;
u.uRes.value.set(sb * dpr, sh * dpr); u.uRect.value.set(0, 0, sb * dpr, sh * dpr);
u.uDpr.value = dpr; u.uZelle.value = Math.round(W.zelle * dpr);
const ep = einpassen(sb, sh); // Raumgrenzen in Gerätepixeln, y von unten
u.uRaum.value.set(ep.ox * dpr, (sh - ep.oy - RAUM.h * ep.s) * dpr, (ep.ox + RAUM.b * ep.s) * dpr, (sh - ep.oy) * dpr);
// neue Textur je Größe: three legt den Speicher fest an, ein größeres Bild würde nur teilweise ersetzt
zeichnePlan(sim.planCanvas, sb, sh, dpr, 'leer');
sim.planTex.dispose();
sim.planTex = new THREE.CanvasTexture(sim.planCanvas);
sim.planTex.minFilter = THREE.LinearFilter; sim.planTex.generateMipmaps = false;
u.uPlan.value = sim.planTex;
if (!laeuft) einBild();
}
groesseAlt = schluessel;
statischZeichnen();
}
function statischZeichnen() {
if (sim && !reduziert()) return;
const r = sicht.getBoundingClientRect();
zeichnePlan(statisch, r.width, r.height, Math.min(devicePixelRatio || 1, 2), modus);
}
/* ---------- Eingabe: Zeiger, Finger, gedachter Zeiger ---------- */
const ein = { x: 0, y: 0, da: false, neu: false, alt: null }; // in CSS-px (Client)
let letzteEingabe = -1e9, quelle = 'keine';
const sonde = { v: 0, w: 0 };
function eingabe(x, y, von) {
ein.x = x; ein.y = y; ein.da = true; ein.neu = true; quelle = von;
letzteEingabe = performance.now();
wecken();
}
addEventListener('pointermove', e => {
if (e.pointerType === 'touch') return; // Finger laufen über touchmove (Scrollen bleibt möglich)
if (vorfuehrt || intro) stopVorfuehrung();
eingabe(e.clientX, e.clientY, 'zeiger');
}, { passive: true });
addEventListener('touchstart', e => { const p = e.touches[0]; if (vorfuehrt || intro) stopVorfuehrung(); ein.alt = null; eingabe(p.clientX, p.clientY, 'finger'); }, { passive: true });
addEventListener('touchmove', e => { const p = e.touches[0]; eingabe(p.clientX, p.clientY, 'finger'); }, { passive: true });
addEventListener('touchend', () => { ein.alt = null; ein.da = false; }, { passive: true });
document.addEventListener('pointerleave', () => { ein.alt = null; ein.da = false; });
addEventListener('blur', () => { ein.alt = null; ein.da = false; });
/* ---------- Darstellung umschalten ---------- */
function zeigerfeldSetzen() {
const b = knoepfe.find(k => k.dataset.modus === modus) || knoepfe[0];
zeigerfeld.style.width = b.offsetWidth + 'px';
zeigerfeld.style.transform = `translateX(${b.offsetLeft}px)`;
}
function modusZeigen() {
knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.modus === modus)));
html.dataset.modusAn = modus;
$('[data-sonde-ort]').textContent = modus === 'autonom' ? 'Sonde S1 · an Z1' : 'Sonde S1 · am Zeiger';
}
function setzeModus(neu) {
if (!MODI.includes(neu) || neu === modus) return;
modusAlt = modus; modus = neu; mischStart = performance.now();
if ((neu === 'autonom') !== (modusAlt === 'autonom') && sim) sim.leeren(); // Nebel und Zeigerspur nicht mischen
modusZeigen(); zeigerfeldSetzen(); werteZeigen(); statischZeichnen(); wecken();
}
// Variante in der URL (?v=), andere Parameter bleiben erhalten
function urlSetzen(m) {
const u = new URL(location.href);
m === 'rauch' ? u.searchParams.delete('v') : u.searchParams.set('v', m);
try { history.replaceState(null, '', u); } catch (e) { /* ohne History */ }
}
knoepfe.forEach(k => k.addEventListener('click', () => { if (vorfuehrt || intro) stopVorfuehrung(); setzeModus(k.dataset.modus); urlSetzen(k.dataset.modus); }));
function werteZeigen() {
const basis = `<b>Stable Fluids</b> · dissipation 0,96 · poisson 4 · dt 0,014 · mouse_force 50 · cursor_size 18 · auflösung 0,1 · bfecc`;
const extra = {
rauch: `rauch × ${zahl(W.rauchAbkl, 3)} · stempel ${zahl(W.rauchRadius, 1)}`,
vektoren: `pfeil-raster ${W.zelle} px · länge ∝ |v|`,
verzerrung: `versatz = v × ${zahl(W.verzerrung, 3)}`,
autonom: `quellen Z1 + F1–F3 · strahl ${zahl(W.strahl, 2)} ± ${W.strahlFaecher}° · takt ${zahl(W.strahlTakt / 1000, 1)} s / ${zahl(W.boeTakt / 1000, 1)} s · nebel × ${zahl(W.nebelAbkl, 3)}`,
}[modus];
if (reduziert() || !sim) { anzeige.status.textContent = 'Ergebnis · statisch'; anzeige.status.dataset.an = '0'; }
anzeige.werte.innerHTML = `<span>${basis}</span><span><b>${modus}</b> · ${extra}${reduziert() ? ' · reduziert: statisches Ergebnis' : ''}</span>`;
}
function zahl(n, d) { return n.toFixed(d).replace('.', ','); }
/* ---------- Vorführung: gedachter Zeiger zieht eine Acht durch die Sicht ---------- */
let vorfuehrt = false, vStart = 0, vModus = 0, intro = 0, festV = null;
const VORF_MODUS_MS = 5200;
function vorfuehrZeiger(t) {
const r = sicht.getBoundingClientRect();
const unten = Math.min(r.bottom, hoehe) - r.top; // sichtbarer Teil der Sicht
const cx = r.left + r.width * 0.52, cy = r.top + unten * 0.56;
const ax = r.width * 0.32, ay = unten * 0.3;
const w = t / 1000 * 1.25;
return { x: cx + ax * Math.sin(w), y: cy + ay * Math.sin(2 * w + 0.6) };
}
function stopVorfuehrung() {
if (!vorfuehrt && !intro) return;
vorfuehrt = false; intro = 0; html.classList.remove('vorfuehrung'); ein.alt = null;
}
/* ---------- Nebelprobe: Quellen nach Zeitplan, ohne Zeiger ---------- */
W.strahlMs = 0.35; // Z1 liefert 0,35 m/s (Szene links)
let strahlJetzt = 0, strahlWinkel = 0;
function nebelStempel(u, v, kx, ky, menge, radius) { // ein Punkt-Stempel: Kraft und Nebel
const s = sim, m = s.m, k = m.kraft.uniforms, rs = m.rauchStempel.uniforms, lang = Math.max(s.px.x ? 1 / s.px.x : 1, s.px.y ? 1 / s.px.y : 1);
k.uVel.value = s.t.velA.texture; k.uA.value.set(u, v); k.uB.value.set(u, v); k.uKraft.value.set(kx, ky); k.uRadius.value = radius / lang;
s.pass(m.kraft, s.t.velB); s.tausch('velA', 'velB');
rs.uRauch.value = s.t.rauchA.texture; rs.uA.value.set(u, v); rs.uB.value.set(u, v); rs.uMenge.value = menge; rs.uRadius.value = radius * W.rauchRadius * 1.4 / lang;
s.pass(m.rauchStempel, s.t.rauchB); s.tausch('rauchA', 'rauchB');
k.uRadius.value = W.cursor / lang; rs.uRadius.value = W.cursor * W.rauchRadius / lang;
}
function nebelQuellen(t, f, r) {
const { s, ox, oy } = einpassen(r.width, r.height);
const uv = (x, y) => [(ox + x * s) / r.width, 1 - (oy + y * s) / r.height];
const ff = Math.min(f, 3);
// Z1: Strahl Richtung Raummitte, schwenkt ± 35°, alle 4,8 s ein Stoß (Shooting)
const basis = Math.atan2(-(3.0 - Z1.y), 4.2 - Z1.x); // in uv: y nach oben
strahlWinkel = basis + Math.sin(t / 1000 * 0.55) * W.strahlFaecher * Math.PI / 180;
const phase = (t % W.strahlTakt) / W.strahlTakt;
strahlJetzt = 0.45 + 0.55 * Math.exp(-phase * 7); // Stoß, dann ruhiger Grundstrom
const [zu, zv] = uv(Z1.x + 0.25 * Math.cos(basis), Z1.y - 0.25 * Math.sin(basis));
nebelStempel(zu, zv, Math.cos(strahlWinkel) * W.strahl * strahlJetzt * ff * sim.seite.x, Math.sin(strahlWinkel) * W.strahl * strahlJetzt * ff * sim.seite.y, W.strahlMenge * strahlJetzt * ff, W.cursor * 0.8);
// Fenster F1–F3 reihum: weiche, breite Böe nach unten (Mist/Fog)
const nr = Math.floor(t / W.boeTakt) % 3, in_ = (t % W.boeTakt) / W.boeDauer;
if (in_ < 1) {
const st = Math.sin(in_ * Math.PI), fx = [0.9, 3.2, 5.5][nr] + 0.7;
const [bu, bv] = uv(fx + Math.sin(t / 700) * 0.3, 0.35);
nebelStempel(bu, bv, Math.sin(t / 900) * 0.08 * ff, -W.boe * st * ff, W.boeMenge * st * ff, W.cursor * W.boeRadius);
}
}
/* ---------- Bildschleife (pausiert ohne Eingabe, außerhalb des Bildschirms und im Hintergrund-Tab) ---------- */
let laeuft = false, letzte = performance.now(), sondeZeit = 0, gitter = '', imBild = true;
let angehalten = false;
function wecken() {
if (!laeuft && !angehalten && !reduziert() && sim && imBild && !document.hidden) { laeuft = true; letzte = performance.now(); requestAnimationFrame(bild); }
}
function ausgabe(t) {
const s = sim, u = s.m.ausgabe.uniforms;
u.uVel.value = s.t.velA.texture; u.uRauch.value = s.t.rauchA.texture;
u.uModA.value = MODI.indexOf(modusAlt); u.uModB.value = MODI.indexOf(modus);
u.uMisch.value = Math.min(1, (t - mischStart) / W.misch);
s.pass(s.m.ausgabe, null);
}
function einBild() { if (sim && !reduziert()) ausgabe(performance.now()); }
function bild(t) {
const roh = Math.min(100, Math.max(1, t - letzte)); letzte = t;
const f = roh / 16.667; // Bildzeit relativ zu 60 Hz
if (angehalten || reduziert() || !sim || !imBild || document.hidden) { laeuft = false; return; }
const r = sicht.getBoundingClientRect();
// Eingabequelle bestimmen (Client-Koordinaten)
let px = null;
if (vorfuehrt || intro) {
const lt = Math.max(0, t - vStart);
if (vorfuehrt) {
const n = Math.floor(lt / VORF_MODUS_MS) % FOLGE.length;
if (!festV && n !== vModus) { vModus = n; setzeModus(FOLGE[n]); }
} else if (lt > 2600) { intro = 0; html.classList.remove('vorfuehrung'); ein.alt = null; }
if ((vorfuehrt || intro) && modus !== 'autonom') {
px = vorfuehrZeiger(lt + 400);
zeiger.style.transform = `translate(${px.x}px, ${px.y}px)`;
letzteEingabe = t;
}
} else if (ein.da && (ein.neu || t - letzteEingabe < 120)) {
px = { x: ein.x, y: ein.y }; ein.neu = false;
} else if (beruehrung && modus !== 'autonom' && t - letzteEingabe > 2500) {
// Touch ohne Eingabe: die Zuluft bläst von selbst, langsam kreisend in der Sicht
const w = t / 1000;
px = { x: r.left + r.width * (0.5 + 0.3 * Math.sin(w * 0.9)), y: r.top + r.height * (0.55 + 0.22 * Math.sin(w * 1.7)) };
quelle = 'leerlauf';
}
// Ruhe: lange keine Eingabe, Spur ist abgeklungen
const auto = modus === 'autonom';
const ruhig = !auto && !px && t - letzteEingabe > W.ruhe && t - mischStart > W.misch;
const s = sim, m = s.m, dt = W.dt * Math.min(f, 3);
if (!ruhig) {
// 1. Advektion mit Abklingen (bildratenunabhängig), Rauch wird mitgenommen
m.advektion.uniforms.uVel.value = s.t.velA.texture; m.advektion.uniforms.uDt.value = dt;
m.advektion.uniforms.uAbkl.value = Math.pow(W.dissipation, f);
s.pass(m.advektion, s.t.velB); s.tausch('velA', 'velB');
const ra = m.rauchAdv.uniforms;
ra.uVel.value = s.t.velA.texture; ra.uRauch.value = s.t.rauchA.texture; ra.uDt.value = dt; ra.uAbkl.value = Math.pow(auto ? W.nebelAbkl : W.rauchAbkl, f);
s.pass(m.rauchAdv, s.t.rauchB); s.tausch('rauchA', 'rauchB');
// 2. Kraft und Rauch entlang der Strecke vom letzten zum aktuellen Zeigerpunkt (Kapsel, keine Lücken)
if (px) {
const u = (px.x - r.left) / r.width, v = 1 - (px.y - r.top) / r.height;
const a = ein.alt || { u, v };
// Gesamtweg seit dem letzten Bild × mouse_force: gleicher Impuls bei 60 Hz wie bei langsamen Geräten
let kx = (u - a.u) * s.seite.x * W.kraft, ky = (v - a.v) * s.seite.y * W.kraft;
const kl = Math.hypot(kx, ky); if (kl > W.kraftMax) { kx *= W.kraftMax / kl; ky *= W.kraftMax / kl; }
const k = m.kraft.uniforms;
k.uVel.value = s.t.velA.texture; k.uA.value.set(a.u, a.v); k.uB.value.set(u, v); k.uKraft.value.set(kx, ky);
s.pass(m.kraft, s.t.velB); s.tausch('velA', 'velB');
const vpx = Math.hypot((u - a.u) * r.width, (v - a.v) * r.height) / roh * 1000; // px/s
const rs = m.rauchStempel.uniforms;
rs.uRauch.value = s.t.rauchA.texture; rs.uA.value.set(a.u, a.v); rs.uB.value.set(u, v);
rs.uMenge.value = Math.min(1, 0.12 + vpx / 1400) * 0.35 * Math.min(f, 3);
s.pass(m.rauchStempel, s.t.rauchB); s.tausch('rauchA', 'rauchB');
ein.alt = { u, v };
// Sonde: Zeigergeschwindigkeit, geglättet
sonde.v += (vpx - sonde.v) * Math.min(1, 0.2 * f);
if (Math.hypot(u - a.u, v - a.v) > 1e-4) sonde.w = Math.atan2((v - a.v) * r.height, (u - a.u) * r.width);
} else { ein.alt = null; sonde.v *= Math.pow(W.dissipation, f); }
if (auto) nebelQuellen(t, f, r);
// 3. Divergenz, 4. Druck (Jacobi), 5. Projektion
m.divergenz.uniforms.uVel.value = s.t.velA.texture; m.divergenz.uniforms.uDt.value = dt;
s.pass(m.divergenz, s.t.div);
m.druck.uniforms.uDiv.value = s.t.div.texture;
for (let i = 0; i < W.poisson; i++) { m.druck.uniforms.uP.value = s.t.pA.texture; s.pass(m.druck, s.t.pB); s.tausch('pA', 'pB'); }
m.projektion.uniforms.uVel.value = s.t.velA.texture; m.projektion.uniforms.uP.value = s.t.pA.texture; m.projektion.uniforms.uDt.value = dt;
s.pass(m.projektion, s.t.velB); s.tausch('velA', 'velB');
}
// 6. Ausgabe
ausgabe(t);
// Anzeigen: Sonde 10× je Sekunde
if (t - sondeZeit > 100) {
sondeZeit = t;
const ms = auto ? W.strahlMs * strahlJetzt : Math.min(2.4, sonde.v / 1000); // 1000 px/s ≙ 1 m/s im Modell
if (auto) sonde.w = strahlWinkel;
anzeige.v.textContent = `${zahl(ms, 2)} m/s`;
anzeige.r.textContent = ms > 0.02 ? `${String(Math.round(((-sonde.w * 180 / Math.PI) + 450) % 360)).padStart(3, '0')}°` : '–';
anzeige.q.textContent = `${zahl(0.6 * ms * ms, 2)} Pa`; // Staudruck q = ½ · 1,2 kg/m³ · v²
}
if (ruhig) { anzeige.status.dataset.an = '0'; laeuft = false; anzeige.status.textContent = `Pausiert · ${gitter}`; return; }
if (anzeige.status.dataset.an !== '1') { anzeige.status.dataset.an = '1'; anzeige.status.textContent = `Live · ${gitter}`; }
requestAnimationFrame(bild);
}
new IntersectionObserver(e => { imBild = e[0].isIntersecting; if (imBild) wecken(); }).observe(sicht);
document.addEventListener('visibilitychange', () => { if (!document.hidden) wecken(); });
/* ---------- Ablauf ---------- */
let modusTakt = null;
const urlModus = () => { const v = new URLSearchParams(location.search).get('v'); return MODI.includes(v) ? v : null; };
function vorfuehren() {
stopVorfuehrung(); clearInterval(modusTakt);
festV = urlModus(); // mit ?v= bleibt die Vorführung in dieser Variante
if (reduziert() || !sim) {
// ohne Bewegung: die statischen Ergebnisbilder im Wechsel (mit ?v= nur dieses)
if (festV) { setzeModus(festV); return; }
let n = 0; setzeModus(FOLGE[0]);
modusTakt = setInterval(() => { n = (n + 1) % FOLGE.length; setzeModus(FOLGE[n]); }, 3200);
return;
}
vorfuehrt = true; vStart = performance.now(); vModus = 0; setzeModus(festV || FOLGE[0]);
html.classList.add('vorfuehrung'); wecken();
}
function neustart() {
stopVorfuehrung(); clearInterval(modusTakt);
setzeModus(urlModus() || 'rauch');
if (sim) sim.leeren();
sonde.v = 0;
if (reduziert() || !sim) { statischZeichnen(); return; }
if (modus === 'autonom') { wecken(); return; } // Nebelprobe braucht keinen gedachten Zeiger
// einmal abspielen: der gedachte Zeiger zieht eine Schleife und verschwindet
intro = 1; vStart = performance.now(); html.classList.add('vorfuehrung'); wecken();
}
function pause() { stopVorfuehrung(); clearInterval(modusTakt); }
function weiter() { vorfuehren(); }
// Knopf „Bewegung anhalten“: pause() und das letzte Bild bleibt stehen; „Abspielen“ weckt die Schleife wieder
const haltKnopf = document.querySelector('[data-halt]');
function anhalten(an) {
angehalten = an;
haltKnopf.setAttribute('aria-pressed', String(an));
haltKnopf.textContent = an ? 'Abspielen' : 'Bewegung anhalten';
if (an) pause(); else wecken();
}
haltKnopf.addEventListener('click', () => anhalten(!angehalten));
// reduzierte Bewegung kann von außen umgeschaltet werden (Schalter der Detailseite)
new MutationObserver(() => { werteZeigen(); statischZeichnen(); if (!reduziert()) wecken(); }).observe(html, { attributes: true, attributeFilter: ['class'] });
addEventListener('resize', () => { zeigerfeldSetzen(); groesse(); });
new ResizeObserver(() => groesse()).observe(sicht);
document.fonts.ready.then(() => { zeigerfeldSetzen(); groesseAlt = ''; groesse(); wecken(); });
groesse(); modusZeigen(); zeigerfeldSetzen(); werteZeigen();
requestAnimationFrame(() => document.querySelectorAll('.ein').forEach((el, i) => setTimeout(() => el.classList.add('da'), i * 60)));
if (!html.classList.contains('vorschau') && !reduziert() && sim) setTimeout(() => { if (!vorfuehrt && quelle === 'keine') neustart(); }, 500);
</script>
</body>
</html>