Interaktiver Physik-Footer
Die Lernziele eines Jenaer Python-Kurses fallen als Pillen in den Footer, stapeln sich und lassen sich greifen, werfen und anschubsen. Eigene kleine Physik ohne Bibliothek.
Sobald der Footer erscheint, fallen Buchstaben, Pillen mit Begriffen oder Logos von oben hinein und stapeln sich physikalisch. Man kann die Teile greifen, werfen und anstoßen, der Zeiger schiebt sie zur Seite. Manchmal bilden sie die Wortmarke oder einen Claim. Die eigentlichen Links stehen außerhalb der Physikfläche.
Ein spielerischer Abschluss, der zum Verweilen einlädt und im Gedächtnis bleibt. Er zeigt technisches Können mit Humor.
Wann einsetzen
Kreative Studios, Startups mit lockerer Marke, Kampagnen, Portfolios und Lernangebote mit vielen greifbaren Begriffen.
Wann nicht
In seriösen Branchen. Bei knappem Performance-Budget. Nie als Ersatz für die eigentlichen Footer-Informationen.
Varianten
- Fallende Pillen mit Leistungen oder Lernzielen
- Fallende Buchstaben der Wortmarke
- Physik erst auf Klick oder Hover
- Animierter SVG-Footer
Umsetzung
Matter.js oder eine kleine eigene Physik: unsichtbare Wände für Boden und Seiten, je Element ein Körper (Rechteck mit chamfer oder Kapsel für Pillen). DOM-Elemente per transform aus Position und Winkel setzen, das ergibt bessere Typografie als Canvas-Text. Zum Ziehen eine MouseConstraint. Mausrad-Events nicht abfangen (bei Matter mouse.element.removeEventListener('wheel', …)), sonst lässt sich nicht weiterscrollen. Simulation erst starten, wenn der Footer sichtbar ist (IntersectionObserver), und anhalten, wenn er verschwindet oder alles ruht. Bei Resize die Wände neu setzen. Fallstrick: Ziehen per Touch kollidiert mit Scrollen, daher auf Touch nur fallen lassen.
Robust umsetzen
Telefon. Weniger Elemente, kein Greifen wegen des Scroll-Konflikts. Antippen darf schubsen. Alternativ eine statische Endlage.
Ohne Bewegung. Die Elemente liegen schon gestapelt da (vorab berechnete Endlage), keine Simulation.
Barrierefrei. Physikfläche mit aria-hidden. Derselbe Inhalt steht als normale Liste im Text. Keine Links in der Physikfläche; Bedienknöpfe wie „Loslassen“ liegen außerhalb des aria-hidden-Bereichs.
Code
<!DOCTYPE html>
<!--
Interaktiver Physik-Footer
Fundus · Footer · https://cecodesign.de/lab/fundus/footer/interaktiver-physik-footer/
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>Interaktiver Physik-Footer · 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: 'Bricolage Grotesque'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/bricolage-grotesque:vf@latest/latin-opsz-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'Onest'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/onest:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Shantell Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/shantell-sans:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 300 800; 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: #f4f6fa; --karte: #ffffff; --tinte: #141414; --leise: #5b606b; --linie: rgba(20, 20, 20, .12);
--marke: #7491ff; --erfolg: #10bf7a; --gelb: #f7c325;
--kurve: cubic-bezier(0.22, 1, 0.36, 1); /* easeOutQuint, Basis für alles */
--feder: cubic-bezier(0.34, 1.56, 0.64, 1); /* nur für Erfolgsmomente */
--rand: clamp(16px, 3vw, 40px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--tinte); font-family: 'Onest', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { overflow-x: clip; }
a { color: inherit; text-decoration: none; }
.weg { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.hand { font-family: 'Shantell Sans', 'Onest', sans-serif; font-variation-settings: 'INFM' 40, 'BNCE' 0; font-weight: 500; }
:focus-visible { outline: 2px solid var(--marke); outline-offset: 3px; }
/* ---------- Demo-Pult (nicht Teil der erfundenen Seite) ---------- */
.pult { position: fixed; top: 12px; right: var(--rand); z-index: 40; display: flex; align-items: center; gap: 6px 12px; flex-wrap: wrap; justify-content: flex-end; padding: 5px 5px 5px 14px; border-radius: 16px; background: rgba(20, 20, 20, .88); color: rgba(244, 246, 250, .62); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
font: 500 10.5px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.pult b { color: #f4f6fa; font-weight: 500; }
.pult .gruppe { display: flex; gap: 3px; }
.pult button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: rgba(244, 246, 250, .72); background: none; border: 1px solid rgba(244, 246, 250, .18); border-radius: 999px; height: 28px; padding: 0 10px; cursor: pointer; transition: color .15s var(--kurve), background-color .15s var(--kurve); }
.pult button:hover { color: #f4f6fa; }
.pult button[aria-pressed='true'] { background: #f4f6fa; color: var(--tinte); border-color: #f4f6fa; }
/* ---------- Footer ---------- */
.fuss { padding: 12px var(--rand) 0; }
.karte { position: relative; background: var(--karte); border-radius: 28px; overflow: hidden; box-shadow: 0 1px 0 rgba(20, 20, 20, .04), 0 24px 60px -30px rgba(40, 60, 120, .25); }
.kopf { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); gap: 20px clamp(24px, 5vw, 80px); align-items: end; padding: clamp(26px, 4vw, 56px) clamp(22px, 4vw, 56px) 0; }
.vorzeile { font-size: .9375rem; color: var(--leise); margin-bottom: 12px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.vorzeile .start { background: var(--gelb); color: var(--tinte); border-radius: 999px; padding: 3px 10px; font-family: 'Onest', sans-serif; font-weight: 600; font-size: .8125rem; }
.titel { font-family: 'Bricolage Grotesque', 'Onest', sans-serif; font-weight: 700; font-variation-settings: 'opsz' 96; font-size: clamp(2.25rem, 1.4rem + 3.4vw, 4.25rem); line-height: 1.05; letter-spacing: -.02em; text-wrap: balance; }
.titel mark { background: linear-gradient(transparent 62%, rgba(247, 195, 37, .75) 62%, rgba(247, 195, 37, .75) 92%, transparent 92%); color: inherit; padding: 0 .04em; }
.angebot { display: grid; gap: 14px; padding-bottom: 6px; }
.angebot p { font-size: 1.0625rem; line-height: 1.6; color: var(--leise); max-width: 46ch; }
.angebot p b { color: var(--tinte); font-weight: 600; }
.handlung { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 18px; }
.knopf { display: inline-flex; align-items: center; gap: 10px; height: 52px; padding: 0 24px; border-radius: 999px; background: var(--marke); color: var(--tinte); font-weight: 600; font-size: 1rem; transition: transform .15s var(--kurve), background-color .15s var(--kurve); }
.knopf:hover { background: #6582ff; }
.knopf:active { transform: scale(.97); }
.preis { font-size: .9375rem; color: var(--leise); }
.preis b { color: var(--tinte); font-variant-numeric: tabular-nums; }
/* Die Physikfläche: unten in der Karte, eigener Bereich ohne Links */
.wannen-rahmen { position: relative; }
.wanne { position: relative; height: clamp(280px, 40svh, 380px); margin-top: clamp(10px, 2vw, 24px); overflow: hidden; touch-action: pan-y; user-select: none; -webkit-user-select: none; }
.wanne.greifbar { cursor: grab; }
.wanne.greift { cursor: grabbing; }
.teil { position: absolute; left: 0; top: 0; display: flex; align-items: center; justify-content: center; gap: 8px; white-space: nowrap; border-radius: 999px; font-weight: 600; font-size: 1.0625rem; height: 54px; padding: 0 22px;
background: #fff; color: var(--tinte); box-shadow: inset 0 0 0 1.5px var(--tinte); pointer-events: none; will-change: transform; transform: translate3d(-999px, -999px, 0); }
.teil.blau { background: var(--marke); box-shadow: none; }
.teil.blau .nr { opacity: 1; } /* auf Blau volle Tinte: 6,3:1 */
.teil.gelb { background: var(--gelb); box-shadow: none; }
.teil.gruen { background: var(--erfolg); box-shadow: none; }
.teil.dunkel { background: var(--tinte); color: #fff; box-shadow: none; }
.teil .nr { font-family: 'Shantell Sans', sans-serif; font-variation-settings: 'INFM' 40, 'BNCE' 0; font-weight: 500; font-size: .8125rem; opacity: .7; }
.teil.perle { padding: 0; width: 108px; height: 108px; font-family: 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 96; font-weight: 800; font-size: 3.5rem; line-height: 1; }
.teil.perle span { transform: translateY(-.06em); }
.notiz { position: absolute; right: clamp(22px, 4vw, 56px); top: 14px; z-index: 2; font-size: 1rem; color: var(--leise); transform: rotate(-4deg); pointer-events: none; transition: opacity .3s var(--kurve); }
.notiz svg { width: 34px; height: 30px; vertical-align: -12px; margin-left: 4px; }
.wanne.bewegt .notiz { opacity: 0; }
.v-klick .wanne { height: clamp(340px, 48svh, 440px); }
.ausloeser { display: none; position: absolute; left: 50%; bottom: 22px; z-index: 3; translate: -50% 0; height: 40px; padding: 0 18px; border-radius: 999px; border: 0; background: var(--tinte); color: #fff; font: 600 .9375rem 'Onest', sans-serif; cursor: pointer; transition: opacity .2s var(--kurve), transform .15s var(--kurve); }
.ausloeser:active { transform: scale(.97); }
/* Bewegung anhalten (WCAG 2.2.2): gleiche Pause wie FUNDUS_DEMO.pause() */
.halt { position: absolute; left: clamp(16px, 3vw, 40px); top: 12px; z-index: 3; height: 32px; padding: 0 14px; border-radius: 999px; border: 1.5px solid var(--tinte); background: #fff; color: var(--tinte); font: 600 .8125rem 'Onest', sans-serif; cursor: pointer; }
.halt:hover, .halt[aria-pressed='true'] { background: var(--gelb); }
.vorschau .halt, .reduziert .halt { display: none; }
.v-klick .ausloeser { display: inline-flex; align-items: center; }
/* Links außerhalb der Physikfläche */
.spalten { display: grid; grid-template-columns: 1.3fr repeat(3, minmax(0, 1fr)); gap: 28px clamp(16px, 2.4vw, 40px); padding: clamp(26px, 3vw, 44px) clamp(6px, 1vw, 16px) 24px; font-size: .9375rem; line-height: 1.55; }
.spalten h3 { font-size: .9375rem; font-weight: 600; margin-bottom: 10px; }
.spalten ul { list-style: none; display: grid; gap: 2px; color: var(--leise); }
.spalten a { transition: color .15s var(--kurve); }
.spalten a:hover { color: var(--tinte); }
.marke-name { font-family: 'Bricolage Grotesque', sans-serif; font-variation-settings: 'opsz' 48; font-weight: 800; font-size: 1.5rem; letter-spacing: -.02em; }
.marke-name span { color: #6283ff; } /* Marke minimal dunkler: 3,1:1 als großer Text */
.person { display: flex; gap: 12px; align-items: center; margin-top: 12px; color: var(--leise); max-width: 34ch; }
.person svg { flex: none; width: 46px; height: 46px; }
.recht { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px 24px; padding: 16px clamp(6px, 1vw, 16px) 22px; border-top: 1px solid var(--linie); font-size: .8125rem; color: var(--leise); }
.recht nav { display: flex; gap: 18px; }
/* Gedachter Zeiger der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; z-index: 60; width: 24px; height: 24px; margin: -12px 0 0 -12px; border: 1.5px solid var(--tinte); border-radius: 50%; background: rgba(116, 145, 255, .25); pointer-events: none; opacity: 0; transition: opacity .2s var(--kurve), scale .15s var(--kurve); }
.zeiger.an { opacity: 1; }
.zeiger.drueckt { scale: .75; }
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
@media (max-width: 900px) { .kopf { grid-template-columns: 1fr; align-items: start; } .spalten { grid-template-columns: 1fr 1fr; } }
@media (max-width: 640px) {
.teil { height: 40px; padding: 0 15px; font-size: .875rem; gap: 6px; }
.teil.perle { width: 64px; height: 64px; font-size: 2.1rem; }
.wanne { height: 300px; }
.notiz { font-size: .875rem; top: 10px; }
.knopf { width: 100%; justify-content: center; }
.spalten .wir { grid-column: 1 / -1; }
}
@media (hover: none) { .notiz .maus { display: none; } }
@media (hover: hover) { .notiz .finger { display: none; } }
.notiz .klick, .v-klick .notiz .maus, .v-klick .notiz .finger { display: none; }
.v-klick .notiz .klick { display: inline; }
/* Vorschau in der Galerie: nur die Karte */
.vorschau body { overflow: hidden; }
.vorschau .angebot p, .vorschau .notiz { display: none; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<div class="pult">
<span data-werte></span>
<div class="gruppe" role="group" aria-label="Variante">
<button type="button" data-variante="pillen" aria-pressed="true">Lernziele</button>
<button type="button" data-variante="wortmarke" aria-pressed="false">Wortmarke</button>
<button type="button" data-variante="klick" aria-pressed="false">Auf Klick</button>
</div>
</div>
<footer class="fuss" data-fuss>
<section class="karte" aria-labelledby="schluss-titel">
<div class="kopf">
<div>
<p class="vorzeile"><span class="start">Nächster Start: 12. Januar 2027</span><span class="hand">noch 9 von 24 Plätzen</span></p>
<h2 class="titel" id="schluss-titel">Das alles kannst du nach <mark>acht Wochen</mark>.</h2>
<ul class="weg" aria-label="Lernziele des Kurses" data-liste></ul>
</div>
<div class="angebot">
<p><b>Python für Forschende,</b> die lieber auswerten als abtippen. Live-Kurs mit Dr. Ragna Kowalczyk aus Jena: acht Wochen, zwei Abende pro Woche, kleine Gruppe.</p>
<div class="handlung">
<a class="knopf" href="#">Platz sichern <span aria-hidden="true">→</span></a>
<span class="preis"><b>490 €</b> oder 3 × <b>170 €</b></span>
</div>
</div>
</div>
<div class="wannen-rahmen">
<div class="wanne" data-wanne aria-hidden="true">
<p class="notiz hand"><span class="maus">Zieh mal dran</span><span class="finger">Tipp mal drauf</span><span class="klick">Klick, und alles fällt</span><svg viewBox="0 0 34 30" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 4c10 1 20 6 25 19"/><path d="M21 19l7 5 2-9"/></svg></p>
</div>
<button type="button" class="ausloeser" data-ausloeser>Loslassen</button>
<button type="button" class="halt" data-halt aria-pressed="false">Bewegung anhalten</button>
</div>
</section>
<nav class="spalten" aria-label="Fußbereich">
<div class="wir">
<p class="marke-name">labor<span>code</span></p>
<div class="person">
<svg viewBox="0 0 46 46" aria-hidden="true"><circle cx="23" cy="23" r="23" fill="#f7c325"/><circle cx="23" cy="19" r="8" fill="#141414"/><path d="M8 40c3-8 9-12 15-12s12 4 15 12" fill="#141414"/><path d="M13 17c1-8 7-11 12-10 6 1 9 5 9 12-3-4-8-6-13-5-3 1-6 2-8 3z" fill="#7491ff"/></svg>
<p>Ragna Kowalczyk, promovierte Biophysikerin. Seit 2019 haben 640 Forschende bei ihr Python gelernt.</p>
</div>
</div>
<div>
<h3>Kurs</h3>
<ul><li><a href="#">Lernweg in 8 Modulen</a></li><li><a href="#">Termine 2027</a></li><li><a href="#">Preise & Ratenzahlung</a></li><li><a href="#">Häufige Fragen</a></li></ul>
</div>
<div>
<h3>Für Institute</h3>
<ul><li><a href="#">Kurs im eigenen Haus</a></li><li><a href="#">Rechnung ans Institut</a></li><li><a href="#">Bildungsfreistellung</a></li></ul>
</div>
<div>
<h3>Kontakt</h3>
<ul><li><a href="mailto:hallo@laborcode.de">hallo@laborcode.de</a></li><li><a href="#">Montagsnotiz: ein Python-Kniff pro Woche</a></li><li>Laborcode · Jena</li></ul>
</div>
</nav>
<div class="recht">
<span>© 2026 Laborcode, Ragna Kowalczyk · 14 Tage Widerruf, danach Geld zurück bis Modul 2</span>
<nav aria-label="Rechtliches"><a href="#">Impressum</a><a href="#">Datenschutz</a><a href="#">AGB</a></nav>
</div>
</footer>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
<script>
(() => {
const html = document.documentElement;
const $ = s => document.querySelector(s);
const reduziert = () => html.classList.contains('reduziert');
const beruehrung = matchMedia('(hover: none)').matches;
const wanne = $('[data-wanne]'), werteEl = $('[data-werte]'), zeigerEl = $('[data-zeiger]'), ausloeser = $('[data-ausloeser]');
/* ---------- Werte (meta.json → messwerte: restitution 0.3–0.6, friction 0.1–0.3, Gravitation wie Matter-Default, Abstand 50–150 ms) ---------- */
const W = {
schwerkraft: 1000, // px/s², entspricht Matter gravity.y 1 bei scale 0.001 und 60 Bildern/s
restitution: 0.35, reibung: 0.25,
luft: 0.01, // Luftwiderstand je Schritt bei 60 Bildern/s (Matter frictionAir)
versatzMin: 50, versatzMax: 150, // Abstand der fallenden Teile in ms
schritte: 2, iterationen: 10, // Unterschritte je Bild, Löser-Durchgänge
griff: 0.2, // Steifigkeit beim Ziehen (wie Matter MouseConstraint)
zeigerRadius: 20, // Zeiger schiebt Teile wie eine Kugel
};
const LERNZIELE = [
['Variablen & Typen', '', 'M1'], ['Schleifen', 'blau', ''], ['Funktionen', '', 'M2'], ['Jupyter', 'dunkel', ''], ['pandas', 'blau', 'M3'],
['Messreihen einlesen', '', ''], ['CSV & Excel', '', ''], ['Saubere Daten', 'gelb', 'M4'], ['Diagramme', '', ''], ['Plots fürs Paper', 'blau', 'M5'],
['Fehlermeldungen lesen', '', ''], ['Statistik', 'dunkel', 'M6'], ['Automatisieren', '', ''], ['Git', 'blau', 'M7'], ['Reproduzierbar', '', ''],
['Eigenes Skript', 'dunkel', 'M8'], ['Zertifikat ✓', 'gruen', ''], ['Keine Angst mehr vor Code', 'gelb', ''],
];
const WORT = ['l', 'a', 'b', 'o', 'r', 'c', 'o', 'd', 'e'];
const PERLFARBEN = ['blau', 'gelb', 'dunkel', '', 'gruen', 'blau', 'gelb', 'dunkel', ''];
$('[data-liste]').innerHTML = LERNZIELE.map(([t]) => `<li>${t.replace(' ✓', '')}</li>`).join('');
let variante = 'pillen';
function zeigeWerte() {
werteEl.innerHTML = reduziert()
? 'Simulation <b>aus</b> · Endlage'
: `g <b>${W.schwerkraft} px/s²</b> · restitution <b>${W.restitution}</b> · friction <b>${W.reibung}</b> · <b>${W.versatzMin}–${W.versatzMax} ms</b>`;
}
/* ---------- Kleiner Zufall mit Startwert, damit die Endlage immer gleich aussieht ---------- */
let saat = 7;
const zufall = () => { saat = (saat * 16807) % 2147483647; return (saat - 1) / 2147483646; };
/* ---------- Physik: Kapseln (Strecke + Radius), Impulse mit Reibung ---------- */
let koerper = [], B = 0, H = 0;
const INNEN = 12; // Wände sitzen 12 px innen, damit nichts an den runden Kartenecken abgeschnitten wird
function kapsel(el, x, y, a) {
const w = el.offsetWidth, h = el.offsetHeight, r = h / 2, l = Math.max(0, (w - h) / 2);
const m = (2 * l * 2 * r + Math.PI * r * r) * 0.001, L = 2 * l + 2 * r;
return { el, w, h, r, l, x, y, a, vx: 0, vy: 0, w0: 0, m, im: 1 / m, iI: 12 / (m * (L * L + 4 * r * r)), los: Infinity, aktiv: true };
}
const enden = k => { const c = Math.cos(k.a) * k.l, s = Math.sin(k.a) * k.l; return [k.x - c, k.y - s, k.x + c, k.y + s]; };
function naechster(px, py, x0, y0, x1, y1) {
const dx = x1 - x0, dy = y1 - y0, q = dx * dx + dy * dy;
let t = q > 1e-9 ? ((px - x0) * dx + (py - y0) * dy) / q : 0; t = Math.max(0, Math.min(1, t));
return [x0 + dx * t, y0 + dy * t];
}
function streckeStrecke(p1x, p1y, q1x, q1y, p2x, p2y, q2x, q2y) { // nächste Punkte zweier Strecken (Ericson)
const d1x = q1x - p1x, d1y = q1y - p1y, d2x = q2x - p2x, d2y = q2y - p2y, rx = p1x - p2x, ry = p1y - p2y;
const a = d1x * d1x + d1y * d1y, e = d2x * d2x + d2y * d2y, f = d2x * rx + d2y * ry;
let s, t;
if (a <= 1e-9 && e <= 1e-9) { s = t = 0; }
else if (a <= 1e-9) { s = 0; t = Math.max(0, Math.min(1, f / e)); }
else {
const c = d1x * rx + d1y * ry;
if (e <= 1e-9) { t = 0; s = Math.max(0, Math.min(1, -c / a)); }
else {
const b = d1x * d2x + d1y * d2y, n = a * e - b * b;
s = n !== 0 ? Math.max(0, Math.min(1, (b * f - c * e) / n)) : 0;
t = (b * s + f) / e;
if (t < 0) { t = 0; s = Math.max(0, Math.min(1, -c / a)); } else if (t > 1) { t = 1; s = Math.max(0, Math.min(1, (b - c) / a)); }
}
}
return [p1x + d1x * s, p1y + d1y * s, p2x + d2x * t, p2y + d2y * t];
}
// Kontakte: Normale zeigt von a nach b; b = null ist eine Wand, b = zeiger eine bewegte Kugel ohne Masse
function kontakteKK(A, b, liste) {
const [a0x, a0y, a1x, a1y] = enden(A), [b0x, b0y, b1x, b1y] = enden(b), rs = A.r + b.r, kand = [];
const pruefe = (ax, ay, bx, by) => {
const dx = bx - ax, dy = by - ay, d = Math.hypot(dx, dy);
if (d >= rs) return;
const nx = d > 1e-6 ? dx / d : 0, ny = d > 1e-6 ? dy / d : 1;
kand.push({ a: A, b, nx, ny, px: ax + nx * A.r, py: ay + ny * A.r, tief: rs - d, jn: 0, jt: 0 });
};
let q = naechster(a0x, a0y, b0x, b0y, b1x, b1y); pruefe(a0x, a0y, q[0], q[1]);
if (A.l > 0.5) { q = naechster(a1x, a1y, b0x, b0y, b1x, b1y); pruefe(a1x, a1y, q[0], q[1]); }
q = naechster(b0x, b0y, a0x, a0y, a1x, a1y); pruefe(q[0], q[1], b0x, b0y);
if (b.l > 0.5) { q = naechster(b1x, b1y, a0x, a0y, a1x, a1y); pruefe(q[0], q[1], b1x, b1y); }
const s = streckeStrecke(a0x, a0y, a1x, a1y, b0x, b0y, b1x, b1y); pruefe(s[0], s[1], s[2], s[3]);
if (!kand.length) return;
kand.sort((u, v) => v.tief - u.tief);
liste.push(kand[0]);
const zweit = kand.find(k => Math.hypot(k.px - kand[0].px, k.py - kand[0].py) > 6);
if (zweit) liste.push(zweit);
}
function kontakteWand(A, liste) {
const [a0x, a0y, a1x, a1y] = enden(A), pkte = A.l > 0.5 ? [[a0x, a0y], [a1x, a1y]] : [[a0x, a0y]];
for (const [x, y] of pkte) {
const boden = H - INNEN, links = INNEN, rechts = B - INNEN;
if (y + A.r > boden) liste.push({ a: A, b: null, nx: 0, ny: 1, px: x, py: boden, tief: y + A.r - boden, jn: 0, jt: 0 });
if (x - A.r < links) liste.push({ a: A, b: null, nx: -1, ny: 0, px: links, py: y, tief: links + A.r - x, jn: 0, jt: 0 });
if (x + A.r > rechts) liste.push({ a: A, b: null, nx: 1, ny: 0, px: rechts, py: y, tief: x + A.r - rechts, jn: 0, jt: 0 });
}
}
const zeigerKugel = { x: -999, y: -999, vx: 0, vy: 0, l: 0, r: W.zeigerRadius, a: 0, an: false };
function geschw(k, rx, ry) { return k ? [k.vx - (k.w0 || 0) * ry, k.vy + (k.w0 || 0) * rx] : [0, 0]; }
function loese(c, ersterDurchgang) {
const A = c.a, Bk = c.b, bMasse = Bk && Bk !== zeigerKugel;
const rax = c.px - A.x, ray = c.py - A.y, rbx = Bk ? c.px - Bk.x : 0, rby = Bk ? c.py - Bk.y : 0;
const [vax, vay] = geschw(A, rax, ray), [vbx, vby] = Bk ? geschw(Bk, rbx, rby) : [0, 0];
const rvx = vbx - vax, rvy = vby - vay, vn = rvx * c.nx + rvy * c.ny;
const imB = bMasse ? Bk.im : 0, iIB = bMasse ? Bk.iI : 0;
const ran = rax * c.ny - ray * c.nx, rbn = rbx * c.ny - rby * c.nx;
const kN = A.im + imB + ran * ran * A.iI + rbn * rbn * iIB;
if (ersterDurchgang) c.bias = vn < -60 ? -W.restitution * vn : 0;
let dj = (-vn + (c.bias || 0)) / kN;
const neu = Math.max(c.jn + dj, 0); dj = neu - c.jn; c.jn = neu;
const tx = -c.ny, ty = c.nx, vt = rvx * tx + rvy * ty;
const rat = rax * ty - ray * tx, rbt = rbx * ty - rby * tx;
const kT = A.im + imB + rat * rat * A.iI + rbt * rbt * iIB;
let djt = -vt / kT; const grenze = W.reibung * c.jn;
const neuT = Math.max(-grenze, Math.min(grenze, c.jt + djt)); djt = neuT - c.jt; c.jt = neuT;
const Px = c.nx * dj + tx * djt, Py = c.ny * dj + ty * djt;
A.vx -= Px * A.im; A.vy -= Py * A.im; A.w0 -= A.iI * (rax * Py - ray * Px);
if (bMasse) { Bk.vx += Px * Bk.im; Bk.vy += Py * Bk.im; Bk.w0 += Bk.iI * (rbx * Py - rby * Px); }
}
let griff = null; // { k, lx, ly, x, y }
function schritt(dt, uhr) {
const aktive = koerper.filter(k => k.aktiv && uhr >= k.los);
const kontakte = [];
for (let i = 0; i < aktive.length; i++) {
const A = aktive[i];
kontakteWand(A, kontakte);
for (let j = i + 1; j < aktive.length; j++) {
const b = aktive[j];
if (Math.abs(A.x - b.x) > A.l + b.l + A.r + b.r || Math.abs(A.y - b.y) > A.l + b.l + A.r + b.r) continue;
kontakteKK(A, b, kontakte);
}
if (zeigerKugel.an) kontakteKK(A, zeigerKugel, kontakte);
}
const luft = Math.pow(1 - W.luft, dt * 60);
for (const k of aktive) {
k.vy += W.schwerkraft * dt;
if (griff && griff.k === k) {
// Feder zum Zeiger, am gegriffenen Punkt angesetzt (dreht das Teil mit)
const c = Math.cos(k.a), s = Math.sin(k.a), px = k.x + griff.lx * c - griff.ly * s, py = k.y + griff.lx * s + griff.ly * c;
const rx = px - k.x, ry = py - k.y, [vx, vy] = geschw(k, rx, ry);
const zx = (griff.x - px) / dt * W.griff - vx, zy = (griff.y - py) / dt * W.griff - vy;
const Px = zx * k.m * 0.5, Py = zy * k.m * 0.5;
k.vx += Px * k.im; k.vy += Py * k.im; k.w0 += k.iI * (rx * Py - ry * Px) * 0.4;
k.w0 *= 0.92;
}
k.vx *= luft; k.vy *= luft; k.w0 *= luft;
}
for (let it = 0; it < W.iterationen; it++) for (const c of kontakte) loese(c, it === 0);
for (const k of aktive) { k.x += k.vx * dt; k.y += k.vy * dt; k.a += k.w0 * dt; }
// Lagekorrektur: Überlappung zu 40 % je Schritt abbauen
for (const c of kontakte) {
const tief = c.tief - 0.4; if (tief <= 0) continue;
const imB = c.b && c.b !== zeigerKugel ? c.b.im : 0, f = tief * 0.4 / (c.a.im + imB);
c.a.x -= c.nx * f * c.a.im; c.a.y -= c.ny * f * c.a.im;
if (imB) { c.b.x += c.nx * f * imB; c.b.y += c.ny * f * imB; }
}
// Ruhe: kleine Restbewegung am Boden der Haufen schlucken, damit nichts zittert
for (const k of aktive) if (Math.abs(k.vx) < 3 && Math.abs(k.vy) < 3 && Math.abs(k.w0) < 0.03) { k.vx *= 0.5; k.vy *= 0.5; k.w0 *= 0.5; }
}
function zeichne() {
for (const k of koerper) k.el.style.transform = `translate3d(${(k.x - k.w / 2).toFixed(2)}px, ${(k.y - k.h / 2).toFixed(2)}px, 0) rotate(${k.a.toFixed(4)}rad)`;
}
/* ---------- Aufbau je Variante ---------- */
function baue() {
wanne.querySelectorAll('.teil').forEach(e => e.remove());
saat = 7;
B = wanne.clientWidth; H = wanne.clientHeight;
const schmal = B < 640;
let daten;
if (variante === 'wortmarke') daten = WORT.map((z, i) => ({ html: `<span>${z}</span>`, kl: 'perle ' + PERLFARBEN[i] }));
else daten = (schmal ? LERNZIELE.filter((_, i) => i % 2 === 0 || i > 15) : LERNZIELE).map(([t, f, nr]) => ({ html: (nr ? `<span class="nr">${nr}</span>` : '') + t, kl: f }));
koerper = daten.map(d => {
const el = document.createElement('span'); el.className = 'teil ' + d.kl; el.innerHTML = d.html; wanne.appendChild(el);
return kapsel(el, 0, 0, 0);
});
if (variante === 'klick') ordne(); else verteile();
zeichne();
}
// Startlage oben über der Fläche, versetzt fallen lassen
function verteile() {
let t = 0;
if (variante === 'wortmarke') {
// Buchstaben fallen der Reihe nach über ihre Plätze in der Wortmarke und landen fast lesbar
const luecke = 6, breite = koerper.reduce((s, k) => s + k.w, 0) + luecke * (koerper.length - 1);
let x = Math.max(INNEN, (B - breite) / 2);
koerper.forEach(k => {
k.x = x + k.w / 2 + (zufall() - 0.5) * 10; x += k.w + luecke;
k.y = -k.h - 20 - zufall() * 60; k.a = (zufall() - 0.5) * 0.3; k.vx = 0; k.vy = 0; k.w0 = (zufall() - 0.5) * 0.4;
k.los = t; t += W.versatzMin + zufall() * (W.versatzMax - W.versatzMin); k.aktiv = true;
});
return;
}
koerper.forEach((k, i) => {
k.x = INNEN + k.w / 2 + zufall() * Math.max(1, B - 2 * INNEN - k.w);
k.y = -k.h - 20 - zufall() * 120;
k.a = (zufall() - 0.5) * 0.6; k.vx = (zufall() - 0.5) * 60; k.vy = 0; k.w0 = (zufall() - 0.5) * 1.2;
k.los = t; t += W.versatzMin + zufall() * (W.versatzMax - W.versatzMin);
k.aktiv = true;
});
}
// Variante „Auf Klick“: der Lernweg steht geordnet als Zeilen in der Fläche, die Physik schläft
function ordne() {
const luecke = B < 640 ? 8 : 12, rand = B < 640 ? 14 : 36;
let x = rand, zeile = [];
const zeilen = [];
koerper.forEach(k => {
if (x + k.w > B - rand && zeile.length) { zeilen.push(zeile); zeile = []; x = rand; }
zeile.push(k); x += k.w + luecke;
});
if (zeile.length) zeilen.push(zeile);
// Block senkrecht mittig zwischen Oberkante und Knopf
const hoehe = zeilen.reduce((s, z) => s + z[0].h, 0) + luecke * (zeilen.length - 1);
let y = Math.max(INNEN + 4, (H - 80 - hoehe) / 2);
zeilen.forEach(z => {
const breite = z.reduce((s, k) => s + k.w, 0) + luecke * (z.length - 1);
let xx = (B - breite) / 2;
z.forEach(k => { k.x = xx + k.w / 2; k.y = y + k.h / 2; k.a = 0; k.vx = k.vy = k.w0 = 0; k.aktiv = false; k.los = 0; xx += k.w + luecke; });
y += z[0].h + luecke;
});
ausloeser.textContent = 'Loslassen';
}
function loslassen() {
let t = uhr;
koerper.forEach(k => { k.aktiv = true; k.los = t; t += W.versatzMin + zufall() * (W.versatzMax - W.versatzMin); k.vy = -60 - zufall() * 80; k.w0 = (zufall() - 0.5) * 3; });
ausloeser.textContent = 'Aufräumen';
wanne.classList.add('bewegt');
wecke();
}
ausloeser.addEventListener('click', e => {
e.stopPropagation(); stoppe();
if (koerper.some(k => k.aktiv)) { ordne(); zeichne(); wanne.classList.remove('bewegt'); } else loslassen();
});
// Reduzierte Bewegung: Endlage vorab ausrechnen, dann ein einziges Mal zeichnen
function endlage() {
if (variante === 'klick') { zeichne(); return; }
let u = 0; for (let i = 0; i < 1400; i++) { u += 1000 / 120; schritt(1 / 120, u); }
zeichne();
}
/* ---------- Schleife: läuft nur, solange der Footer sichtbar ist ---------- */
let raf = 0, uhr = 0, letzt = 0, sichtbar = false, angehalten = false, ruhig = 0;
function bild(jetzt) {
raf = 0;
const dt = Math.min(1 / 30, (jetzt - letzt) / 1000); letzt = jetzt;
uhr += dt * 1000;
const h = dt / W.schritte;
for (let i = 0; i < W.schritte; i++) schritt(h, uhr);
zeichne();
// schläft ein, wenn alles liegt und niemand zieht
const bewegung = koerper.reduce((s, k) => s + (k.aktiv ? Math.abs(k.vx) + Math.abs(k.vy) : 0), 0);
const wartet = koerper.some(k => k.aktiv && uhr < k.los);
ruhig = bewegung < 2 * koerper.length && !griff && !zeigerKugel.an && !wartet ? ruhig + dt : 0;
if (sichtbar && !angehalten && ruhig < 1.5) raf = requestAnimationFrame(bild);
}
function wecke() { ruhig = 0; if (!raf && sichtbar && !angehalten && !reduziert()) { letzt = performance.now(); raf = requestAnimationFrame(bild); } }
/* ---------- Zeiger: greifen, werfen, schieben (nur Maus und Stift) ---------- */
function lokal(e) { const r = wanne.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; }
function treffer(x, y) {
for (let i = koerper.length - 1; i >= 0; i--) {
const k = koerper[i]; if (!k.aktiv && variante !== 'klick') continue;
const [x0, y0, x1, y1] = enden(k), q = naechster(x, y, x0, y0, x1, y1);
if (Math.hypot(x - q[0], y - q[1]) <= k.r) return k;
}
return null;
}
let zeigerT = 0;
function zeigerBewegt(x, y) {
const jetzt = performance.now(), dt = Math.max(8, jetzt - zeigerT) / 1000; zeigerT = jetzt;
zeigerKugel.vx = zeigerKugel.vx * 0.5 + (x - zeigerKugel.x) / dt * 0.5; zeigerKugel.vy = zeigerKugel.vy * 0.5 + (y - zeigerKugel.y) / dt * 0.5;
zeigerKugel.x = x; zeigerKugel.y = y;
if (griff) { griff.x = x; griff.y = y; }
}
wanne.addEventListener('pointermove', e => {
if (e.pointerType === 'touch' || reduziert() || vorf) return;
const [x, y] = lokal(e); zeigerBewegt(x, y);
zeigerKugel.an = !griff && koerper.some(k => k.aktiv);
const k = treffer(x, y);
wanne.classList.toggle('greifbar', !!k && k.aktiv);
if (zeigerKugel.an || griff) wecke();
});
wanne.addEventListener('pointerleave', () => { if (!vorf) { zeigerKugel.an = false; zeigerKugel.x = zeigerKugel.y = -999; } });
wanne.addEventListener('pointerdown', e => {
if (reduziert()) return;
stoppe();
const [x, y] = lokal(e);
if (variante === 'klick' && !koerper.some(k => k.aktiv)) { loslassen(); return; }
if (e.pointerType === 'touch') { // Touch: kein Greifen (Scroll-Konflikt), nur ein kleiner Schubs nach oben
koerper.forEach(k => { const d = Math.hypot(k.x - x, k.y - y); if (d < 120 && k.aktiv) { k.vy -= 420 * (1 - d / 120); k.w0 += (zufall() - 0.5) * 4; } });
wanne.classList.add('bewegt'); wecke(); return;
}
const k = treffer(x, y); if (!k || !k.aktiv) return;
const c = Math.cos(-k.a), s = Math.sin(-k.a), dx = x - k.x, dy = y - k.y;
griff = { k, lx: dx * c - dy * s, ly: dx * s + dy * c, x, y };
zeigerKugel.an = false; wanne.classList.add('greift', 'bewegt'); wanne.setPointerCapture(e.pointerId);
e.preventDefault(); wecke();
});
const loslass = () => { griff = null; wanne.classList.remove('greift'); };
wanne.addEventListener('pointerup', loslass); wanne.addEventListener('pointercancel', loslass);
// Mausrad wird nicht abgefangen: Scrollen über der Fläche bleibt frei
/* ---------- Start erst, wenn der Footer zu sehen ist ---------- */
function start() {
baue(); uhr = 0; wanne.classList.remove('bewegt');
if (reduziert()) { endlage(); return; }
wecke();
}
let gestartet = false;
new IntersectionObserver(e => {
sichtbar = e[0].isIntersecting;
if (sichtbar && !gestartet) { gestartet = true; document.fonts.ready.then(start); }
else if (sichtbar) wecke();
}, { threshold: 0.15 }).observe(wanne);
let breiteVorher = 0;
new ResizeObserver(() => {
if (!gestartet || Math.abs(wanne.clientWidth - breiteVorher) < 2 && wanne.clientHeight === H) return;
breiteVorher = wanne.clientWidth;
B = wanne.clientWidth; H = wanne.clientHeight;
koerper.forEach(k => { k.x = Math.min(Math.max(k.x, k.w / 2), B - k.w / 2); k.y = Math.min(k.y, H - k.r); });
if (reduziert()) endlage(); else wecke();
}).observe(wanne);
function setzeVariante(v) {
variante = v;
html.classList.toggle('v-klick', v === 'klick');
document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
if (gestartet) start();
}
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { stoppe(); setzeVariante(b.dataset.variante); }));
/* ---------- Vorführung: fallen lassen, ein Teil greifen und werfen, durch den Haufen schieben ---------- */
let vorf = false, vUhren = [];
const nach = (ms, f) => vUhren.push(setTimeout(f, ms));
function zeigerZu(x, y) { const r = wanne.getBoundingClientRect(); zeigerEl.style.transform = `translate(${r.left + x}px, ${r.top + y}px)`; zeigerBewegt(x, y); }
function fahrt(von, bis, dauer, schrittF) {
const t0 = performance.now();
const f = () => {
if (!vorf) return;
const p = Math.min(1, (performance.now() - t0) / dauer), e = 1 - Math.pow(1 - p, 3);
const x = von[0] + (bis[0] - von[0]) * e, y = von[1] + (bis[1] - von[1]) * e;
zeigerZu(x, y); if (schrittF) schrittF(x, y); wecke();
if (p < 1) vUhren.push(requestAnimationFrame(f));
};
f();
}
function runde() {
vUhren.forEach(id => { clearTimeout(id); cancelAnimationFrame(id); }); vUhren = [];
if (variante === 'klick') {
start();
nach(900, () => { zeigerEl.classList.add('an'); zeigerZu(B * 0.5, 60); });
nach(1500, () => { zeigerEl.classList.add('drueckt'); });
nach(1650, () => { zeigerEl.classList.remove('drueckt'); loslassen(); });
nach(2400, () => zeigerEl.classList.remove('an'));
nach(7200, () => { if (vorf && !angehalten) runde(); });
return;
}
start();
const ziel = () => koerper.slice().sort((a, b) => a.y - b.y)[0];
nach(3000, () => {
const k = ziel(); if (!k) return;
zeigerEl.classList.add('an'); zeigerZu(B * 0.8, 40);
fahrt([B * 0.8, 40], [k.x, k.y], 600);
});
nach(3700, () => {
const k = ziel(); if (!k) return;
zeigerEl.classList.add('drueckt'); zeigerZu(k.x, k.y);
griff = { k, lx: 0, ly: 0, x: k.x, y: k.y }; wanne.classList.add('bewegt');
fahrt([k.x, k.y], [B * 0.32, 30], 650);
});
nach(4400, () => { fahrt([B * 0.32, 30], [B * 0.14, 10], 160); });
nach(4560, () => { griff = null; zeigerEl.classList.remove('drueckt'); });
nach(5100, () => {
zeigerKugel.an = true;
fahrt([B * 0.08, H - 50], [B * 0.92, H - 70], 1500);
zeigerZu(B * 0.08, H - 50);
});
nach(6700, () => { zeigerKugel.an = false; zeigerKugel.x = zeigerKugel.y = -999; zeigerEl.classList.remove('an'); });
nach(9200, () => { if (vorf && !angehalten) runde(); });
}
function stoppe() {
if (!vorf) return; vorf = false; griff = null; zeigerKugel.an = false;
vUhren.forEach(id => { clearTimeout(id); cancelAnimationFrame(id); }); vUhren = [];
zeigerEl.classList.remove('an', 'drueckt');
}
function vorfuehren() {
stoppe(); vorf = true; angehalten = false;
const los = () => { gestartet = true; sichtbar = true; if (reduziert()) { start(); return; } runde(); };
document.fonts.ready.then(los);
}
function pause() { angehalten = true; vUhren.forEach(id => { clearTimeout(id); cancelAnimationFrame(id); }); vUhren = []; griff = null; zeigerKugel.an = false; zeigerEl.classList.remove('an', 'drueckt'); }
function weiter() { angehalten = false; if (vorf) runde(); else wecke(); }
const halt = $('[data-halt]');
const haltZeigen = an => { halt.setAttribute('aria-pressed', String(an)); halt.textContent = an ? 'Abspielen' : 'Bewegung anhalten'; };
halt.addEventListener('click', () => { if (angehalten) { haltZeigen(false); weiter(); } else { haltZeigen(true); pause(); } });
function neustart() { stoppe(); angehalten = false; if (gestartet) start(); }
zeigeWerte();
})();
</script>
</body>
</html>