Endlose Leinwand – Drag-Galerie in alle Richtungen
Eine Bildwand ohne Ränder: ziehen, werfen, in jede Richtung weiter. Das Archiv wirkt größer als der Bildschirm und lädt zum Stöbern ein.
- Ein Satz Kacheln (Bilder in verschiedenen Formaten, oft mit Masonry-Versatz) wird so oft kopiert, dass in jede Richtung eine Kopie bereitsteht.
- Ziehen und Mausrad (deltaX und deltaY) verschieben einen Zielwert; der aktuelle Wert folgt ihm in jedem Frame per Lerp.
- Jede Kachel wird über Modulo der Satzgröße positioniert. Verlässt sie eine Kante, springt sie um eine volle Satzbreite oder -höhe auf die Gegenseite.
- Jede Kachel hat einen leicht zufälligen Faktor (0,5 bis 1,0). Er steuert Maus-Parallaxe und Nachlauf und erzeugt Tiefe.
- Ein Klick öffnet die Kachel per FLIP groß, die Leinwand bleibt dahinter stehen.
- Intro: Die Kacheln fliegen mit expo.inOut in 2 s und 50 ms Versatz auf ihren Platz.
Der Nutzer erkundet, statt zu scrollen. Das Werk wirkt größer als der Bildschirm. Freie Bewegung in zwei Achsen ist auf Websites ungewohnt und bleibt deshalb hängen; die Trägheit hält sie trotzdem kontrolliert. Das vermittelt Fülle und weckt Neugier.
Wann einsetzen
Bildlastige Portfolios, Fotografie, Studios mit vielen Projekten, Kampagnen-Archive, Moodboards, Team- oder Projektwände.
Wann nicht
Wenn Inhalte gelesen oder verglichen werden müssen oder Nutzer gezielt etwas suchen. Bei wenigen Bildern: unter etwa 12 fallen die Wiederholungen auf. Nie als einziger Zugang zu wichtigen Inhalten, immer eine Listen- oder Indexansicht anbieten.
Varianten
- Masonry mit Tiefe (Standard der Demo): Spalten mit Versatz, Kachel-Faktor 0,5–1,0 für Maus-Parallaxe und Nachlauf
- Raster (Basic): gleichmäßige Zellen ohne Parallaxe; flache DOM-Version mit translate3d bis etwa 60 Kacheln
- Zoom-Stufe: Strg+Rad oder Pinch zoomt zwischen Übersicht und Detail; beim Ziehen zoomt die Leinwand heraus
- WebGL/Three.js mit Chunks: nur sichtbare Felder werden instanziiert, für große Archive
- WebGL-Leinwand mit Verzerrung beim Ziehen
- Kuppel- oder Kugelkrümmung der Leinwand (siehe kuppel-kugel-galerie)
- Liquid-Glass-Grid mit Videobrechung (WebGPU/TSL)
- Kachelfläche mit Hover-Reveal
- Als Projektübersicht oder Startseite, mit Umschalter zur Liste
Umsetzung
Zustand: ziel {x, y} und ist {x, y}. Lerp-Faktor 0,08–0,12 pro Frame, zeitbasiert: k = 1 − (1 − f)^(dt × 60). Wrap: x = ((basisX + ist.x) % W + W) % W − Puffer. Nur transform setzen (translate3d), nie top/left. Kacheln außerhalb des Fensters auf visibility: hidden.
Pointer Events mit setPointerCapture; ein Klick zählt nur, wenn die Bewegung unter 5 px blieb. Rad-Delta mit Faktor (hier 0,4) aufs Ziel. Bilder mit decoding=async und srcset, vorab mit img.decode() laden; Videos außerhalb des Sichtbereichs pausieren. Für sehr viele Bilder auf WebGL mit Instancing wechseln.
Fallstricke: Trackpads liefern sehr kleine Rad-Deltas, Safari meldet Pinch als gesturechange. Der Fokus darf nicht in kopierte Kacheln laufen.
Robust umsetzen
Telefon. Touch-Pan mit touch-action: none nur auf der Leinwand. Auf kleinen Displays größere Kacheln und weniger Parallaxe. Die Listenansicht prominent anbieten.
Ohne Bewegung. Keine Trägheit (Lerp-Faktor 1), keine Parallaxe, Intro als einfaches Einblenden. Zusätzlich eine statische Liste oder ein Raster mit normalem Scrollen als Umschalter.
Barrierefrei. Eine Leinwand ist für Tastatur und Screenreader schwer erfassbar. Deshalb eine echte Projektliste als gleichwertige Alternative mit sichtbarem Umschalter Leinwand/Liste. Pfeiltasten verschieben die Leinwand, Tab fokussiert Projekte und holt sie in die Mitte. Kopien bekommen aria-hidden und inert (oder tabindex -1).
Code
<!DOCTYPE html>
<!--
Endlose Leinwand – Drag-Galerie in alle Richtungen
Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/unendliche-leinwand-galerie/
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>Endlose Leinwand · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-style: italic; font-weight: 400; font-display: swap; }
:root {
--grund: #131312; --text: #efece5; --leise: rgba(239, 236, 229, .52); --linie: rgba(239, 236, 229, .16);
--papier: #e9e5dc; --tinte: #161615;
--kurve: cubic-bezier(0.87, 0, 0.13, 1); /* expo.inOut: Intro 2000 ms, Wechsel 1200 ms, Öffnen 1200 ms */
--ui: 150ms;
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { height: 100svh; overflow: hidden; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.mono { font: 500 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.serif { font-family: 'Instrument Serif', Georgia, serif; font-style: italic; font-weight: 400; }
:focus-visible { outline: 1px solid var(--text); outline-offset: 3px; }
/* Bühne: die Leinwand. Alle Kacheln absolut, nur transform wird pro Frame gesetzt. */
.buehne { position: fixed; inset: 0; overflow: hidden; touch-action: none; cursor: grab; outline: none; transition: opacity .6s var(--kurve); }
.buehne.zieht { cursor: grabbing; }
.buehne:focus-visible { outline: 1px solid var(--linie); outline-offset: -12px; }
.kachel { position: absolute; left: 0; top: 0; display: block; transform-origin: 0 0; visibility: hidden; will-change: transform; text-align: left; }
.kachel .rahmen { position: absolute; inset: 0; overflow: hidden; border-radius: 3px; background: #1f1f1d; container-type: inline-size; }
.kachel img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; scale: 1.14; pointer-events: none; user-select: none; -webkit-user-drag: none; }
.kachel .unter { position: absolute; left: 0; right: 0; top: calc(100% + 8px); display: flex; gap: 10px; font-size: 9.5px; white-space: nowrap; overflow: hidden; pointer-events: none; transition: color var(--ui) linear; }
.kachel .unter b { font-weight: 500; color: var(--text); opacity: .78; }
.kachel:hover .unter b, .kachel:focus-visible .unter b { opacity: 1; }
.kachel:focus-visible { outline: none; }
.kachel:focus-visible .rahmen { outline: 1px solid var(--text); outline-offset: 4px; }
.kachel.weg { opacity: 0 !important; }
.buehne.gedimmt { opacity: .14; }
/* Kachel-Arten ohne Foto: Farbmuster, Zahl, Leitsatz, Schrift, Studio */
.feld { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: space-between; padding: 7cqi 8cqi; }
.feld .mono { font-size: max(8px, 5.2cqi); color: inherit; opacity: .7; letter-spacing: .12em; }
.feld.voll .mono { opacity: 1; }
.feld .hex { font: 500 max(11px, 9.5cqi)/1 'Geist Mono', monospace; letter-spacing: .02em; }
.feld .gross { font-size: 62cqi; line-height: .8; letter-spacing: -.02em; }
.feld .satz { font-size: 11.5cqi; line-height: 1.04; letter-spacing: -.01em; }
.feld .marke { font-size: 13cqi; font-weight: 600; letter-spacing: -.03em; line-height: .95; }
/* Kopf und Fuß liegen über der Leinwand */
.kopf, .fuss { position: fixed; left: 0; right: 0; z-index: 4; display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; padding: clamp(16px, 2.6vw, 30px) clamp(16px, 3vw, 36px); pointer-events: none; }
.kopf { top: 0; background: linear-gradient(rgba(19, 19, 18, .94) 35%, rgba(19, 19, 18, 0)); padding-bottom: 44px; }
.fuss { bottom: 0; align-items: flex-end; background: linear-gradient(rgba(19, 19, 18, 0), rgba(19, 19, 18, .94) 65%); padding-top: 56px; }
.glas { background: rgba(19, 19, 18, .62); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-radius: 4px; padding: 6px 9px; }
.kopf > *, .fuss > * { pointer-events: auto; }
.marke-kopf { display: grid; gap: 4px; }
.marke-kopf strong { font-size: 19px; font-weight: 600; letter-spacing: -.035em; line-height: 1; }
.marke-kopf strong em { font-family: 'Instrument Serif', serif; font-style: italic; font-weight: 400; letter-spacing: 0; }
.mitte { position: absolute; left: 50%; translate: -50% 0; text-align: center; white-space: nowrap; }
.kopf .mitte { top: clamp(13px, 2.3vw, 26px); }
.knopfreihe { display: flex; gap: 6px; align-items: center; }
.knopfreihe button { background: rgba(19, 19, 18, .62); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); border: 1px solid var(--linie); border-radius: 999px; height: 28px; padding: 0 11px; transition: background-color var(--ui) linear, color var(--ui) linear, border-color var(--ui) linear; }
.knopfreihe button:hover { color: var(--text); border-color: rgba(239, 236, 229, .4); }
.knopfreihe button[aria-pressed='true'] { background: var(--text); color: var(--grund); border-color: var(--text); }
.knopfreihe .zahl { min-width: 4.6em; text-align: center; color: var(--text); }
.zoomer[hidden] { display: none; }
.werte { text-align: right; display: grid; gap: 3px; font-size: 10px; }
.werte span { color: var(--text); opacity: .82; }
.links { display: grid; gap: 10px; }
/* Eigener Zeiger (nur Maus): Ring mit Hinweis */
.ring { position: fixed; left: 0; top: 0; z-index: 6; width: 64px; height: 64px; margin: -32px 0 0 -32px; border-radius: 50%; border: 1px solid rgba(239, 236, 229, .55); display: grid; place-items: center; pointer-events: none; opacity: 0; transition: opacity var(--ui) linear, scale .5s var(--kurve), background-color var(--ui) linear; }
.ring span { font: 500 9px/1 'Geist Mono', monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--text); }
.ring.an { opacity: 1; }
.ring.zieht { scale: .42; background: var(--text); border-color: var(--text); }
.ring.zieht span { opacity: 0; }
.ring.ueber { scale: 1.25; background: var(--text); border-color: var(--text); }
.ring.ueber span { color: var(--grund); }
/* Index: die gleichwertige Listenansicht */
.index { position: fixed; inset: 0; z-index: 3; background: rgba(19, 19, 18, .94); display: grid; align-content: center; padding: 96px clamp(16px, 6vw, 96px) 110px; overflow-y: auto; opacity: 0; visibility: hidden; transition: opacity .4s linear, visibility 0s .4s; }
.index.offen { opacity: 1; visibility: visible; transition: opacity .4s linear; }
.index ol { list-style: none; padding: 0; border-bottom: 1px solid var(--linie); max-width: 1100px; width: 100%; margin: 0 auto; }
.index li button { width: 100%; display: grid; grid-template-columns: 3em 1fr auto 6em; gap: 16px; align-items: baseline; padding: clamp(9px, 1.3vw, 14px) 0; border-top: 1px solid var(--linie); text-align: left; }
.index .n { font-size: clamp(24px, 3.6vw, 46px); font-weight: 500; letter-spacing: -.04em; line-height: 1.02; transition: translate .6s var(--kurve), opacity var(--ui) linear; }
.index ol:hover .n { opacity: .4; }
.index li button:hover .n, .index li button:focus-visible .n { opacity: 1; translate: .2em 0; }
.index .j { text-align: right; }
/* Detail: Kachel klappt per FLIP auf, die Leinwand bleibt dahinter stehen */
.detail { position: fixed; inset: 0; z-index: 5; visibility: hidden; }
.detail::before { content: ''; position: absolute; inset: 0; background: rgba(19, 19, 18, .7); opacity: 0; transition: opacity .6s var(--kurve); }
.detail.offen { visibility: visible; }
.detail.zeigt::before { opacity: 1; }
.detail figure { position: fixed; overflow: hidden; border-radius: 3px; background: #1f1f1d; }
.detail figure img { width: 100%; height: 100%; object-fit: cover; display: block; }
.detail .text { position: fixed; display: grid; gap: 18px; align-content: start; }
.detail .zeile { overflow: hidden; padding-bottom: .08em; }
.detail .zeile > * { display: block; translate: 0 110%; transition: translate .9s var(--kurve); }
.detail.offen.zeigt .zeile > * { translate: 0 0; transition: translate 1s var(--kurve) calc(var(--i) * 150ms); }
.detail h2 { font-size: clamp(40px, 5.4vw, 84px); line-height: .92; letter-spacing: -.01em; }
.detail .lauf { font-size: clamp(15px, 1.15vw, 17px); line-height: 1.5; color: rgba(239, 236, 229, .78); max-width: 34ch; }
.detail .zeile > dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 18px; }
.detail dd { color: var(--text); }
.detail .zu { justify-self: start; border-bottom: 1px solid var(--linie); padding-bottom: 4px; color: var(--text); transition: border-color var(--ui) linear; }
.detail .zu:hover, .detail .zu:focus-visible { outline: none; border-bottom-color: var(--text); }
.vorschau .kopf, .vorschau .fuss { display: none; }
.vorschau body { overflow: hidden; }
/* Touch: kein Ring, größere Kacheln (im Skript), schlankere Bedienleiste */
@media (hover: none) {
.ring { display: none; }
.buehne { cursor: auto; }
}
@media (max-width: 640px) {
.werte, .kopf .mitte { display: none; }
.fuss { justify-content: center; }
.fuss .hinweis { display: none; }
.index li button { grid-template-columns: 2.2em 1fr auto; }
.index .l { display: none; }
}
@media (max-width: 900px) { .fuss .hinweis { display: none; } }
/* Reduzierte Bewegung: keine Trägheit, keine Parallaxe, nur Überblenden */
.reduziert .detail .zeile > *, .reduziert .detail.offen.zeigt .zeile > * { translate: none; transition: none; }
.reduziert .buehne, .reduziert .ring, .reduziert .index .n { transition-duration: .15s; }
.reduziert .index li button:hover .n { translate: none; }
@media (forced-colors: active) {
button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; }
}
</style>
</head>
<body>
<div class="buehne" data-buehne tabindex="0" role="region" aria-roledescription="Leinwand" aria-label="Archiv als Leinwand. Mit Pfeiltasten verschieben, mit Tab Projekte wählen." ></div>
<header class="kopf">
<div class="marke-kopf"><strong>Werkraum <em>Ost</em></strong><span class="mono">Archiv 2016–2026</span></div>
<p class="mono mitte glas">24 Arbeiten · 8 Projekte</p>
<div class="knopfreihe mono" role="group" aria-label="Ansicht">
<button type="button" data-ansicht="leinwand" aria-pressed="true">Leinwand</button>
<button type="button" data-ansicht="index" aria-pressed="false">Index</button>
</div>
</header>
<footer class="fuss">
<div class="links">
<div class="knopfreihe mono zoomer" data-zoomer hidden role="group" aria-label="Zoom">
<button type="button" data-zoom="-1" aria-label="Verkleinern">−</button><span class="zahl" data-zoomwert>100 %</span><button type="button" data-zoom="1" aria-label="Vergrößern">+</button>
</div>
<div class="knopfreihe mono" role="group" aria-label="Variante">
<button type="button" data-variante="masonry" aria-pressed="true">Masonry</button>
<button type="button" data-variante="raster" aria-pressed="false">Raster</button>
<button type="button" data-variante="zoom" aria-pressed="false">Zoom</button>
</div>
</div>
<p class="mono mitte hinweis glas">Ziehen · Rad · Pfeiltasten</p>
<p class="mono werte glas" data-werte aria-live="off"></p>
</footer>
<section class="index" data-index aria-label="Projektindex">
<ol data-liste></ol>
</section>
<div class="detail" data-detail role="dialog" aria-modal="true" aria-labelledby="d-titel">
<figure data-fig><img data-figbild alt=""></figure>
<div class="text" data-text>
<div class="zeile"><span class="mono" style="--i:0" data-dnr></span></div>
<div class="zeile"><h2 class="serif" id="d-titel" style="--i:1" data-dname></h2></div>
<div class="zeile"><p class="lauf" style="--i:2" data-dtext></p></div>
<div class="zeile"><dl class="mono" style="--i:3"><dt>Leistung</dt><dd data-dart></dd><dt>Jahr</dt><dd data-djahr></dd></dl></div>
<div class="zeile"><button type="button" class="mono zu" style="--i:4" data-zu>Zurück zur Leinwand</button></div>
</div>
</div>
<div class="ring" data-ring aria-hidden="true"><span data-ringtext>Ziehen</span></div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s);
const buehne = $('[data-buehne]'), ring = $('[data-ring]'), ringText = $('[data-ringtext]');
const werteEl = $('[data-werte]'), zoomer = $('[data-zoomer]'), zoomWert = $('[data-zoomwert]');
const indexEl = $('[data-index]'), detail = $('[data-detail]'), fig = $('[data-fig]'), figBild = $('[data-figbild]');
const M = 'bilder/';
// ---------- Inhalte ----------
const P = [
{ name: 'Eselsohr', art: 'Logo & Ladenschild', jahr: '2018', bild: 'marke-eselsohr', text: 'Buchhandlung mit Lesecafé in einem Eckhaus von 1987. Ein Blatt mit umgeknickter Ecke als Zeichen, eine ruhige Kursive und ein Rosa, das nur an einer Stelle auftaucht.' },
{ name: 'Die Ruhe der Dinge', art: 'Editorial', jahr: '2019', bild: 'magazin-doppelseite', text: 'Hausmagazin einer Keramikwerkstatt. 62 Seiten, ein Raster und viel Weißraum, damit jedes Gefäß Platz zum Atmen hat.' },
{ name: 'Firnis', art: 'Marke & Print', jahr: '2020', bild: 'flakon', text: 'Ein kleines Parfumhaus mit drei Düften. Bernstein im Glas, Kobalt dahinter und ein Etikett, das nur aus einem Wort besteht.' },
{ name: 'Röstkontor', art: 'Corporate Design', jahr: '2021', bild: 'marke-roestkontor', text: 'Kaffeerösterei mit Ausschank. Breite Versalien wie auf alten Frachtkisten, ein Ziegelrot, das im Regal jedem anderen Beutel die Schau stiehlt.' },
{ name: 'Krume', art: 'Marke & Website', jahr: '2022', bild: 'marke-krume', text: 'Backstube für Sauerteig und Kaffee. Eine runde, fette Wortmarke und ein Brotlaib als Zeichen, Dunkelbraun auf Sonnengelb.' },
{ name: 'Tonhaus Reger', art: 'Bildsprache', jahr: '2023', bild: 'keramik-stillleben', text: 'Fotografie für eine Keramikwerkstatt. Jedes Stück auf Gipssockeln vor Terrakotta, hartes Licht und ein Schatten, der mitkomponiert ist.' },
{ name: 'Licht', art: 'Plakatkampagne', jahr: '2024', bild: 'typo-plakat', text: 'Ausstellung über Fotografie und Raum in Halle 4. Eine fette Grotesk, eine Kursive und ein Kreis, der auf jedem Format woanders aufgeht.' },
{ name: 'Webstube Albers', art: 'Etiketten & Musterbuch', jahr: '2016–2024', bild: 'stoff-stapel', text: 'Leinenweberei in dritter Generation. Gewebte Etiketten in sechs Farben, jede muss auch in acht Millimetern Höhe lesbar bleiben.' },
];
// Kachelsatz: 6 Spalten × 4 Kacheln. Formate: h 4:5, q 3:2, s 1:1, k 4:5
const F = { h: 1.25, q: 2 / 3, s: 1, k: 1.25 };
const bild = (p, hoch) => ({ typ: 'bild', p, src: M + P[p].bild + (hoch ? '-hoch' : '') + '.jpg', a: hoch ? 'h' : 'q' });
const farbe = (p, hex, rolle, a = 's') => ({ typ: 'farbe', p, hex, rolle, a, titel: rolle.split(' · ')[0] + ' · Farbe' });
const SPALTEN = [
[bild(0, 1), bild(4, 0), { typ: 'zahl', p: 1, a: 'k', titel: 'Ruhe der Dinge · Umfang' }, bild(7, 1)],
[bild(6, 0), bild(2, 1), farbe(4, '#EDCA6E', 'Krume · Sonnengelb', 'k'), bild(1, 0)],
[farbe(3, '#D1402D', 'Röstkontor · Ziegelrot', 'k'), bild(7, 0), bild(5, 1), bild(0, 0)],
[bild(1, 1), farbe(6, '#DBF440', 'Licht · Neongelb'), bild(2, 0), bild(4, 1)],
[bild(5, 0), bild(3, 1), { typ: 'zitat', p: 7, a: 'k', titel: 'Leitsatz' }, bild(6, 1)],
[farbe(2, '#1A2FC0', 'Firnis · Kobalt'), bild(3, 0), { typ: 'schrift', p: 7, a: 'k', titel: 'Hausschrift' }, { typ: 'studio', p: 7, a: 'k', titel: 'Werkraum Ost' }],
];
const KACHELN = [];
SPALTEN.forEach((sp, c) => sp.forEach((k, r) => KACHELN.push(Object.assign(k, { c, r }))));
// Kachel-Ease 0,5–1,0, fest gesät, damit es bei jedem Laden gleich aussieht
KACHELN.forEach((k, i) => { const z = Math.sin(i * 12.9898 + 4.1) * 43758.5453; k.ease = +(0.5 + 0.5 * (z - Math.floor(z))).toFixed(2); });
function inhalt(k, i) {
if (k.typ === 'bild') return `<img src="${k.src}" alt="" decoding="async" draggable="false">`;
if (k.typ === 'farbe') {
const n = parseInt(k.hex.slice(1), 16), r = n >> 16, g = (n >> 8) & 255, b = n & 255;
const hell = (r * 299 + g * 587 + b * 114) / 1000 > 150;
// Mitteltöne (Ziegelrot): reines Weiß und volle Deckkraft der Labels, sonst unter 4,5:1
const lin = v => (v /= 255) <= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
const weissK = 1.05 / (.2126 * lin(r) + .7152 * lin(g) + .0722 * lin(b) + .05), voll = !hell && weissK < 7;
return `<div class="feld${voll ? ' voll' : ''}" style="background:${k.hex};color:${hell ? '#161615' : voll ? '#fff' : '#efece5'}"><span class="mono">${k.rolle}</span><span><span class="hex">${k.hex}</span><br><span class="mono">RGB ${r} ${g} ${b}</span></span></div>`;
}
if (k.typ === 'zahl') return `<div class="feld" style="background:var(--papier);color:var(--tinte)"><span class="mono">Ruhe der Dinge · Umfang</span><span class="serif gross">62</span><span class="mono">Seiten, ein Raster</span></div>`;
if (k.typ === 'zitat') return `<div class="feld" style="background:#262624;color:var(--text)"><span class="mono">Leitsatz</span><span class="serif satz">„Ein Zeichen muss auch in acht Millimetern funktionieren.“</span><span class="mono">Werkraum Ost</span></div>`;
if (k.typ === 'schrift') return `<div class="feld" style="background:#efe6d2;color:var(--tinte)"><span class="mono">Hausschrift</span><span class="serif gross" style="font-size:56cqi">Aa</span><span class="mono">Instrument Serif Italic</span></div>`;
return `<div class="feld" style="background:var(--text);color:var(--tinte)"><span class="mono">Studio</span><span class="marke">Werkraum Ost.<br>Marken mit Haltung.</span><span class="mono">Köln-Ehrenfeld · seit 2016</span></div>`;
}
// ---------- Varianten ----------
// masonry: Versatz + Tiefenparallaxe; raster: gleichmäßig, ohne Parallaxe; zoom: Masonry, zoomt beim Ziehen heraus
const VAR = {
masonry: { parallaxe: true, zoomZiehen: 1, zMin: 1 },
raster: { parallaxe: false, zoomZiehen: 1, zMin: 1 },
zoom: { parallaxe: true, zoomZiehen: 0.8, zMin: 0.56 },
};
const LERP = 0.1, RAD = 0.4, MAUS = 0.6, FLING = 220, INTRO = 2000, INTRO_VERSATZ = 50, WECHSEL = 1200, WECHSEL_VERSATZ = 20;
let variante = 'masonry';
let vw = innerWidth, vh = innerHeight, u = 200, L = {};
function layout(v) {
const g = 0.3 * u, o = { x: [], y: [], w: [], h: [] };
if (v === 'raster') {
KACHELN.forEach((k, i) => { o.x[i] = k.c * (u + g); o.y[i] = k.r * (u + g); o.w[i] = u; o.h[i] = u; });
o.W = 6 * (u + g); o.H = 4 * (u + g);
} else {
// Spalten gleich breit; die Lücken jeder Spalte werden so verteilt, dass alle Spalten gleich hoch sind (nahtloser Umbruch)
const summen = SPALTEN.map(sp => sp.reduce((s, k) => s + F[k.a] * u, 0));
const H = Math.max(...summen) + 4 * g, versatz = [0, .42, .18, .63, .3, .08];
const y = SPALTEN.map((_, c) => versatz[c] * u);
KACHELN.forEach((k, i) => {
const gap = (H - summen[k.c]) / 4;
o.x[i] = k.c * (u + g); o.y[i] = y[k.c]; o.w[i] = u; o.h[i] = F[k.a] * u; y[k.c] += o.h[i] + gap;
});
o.W = 6 * (u + g); o.H = H;
}
// Wie viele Kopien des Satzes braucht es, damit keine Kante sichtbar wird?
const zm = VAR[v].zMin, maxH = 1.25 * u;
o.nx = Math.max(1, Math.ceil((vw / zm + 2 * u + u) / o.W));
o.ny = Math.max(1, Math.ceil((vh / zm + 2 * maxH + u) / o.H));
return o;
}
// ---------- Zustand ----------
const ziel = { x: 0, y: 0 }, ist = { x: 0, y: 0 };
const maus = { x: 0.5, y: 0.5 }, par = { x: 0, y: 0 };
let z = 1, zRuhe = 1, zieht = false;
let el = [], anim = null; // anim: { t0, dauer, versatz, von[], rang[] }
function bauen() {
vw = innerWidth; vh = innerHeight;
u = vw < 640 ? Math.min(200, Math.max(140, vw * 0.42)) : Math.min(230, Math.max(150, vw * 0.135));
const pool = Math.max(...Object.keys(VAR).map(v => { const o = layout(v); return o.nx * o.ny; }));
L = layout(variante);
buehne.innerHTML = '';
el = [];
for (let k = 0; k < pool; k++) KACHELN.forEach((t, i) => {
const echt = k === 0;
const e = document.createElement(echt ? 'button' : 'div');
e.className = 'kachel';
if (echt) { e.type = 'button'; e.setAttribute('aria-label', `${P[t.p].name}, ${P[t.p].art}, ${P[t.p].jahr}. Öffnen`); }
else { e.setAttribute('aria-hidden', 'true'); e.inert = true; }
e.innerHTML = `<span class="rahmen">${inhalt(t, i)}</span><span class="mono unter"><span>${String(i + 1).padStart(2, '0')}</span><b>${t.titel || P[t.p].name}</b></span>`;
e._t = i; e._k = k; e._img = e.querySelector('img'); e._w = 0; e._h = 0; e._sicht = false; e._r = { x: vw / 2, y: vh / 2, s: 0.25, o: 0, w: 0, h: 0 };
buehne.appendChild(e); el.push(e);
});
}
const mod = (a, n) => ((a % n) + n) % n;
const klemme = (a, m) => Math.max(-m, Math.min(m, a));
const expoInOut = p => p <= 0 ? 0 : p >= 1 ? 1 : p < .5 ? Math.pow(2, 20 * p - 10) / 2 : (2 - Math.pow(2, -20 * p + 10)) / 2;
// Zielrechteck einer Kachel auf dem Bildschirm (ohne Intro/Wechsel)
function zielRect(e, out) {
const t = e._t, V = VAR[variante], n = L.nx * L.ny;
if (e._k >= n) { out.aus = true; return out; }
out.aus = false;
const kx = e._k % L.nx, ky = Math.floor(e._k / L.nx), TW = L.nx * L.W, TH = L.ny * L.H;
const ease = KACHELN[t].ease, red = reduziert();
let lx = 0, ly = 0, mx = 0, my = 0;
if (V.parallaxe && !red) {
// Träge Kacheln bleiben beim Ziehen zurück (Faktor 1 − Ease), höchstens 0,35 Kachelbreiten
lx = klemme((ist.x - ziel.x) * (1 - ease) * 0.6, 0.35 * u);
ly = klemme((ist.y - ziel.y) * (1 - ease) * 0.6, 0.35 * u);
mx = par.x * ease; my = par.y * ease;
}
const rx = mod(L.x[t] + kx * L.W + ist.x + lx, TW) - TW / 2;
const ry = mod(L.y[t] + ky * L.H + ist.y + ly, TH) - TH / 2;
out.rx = rx; out.ry = ry;
out.x = vw / 2 + (rx + mx) * z; out.y = vh / 2 + (ry + my) * z;
out.w = L.w[t]; out.h = L.h[t]; out.s = z; out.o = 1;
return out;
}
const tmp = {};
let letzte = performance.now();
function frame(t) {
const dt = Math.min(64, t - letzte) / 16.667; letzte = t;
const red = reduziert(), V = VAR[variante];
const k = red ? 1 : 1 - Math.pow(1 - LERP, dt); // zeitbasiertes Lerp
ist.x += (ziel.x - ist.x) * k; ist.y += (ziel.y - ist.y) * k;
const pz = V.parallaxe && !red && !(hoverlos) ? 1 : 0;
par.x += (-(maus.x - 0.5) * u * MAUS * pz - par.x) * k;
par.y += (-(maus.y - 0.5) * u * MAUS * pz - par.y) * k;
const zz = (variante === 'zoom' ? zRuhe : 1) * (zieht && !red ? V.zoomZiehen : 1);
z += (zz - z) * k;
for (const e of el) {
zielRect(e, tmp);
let r = tmp;
if (anim && !tmp.aus) {
const v = anim.von[e._k * KACHELN.length + e._t];
const p = Math.max(0, Math.min(1, (t - anim.t0 - anim.rang[e._k * KACHELN.length + e._t] * anim.versatz) / anim.dauer));
const q = red ? p : expoInOut(p);
if (v && p < 1) {
r = { x: v.x + (tmp.x - v.x) * q, y: v.y + (tmp.y - v.y) * q, w: v.w + (tmp.w - v.w) * q, h: v.h + (tmp.h - v.h) * q, s: v.s + (tmp.s - v.s) * q, o: red ? p : Math.min(1, v.o + q * 3), aus: false };
}
}
const sichtbar = !r.aus && r.x < vw + 40 && r.x + r.w * r.s > -40 && r.y < vh + 40 && r.y + (r.h + 24) * r.s > -40;
if (sichtbar !== e._sicht) { e.style.visibility = sichtbar ? 'visible' : 'hidden'; e._sicht = sichtbar; }
e._r.x = r.x; e._r.y = r.y; e._r.w = r.w; e._r.h = r.h; e._r.s = r.s; e._r.o = r.o; e._r.sicht = sichtbar;
if (!sichtbar) continue;
if (Math.abs(e._w - r.w) > .3 || Math.abs(e._h - r.h) > .3) { e.style.width = r.w + 'px'; e.style.height = r.h + 'px'; e._w = r.w; e._h = r.h; }
e.style.transform = `translate3d(${r.x.toFixed(2)}px, ${r.y.toFixed(2)}px, 0) scale(${r.s.toFixed(4)})`;
if (e._o !== r.o) { e.style.opacity = r.o >= 1 ? '' : r.o.toFixed(3); e._o = r.o; }
if (e._img) {
// Bild im Rahmen wandert gegen die Lage auf dem Bildschirm: zusätzliche Tiefe
if (V.parallaxe && !red) {
const nx = ((r.x + r.w * r.s / 2) / vw - .5), ny = ((r.y + r.h * r.s / 2) / vh - .5);
e._img.style.transform = `translate(${(-nx * 6).toFixed(2)}%, ${(-ny * 6).toFixed(2)}%)`;
} else if (e._img.style.transform) e._img.style.transform = '';
}
}
if (anim && t - anim.t0 > anim.ende) anim = null;
werteZeigen();
ringFrame(dt, k);
requestAnimationFrame(frame);
}
// Intro (alle von der Mitte) oder Variantenwechsel (von der alten Lage)
function animieren(intro) {
const n = KACHELN.length, von = [], rang = [], liste = [];
const t0 = performance.now();
for (const e of el) {
const i = e._k * n + e._t;
zielRect(e, tmp);
if (reduziert()) von[i] = intro ? { ...tmp, o: 0 } : null; // nur einblenden
else if (intro) von[i] = { x: vw / 2 - tmp.w * .125, y: vh / 2 - tmp.h * .125, w: tmp.w, h: tmp.h, s: .25, o: 0 };
else von[i] = e._r.sicht ? { ...e._r } : { x: tmp.x + tmp.w * .2 * z, y: tmp.y + tmp.h * .2 * z, w: tmp.w, h: tmp.h, s: z * .6, o: 0 };
if (!tmp.aus) liste.push({ i, d: Math.hypot(tmp.x + tmp.w / 2 - vw / 2, tmp.y + tmp.h / 2 - vh / 2) });
}
liste.sort((a, b) => a.d - b.d);
liste.forEach((l, r) => { rang[l.i] = Math.min(r, intro ? 24 : 30); });
el.forEach(e => { const i = e._k * n + e._t; if (rang[i] == null) rang[i] = 0; });
const red = reduziert();
const dauer = red ? 400 : intro ? INTRO : WECHSEL, versatz = red ? 0 : intro ? INTRO_VERSATZ : WECHSEL_VERSATZ;
anim = { t0, dauer, versatz, von, rang, ende: dauer + versatz * (intro ? 24 : 30) + 50 };
if (red && !intro) anim = null;
}
function setzeVariante(v, sofort) {
if (!VAR[v]) return;
document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
zoomer.hidden = v !== 'zoom';
if (v === variante && !sofort) return;
variante = v;
L = layout(v);
zRuhe = 1;
if (!sofort) animieren(false);
}
// ---------- Werte-Anzeige ----------
let werteText = '';
function werteZeigen() {
const red = reduziert(), V = VAR[variante];
const z1 = red ? 'lerp 1 · keine Parallaxe' : `lerp ${LERP.toFixed(2)} · rad × ${RAD}` + (V.parallaxe ? ` · ease 0.5–1.0 · maus ±30 %` : ' · ohne Parallaxe');
const z2 = variante === 'zoom' ? `zoom beim Ziehen × ${V.zoomZiehen} · ${Math.round(z * 100)} %` : `intro ${INTRO} ms expo.inOut · ${INTRO_VERSATZ} ms`;
const z3 = `x ${String(Math.round(-ist.x)).padStart(5, ' ')} y ${String(Math.round(-ist.y)).padStart(5, ' ')}`;
const s = `<span>${z1}</span><span>${z2}</span><span>${z3}</span>`;
if (s !== werteText) { werteEl.innerHTML = s; werteText = s; }
if (variante === 'zoom') zoomWert.textContent = Math.round(zRuhe * 100) + ' %';
}
// ---------- Eingabe ----------
const hoverlos = matchMedia('(hover: none)').matches;
let runter = null, letzterPunkt = null, tempo = { x: 0, y: 0 }, letzteZeit = 0;
function ziehenStart(x, y) { zieht = true; buehne.classList.add('zieht'); ring.classList.add('zieht'); letzterPunkt = { x, y }; tempo = { x: 0, y: 0 }; letzteZeit = performance.now(); }
function ziehenBewegen(x, y) {
const now = performance.now(), d = Math.max(1, now - letzteZeit);
const dx = x - letzterPunkt.x, dy = y - letzterPunkt.y;
ziel.x += dx / z; ziel.y += dy / z;
tempo.x = tempo.x * .7 + (dx / d) * .3; tempo.y = tempo.y * .7 + (dy / d) * .3;
letzterPunkt = { x, y }; letzteZeit = now;
}
function ziehenEnde() {
zieht = false; buehne.classList.remove('zieht'); ring.classList.remove('zieht');
if (!reduziert() && performance.now() - letzteZeit < 80) { ziel.x += tempo.x * FLING / z; ziel.y += tempo.y * FLING / z; }
}
buehne.addEventListener('pointerdown', e => {
if (e.button !== 0 || offen) return;
nutzerDa();
runter = { x: e.clientX, y: e.clientY, kachel: e.target.closest('.kachel'), weit: false };
buehne.setPointerCapture(e.pointerId);
ziehenStart(e.clientX, e.clientY);
});
buehne.addEventListener('pointermove', e => {
if (!runter) return;
if (Math.hypot(e.clientX - runter.x, e.clientY - runter.y) > 5) runter.weit = true;
ziehenBewegen(e.clientX, e.clientY);
});
const hoch = e => {
if (!runter) return;
const r = runter; runter = null;
ziehenEnde();
if (!r.weit && r.kachel) oeffnen(r.kachel); // Klick zählt nur unter 5 px Bewegung
};
buehne.addEventListener('pointerup', hoch);
buehne.addEventListener('pointercancel', hoch);
buehne.addEventListener('click', e => { const k = e.target.closest('.kachel'); if (k && e.detail === 0) oeffnen(k); }); // Tastatur
buehne.addEventListener('focusin', e => {
const k = e.target.closest('.kachel'); if (!k || runter) return;
zielRect(k, tmp); ziel.x -= tmp.rx + tmp.w / 2; ziel.y -= tmp.ry + tmp.h / 2; // kürzester Weg in die Mitte
});
addEventListener('wheel', e => {
if (offen || indexOffen) return;
e.preventDefault(); nutzerDa();
const f = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? vh : 1;
if ((e.ctrlKey || e.metaKey) && variante === 'zoom') { zRuhe = Math.max(0.7, Math.min(1.2, zRuhe * Math.exp(-e.deltaY * f * 0.002))); return; }
ziel.x -= e.deltaX * f * RAD; ziel.y -= e.deltaY * f * RAD;
}, { passive: false });
addEventListener('keydown', e => {
if (e.key === 'Escape') { if (offen) schliessen(); else if (indexOffen) ansicht('leinwand'); return; }
if (offen || indexOffen) return;
if (document.activeElement && document.activeElement.closest('.knopfreihe')) return;
const s = 0.75 * u, m = { ArrowLeft: [s, 0], ArrowRight: [-s, 0], ArrowUp: [0, s], ArrowDown: [0, -s] }[e.key];
if (m) { e.preventDefault(); nutzerDa(); ziel.x += m[0]; ziel.y += m[1]; }
});
addEventListener('pointermove', e => {
if (e.pointerType !== 'mouse' || html.classList.contains('vorfuehrung')) return;
if (!e.clientX && !e.clientY) return;
maus.x = e.clientX / vw; maus.y = e.clientY / vh;
ringZiel.x = e.clientX; ringZiel.y = e.clientY; ring.classList.add('an');
if (offen) return ringUeber(true, 'Zurück');
const k = e.target.closest && e.target.closest('.kachel');
ringUeber(!!k && !zieht);
});
document.addEventListener('pointerleave', () => ring.classList.remove('an'));
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { nutzerDa(); setzeVariante(b.dataset.variante); }));
document.querySelectorAll('[data-zoom]').forEach(b => b.addEventListener('click', () => { zRuhe = Math.max(0.7, Math.min(1.2, +(zRuhe + b.dataset.zoom * 0.1).toFixed(2))); }));
// ---------- Zeiger-Ring ----------
const ringZiel = { x: -100, y: -100 }, ringIst = { x: -100, y: -100 };
function ringUeber(an, text) { ring.classList.toggle('ueber', an); ringText.textContent = text || (an ? 'Öffnen' : 'Ziehen'); }
function ringFrame(dt, k) {
const f = reduziert() ? 1 : 1 - Math.pow(1 - 0.25, dt);
ringIst.x += (ringZiel.x - ringIst.x) * f; ringIst.y += (ringZiel.y - ringIst.y) * f;
ring.style.transform = `translate3d(${ringIst.x}px, ${ringIst.y}px, 0)`;
}
// ---------- Index (Listenansicht) ----------
const liste = $('[data-liste]');
liste.innerHTML = P.map((p, i) => `<li><button type="button" data-p="${i}"><span class="mono">${String(i + 1).padStart(2, '0')}</span><span class="n">${p.name}</span><span class="mono l">${p.art}</span><span class="mono j">${p.jahr}</span></button></li>`).join('');
let indexOffen = false;
function ansicht(a) {
indexOffen = a === 'index';
indexEl.classList.toggle('offen', indexOffen);
buehne.inert = indexOffen;
document.querySelectorAll('[data-ansicht]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.ansicht === a)));
if (indexOffen) liste.querySelector('button').focus({ preventScroll: true });
}
document.querySelectorAll('[data-ansicht]').forEach(b => b.addEventListener('click', () => { nutzerDa(); ansicht(b.dataset.ansicht); }));
liste.addEventListener('click', e => {
const b = e.target.closest('[data-p]'); if (!b) return;
const p = +b.dataset.p;
ansicht('leinwand');
// erste sichtbare Kachel dieses Projekts öffnen
const k = el.filter(x => x._r.sicht && KACHELN[x._t].p === p && KACHELN[x._t].typ === 'bild')[0] || el.find(x => x._k === 0 && KACHELN[x._t].p === p);
oeffnen(k);
});
// ---------- Detail (FLIP) ----------
let offen = null;
const textEl = $('[data-text]');
function detailMass(ratio) {
if (vw >= 760) {
const maxW = vw * 0.5, maxH = vh * 0.72;
const w = Math.min(maxW, maxH * ratio), h = w / ratio;
return { fig: { left: vw * 0.07, top: (vh - h) / 2, width: w, height: h }, text: { left: vw * 0.64, top: vh * 0.5, width: vw * 0.29, mitte: true } };
}
const w0 = vw - 32, h0 = Math.min(w0 / ratio, vh * 0.42), w = h0 * ratio;
return { fig: { left: (vw - w) / 2, top: 76, width: w, height: h0 }, text: { left: 20, top: 76 + h0 + 26, width: vw - 40 } };
}
function kachelRect(e) { const r = e._r; return { left: r.x, top: r.y, width: r.w * r.s, height: r.h * r.s }; }
const px = o => ({ left: o.left + 'px', top: o.top + 'px', width: o.width + 'px', height: o.height + 'px' });
function oeffnen(e) {
if (!e || offen) return;
const t = KACHELN[e._t], p = P[t.p];
const src = t.typ === 'bild' ? t.src : M + p.bild + '-hoch.jpg';
const ratio = t.typ === 'bild' && t.a === 'q' ? 1.5 : 0.8;
figBild.src = src; figBild.alt = p.name;
$('[data-dnr]').textContent = `Projekt ${String(t.p + 1).padStart(2, '0')} / 08`;
$('[data-dname]').textContent = p.name; $('[data-dtext]').textContent = p.text;
$('[data-dart]').textContent = p.art; $('[data-djahr]').textContent = p.jahr;
const m = detailMass(ratio);
Object.assign(textEl.style, { left: m.text.left + 'px', top: m.text.top + 'px', width: m.text.width + 'px', translate: m.text.mitte ? '0 -50%' : '' });
offen = { e, m };
const start = kachelRect(e);
Object.assign(fig.style, px(start));
detail.classList.add('offen');
e.classList.add('weg');
buehne.classList.add('gedimmt');
buehne.inert = true;
const red = reduziert();
if (red) { Object.assign(fig.style, px(m.fig)); fig.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 150 }); }
else fig.animate([px(start), px(m.fig)], { duration: 1200, easing: 'cubic-bezier(0.87, 0, 0.13, 1)', fill: 'forwards' }).finished.then(() => Object.assign(fig.style, px(m.fig))).catch(() => {});
requestAnimationFrame(() => requestAnimationFrame(() => detail.classList.add('zeigt')));
if (!html.classList.contains('vorfuehrung')) setTimeout(() => offen && $('[data-zu]').focus({ preventScroll: true }), red ? 0 : 900);
ringUeber(true, 'Zurück');
}
function schliessen() {
if (!offen) return;
const { e, m } = offen;
detail.classList.remove('zeigt');
buehne.classList.remove('gedimmt');
const ende = () => {
fig.getAnimations().forEach(a => a.cancel());
detail.classList.remove('offen'); e.classList.remove('weg'); buehne.inert = false;
if (e._k === 0 && !html.classList.contains('vorfuehrung')) e.focus({ preventScroll: true });
offen = null; ringUeber(false);
};
if (reduziert()) return ende();
fig.getAnimations().forEach(a => a.cancel());
Object.assign(fig.style, px(m.fig));
fig.animate([px(m.fig), px(kachelRect(e))], { duration: 1000, easing: 'cubic-bezier(0.87, 0, 0.13, 1)', fill: 'forwards' }).finished.then(ende).catch(ende);
}
$('[data-zu]').addEventListener('click', schliessen);
detail.addEventListener('click', e => { if (e.target === detail) schliessen(); });
fig.addEventListener('click', schliessen);
// ---------- Vorführung: gedachter Zeiger zieht, wirft, öffnet; jede Runde eine andere Variante ----------
// Abschnitte (Sekunden): Masonry 0–10 (ziehen, werfen, Kachel öffnen), Zoom 10–17, Raster 17–24
const ABSCHNITTE = [
{ v: 'masonry', von: 0, bis: 10, zug: [[.66, .62], [.30, .36]], oeffnen: true },
{ v: 'zoom', von: 10, bis: 17, zug: [[.30, .40], [.72, .66]] },
{ v: 'raster', von: 17, bis: 24, zug: [[.52, .74], [.46, .26]] },
];
const RUNDE = 24;
let uhr = null, vStart = 0, vZeit = 0, vLetzt = -1, vZieht = false, vAuf = false;
const glatt = p => p * p * (3 - 2 * p);
function vSchritt(now) {
if (now < vStart) { ringZiel.x = .86 * vw; ringZiel.y = .9 * vh; uhr = requestAnimationFrame(vSchritt); return; } // Intro abwarten
const T = ((now - vStart) / 1000) % RUNDE, a = ABSCHNITTE.find(x => T >= x.von && T < x.bis), s = T - a.von;
vZeit = now - vStart;
if (vLetzt > T || (vLetzt < a.von && T >= a.von)) { if (offen) schliessen(); setzeVariante(a.v); vAuf = false; }
vLetzt = T;
// Zeigerweg
const [p0, p1] = a.zug;
let x, y;
if (s < 1.4) { const q = glatt(Math.min(1, s / 1.2)); x = (.86 + (p0[0] - .86) * q) * vw; y = (.9 + (p0[1] - .9) * q) * vh; }
else if (s < 4.2) {
const q = glatt((s - 1.4) / 2.8);
x = (p0[0] + (p1[0] - p0[0]) * q) * vw + Math.sin(q * Math.PI) * vw * .05;
y = (p0[1] + (p1[1] - p0[1]) * q) * vh - Math.sin(q * Math.PI) * vh * .06;
if (!vZieht) { vZieht = true; ziehenStart(x, y); } else ziehenBewegen(x, y);
} else {
if (vZieht) { vZieht = false; ziehenEnde(); }
x = p1[0] * vw; y = p1[1] * vh;
if (a.oeffnen) {
// zur Kachel nahe der Bildmitte gleiten, bei 5,8 s öffnen, bei 8,4 s schließen
const kand = el.filter(e => e._r.sicht && e._r.o >= 1 && KACHELN[e._t].typ === 'bild');
let best = null, bd = 1e9;
for (const e of kand) { const cx = e._r.x + e._r.w * e._r.s / 2, cy = e._r.y + e._r.h * e._r.s / 2, d = Math.hypot(cx - vw * .5, cy - vh * .5); if (d < bd) { bd = d; best = e; } }
if (best && !offen && s < 5.8) { const q = glatt(Math.min(1, (s - 4.2) / 1.4)); x += (best._r.x + best._r.w * best._r.s / 2 - x) * q; y += (best._r.y + best._r.h * best._r.s / 2 - y) * q; ringUeber(q > .7); }
if (s >= 5.8 && s < 8.4 && !offen && best && !vAuf) { vAuf = true; oeffnen(best); }
if (s >= 8.4 && offen) schliessen();
if (offen) { x = ringZiel.x; y = ringZiel.y; }
}
}
ringZiel.x = x; ringZiel.y = y;
maus.x = x / vw; maus.y = y / vh;
uhr = requestAnimationFrame(vSchritt);
}
function vorfuehren() {
pause();
html.classList.add('vorfuehrung');
ring.classList.add('an');
// Läuft das Intro noch, beginnt der erste Zug erst danach
const rest = anim ? Math.max(0, anim.t0 + anim.ende - performance.now()) : 0;
vStart = performance.now() - vZeit + Math.max(0, rest - 1000);
vLetzt = vZeit ? vLetzt : -1;
uhr = requestAnimationFrame(vSchritt);
}
function pause() { cancelAnimationFrame(uhr); uhr = null; if (vZieht) { vZieht = false; ziehenEnde(); } }
function weiter() { if (!uhr && html.classList.contains('vorfuehrung')) vorfuehren(); }
function nutzerDa() { if (html.classList.contains('vorfuehrung')) { pause(); html.classList.remove('vorfuehrung'); vZeit = 0; ring.classList.remove('an'); ringUeber(false); } }
function neustart() {
pause(); html.classList.remove('vorfuehrung'); vZeit = 0; vLetzt = -1;
if (offen) { fig.getAnimations().forEach(a => a.cancel()); detail.classList.remove('offen', 'zeigt'); offen.e.classList.remove('weg'); buehne.classList.remove('gedimmt'); buehne.inert = false; offen = null; }
ansicht('leinwand');
ziel.x = ist.x = 0; ziel.y = ist.y = 0; zRuhe = z = 1;
setzeVariante('masonry', true);
animieren(true);
}
let rs = 0;
addEventListener('resize', () => { clearTimeout(rs); rs = setTimeout(() => { if (innerWidth === vw && innerHeight === vh) return; bauen(); }, 150); });
bauen();
animieren(true);
requestAnimationFrame(frame);
})();
</script>
</body>
</html>