Zum Inhalt
← Galerien & Slider

Kuppel- und Kugel-Galerie

Das Winterprogramm eines Kulturhauses als Kuppel zum Umsehen oder als Globus: ziehen mit Trägheit, Klick fährt die Kamera an das Plakat und öffnet Termin und Tickets.

Bilder liegen auf einer Kugeloberfläche oder in einer Kuppel, verteilt in Ringen oder als Fibonacci-Kugel, jedes zur Mitte bzw. nach außen gedreht. Die Kamera sitzt innen (Kuppel, man schaut sich um) oder außen (Globus). Ziehen dreht mit Trägheit, ohne Eingabe dreht sie langsam weiter. Ein Klick fährt die Kamera an das Bild heran oder öffnet es als Vollbild. Ohne WebGL geht es auch mit CSS 3D (rotateX/rotateY pro Kachel).

Maximale Räumlichkeit und Neugier: Die Galerie wird zu einem Raum, in dem man sich umsieht.

Wann einsetzen

Kampagnen, Festivals, Kunst- und Archivprojekte, Programme mit vielen gleichrangigen Einträgen, Studio-Showcases mit vielen kleinen Arbeiten.

Wann nicht

Wenn Inhalte geordnet durchsucht werden müssen oder die Zielhardware schwach ist. Dann nur mit einer gleichwertigen Liste daneben.

Varianten

  • Kuppel von innen (Infinite Dome Grid)
  • Globus von außen (Globe Gallery)
  • Fortlaufend rotierende Galerie in WebGL (OGL)
  • Kreisförmige Endlosgalerie mit Krümmung (Infinite Circular Gallery)

Umsetzung

Fibonacci-Verteilung: y = 1 − (2i + 1) / n, r = √(1 − y²), θ = i × 2,39996 (goldener Winkel); Kachel per lookAt ausrichten. Kuppel: Ringe auf festen Breitengraden, Kamera im Mittelpunkt. Drehung als Gier- und Nickwinkel (oder Quaternion) mit Dämpfung, typisch Geschwindigkeit × 0,92 bis 0,96 pro Frame. Zum Öffnen Zielwinkel aus Länge und Breite der Kachel berechnen und den kürzesten Weg nehmen. Bilder als Texturatlas oder kleine Texturen, Klicks per Raycaster. Ohne WebGL eine Liste als Fallback.

Robust umsetzen

Telefon. Weniger Kacheln, dafür größer. Gyroskop höchstens nach ausdrücklicher Erlaubnis. Die Liste bleibt als Ansicht erreichbar.

Ohne Bewegung. Keine Autorotation und kein Schwung; statische Liste oder statisches Raster als Standard.

Barrierefrei. Canvas ist dekorativ (aria-hidden), die echte Liste der Termine ist eine gleichwertige Ansicht. Tastatur: Pfeile drehen, Enter öffnet das Bild in der Mitte, Esc schließt. Farbe nie allein: jede Sparte trägt ein Text-Label.

Code

<!DOCTYPE html>
<!--
  Kuppel- und Kugel-Galerie
  Fundus · Galerien & Slider · https://cecodesign.de/lab/fundus/galerien-slider/kuppel-kugel-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>Kuppel- und Kugel-Galerie · 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: 'Schibsted Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/schibsted-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'IBM Plex Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/ibm-plex-mono@latest/latin-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; }

/* Kuppelhaus Beuel, Bonn – Rezept „Programm und Haltung“:
   Schwarz auf warmem Off-White, je Sparte eine Farbe mit Text-Label, Programm vor Image. */
:root {
  --grund: #efede9; --text: #0b0b0b; --leise: #5e5b55; --linie: #0b0b0b; --hauch: rgba(11, 11, 11, .14);
  --musik: #c4421a; --buehne: #3d5f9a; --kunst: #2f7d4a; --film: #b0186b;
  --ui-kurve: cubic-bezier(0.215, 0.61, 0.355, 1);   /* Rezept: kurze Out-Kurve für UI */
  --flug: cubic-bezier(0.76, 0, 0.24, 1);           /* power3.inOut, Kamerafahrt des Musters */
  --rand: clamp(16px, 2.6vw, 36px);
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; font-size: 100%; -webkit-font-smoothing: antialiased; }
body { height: 100svh; display: grid; grid-template-rows: auto auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr); }
.modus-liste body { height: auto; min-height: 100svh; }
main { min-height: 0; display: grid; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
a { color: inherit; }
:focus-visible { outline: 3px solid var(--buehne); outline-offset: 2px; }
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-weight: 500; font-size: .8125rem; letter-spacing: .06em; text-transform: uppercase; font-variant-numeric: tabular-nums; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* Navigation: klare Unterkante, Ticket immer erreichbar */
.nav { position: sticky; top: 0; z-index: 10; height: 64px; display: flex; align-items: center; gap: 28px; padding: 0 var(--rand); background: var(--grund); border-bottom: 1px solid var(--linie); }
.logo { display: flex; align-items: center; gap: 10px; margin-right: auto; text-decoration: none; font: 900 1.375rem/1 'Schibsted Grotesk', sans-serif; letter-spacing: -0.02em; }
.logo svg { width: 30px; height: 30px; }
.nav ul { display: flex; gap: 22px; list-style: none; padding: 0; font-size: 1.0625rem; }
.nav ul a { text-decoration: none; }
.nav ul a:hover { text-decoration: underline; text-underline-offset: .2em; text-decoration-thickness: 2px; }
.nav .vorlesen { display: inline-flex; align-items: center; gap: 6px; }
.ticket { display: inline-flex; align-items: center; height: 40px; padding: 0 18px; border-radius: 999px; background: var(--text); color: var(--grund); font-weight: 700; text-decoration: none; transition: background-color 200ms var(--ui-kurve); }
.ticket:hover { background: var(--musik); }

/* Kopf der Programmseite */
.kopf { display: flex; justify-content: space-between; align-items: flex-end; gap: 18px 32px; flex-wrap: wrap; padding: clamp(16px, 3vh, 32px) var(--rand) 16px; border-bottom: 1px solid var(--hauch); }
h1 { font: 900 clamp(2.75rem, min(7vw, 10vh), 7rem)/1 'Schibsted Grotesk', sans-serif; letter-spacing: -0.02em; }
.kopf .unter { margin-top: 10px; color: var(--leise); }
.rechts { display: flex; flex-direction: column; align-items: flex-end; gap: 12px; }
.sparten { display: flex; flex-wrap: wrap; gap: 6px 16px; list-style: none; padding: 0; }
.sparten li { display: inline-flex; align-items: center; gap: 7px; }
.sparten i { width: 12px; height: 12px; display: inline-block; }
.ansicht { display: flex; border: 1px solid var(--linie); border-radius: 999px; padding: 3px; gap: 2px; }
.ansicht button { height: 34px; padding: 0 16px; border-radius: 999px; font-weight: 600; font-size: .9375rem; transition: background-color 200ms var(--ui-kurve), color 200ms var(--ui-kurve); }
.ansicht button[aria-pressed='true'] { background: var(--text); color: var(--grund); }
.ansicht button:not([aria-pressed='true']):hover { background: var(--hauch); }

/* Bühne mit Canvas */
.buehne { position: relative; min-height: 360px; overflow: hidden; touch-action: none; outline: none; }
.buehne canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; }
.buehne.zieht canvas { cursor: grabbing; }
.buehne.zeigt canvas { cursor: pointer; }
.modus-liste .buehne { display: none; }
.hinweis { position: absolute; left: var(--rand); bottom: 18px; display: flex; flex-direction: column; gap: 6px; padding: 8px 12px; margin-left: -12px; background: rgba(239, 237, 233, .88); pointer-events: none; max-width: min(560px, 60%); }
.hinweis .jetzt:empty { display: none; }
.hinweis .jetzt { font: 700 1.125rem/1.25 'Schibsted Grotesk', sans-serif; min-height: 1.25em; transition: opacity 200ms var(--ui-kurve); }
.hinweis .jetzt span { font-family: 'IBM Plex Mono', monospace; font-weight: 500; font-size: .8125rem; letter-spacing: .06em; margin-right: 10px; }
.buehne:focus-visible::after { content: ''; position: absolute; inset: 4px; outline: 3px solid var(--buehne); pointer-events: none; }
.ohne-webgl { position: absolute; inset: 0; display: none; place-content: center; text-align: center; gap: 10px; padding: 24px; }
.kein-webgl .ohne-webgl { display: grid; }
.ohne-webgl b { font: 800 1.5rem/1.2 'Schibsted Grotesk', sans-serif; }

/* Karte zur Veranstaltung */
.karte { position: absolute; z-index: 3; top: 20px; right: var(--rand); bottom: 20px; width: min(430px, 38%); display: flex; flex-direction: column; background: var(--grund); border: 1px solid var(--linie); visibility: hidden; opacity: 0; translate: 24px 0; transition: opacity 250ms var(--ui-kurve), translate 250ms var(--ui-kurve), visibility 0s 250ms; overflow: auto; }
.karte.an { visibility: visible; opacity: 1; translate: 0 0; transition: opacity 250ms var(--ui-kurve), translate 250ms var(--ui-kurve), visibility 0s; }
.karte .balken { height: 10px; background: var(--farbe, var(--text)); flex: none; }
.karte .innen { padding: 22px 24px 24px; display: flex; flex-direction: column; gap: 14px; flex: 1; }
.karte .zeile1 { display: flex; justify-content: space-between; align-items: center; }
.karte .sparte { display: inline-flex; align-items: center; gap: 8px; }
.karte .sparte i { width: 12px; height: 12px; background: var(--farbe); }
.karte .zu { font-weight: 600; text-decoration: underline; text-underline-offset: .2em; }
.karte h2 { font: 800 clamp(1.75rem, 1.2rem + 1.6vw, 2.5rem)/1.02 'Schibsted Grotesk', sans-serif; letter-spacing: -0.02em; text-wrap: balance; }
.karte .untertitel { font-size: 1.125rem; line-height: 1.4; color: var(--leise); }
.karte dl { display: grid; grid-template-columns: auto 1fr; gap: 0 16px; border-top: 1px solid var(--linie); }
.karte dt, .karte dd { padding: 9px 0; border-bottom: 1px solid var(--hauch); }
.karte dd { font-size: 1.0625rem; text-transform: none; letter-spacing: .02em; }
.karte .text { font-size: 1.125rem; line-height: 1.6; max-width: 68ch; }
.karte .meta { display: none; line-height: 1.5; }
.karte .aktionen { margin-top: auto; display: flex; gap: 10px; align-items: center; }
.karte .aktionen .ticket { height: 46px; padding: 0 22px; }
.merken { height: 46px; padding: 0 18px; border: 1px solid var(--linie); border-radius: 999px; font-weight: 600; }

/* Liste: echte Alternative, Standard bei reduzierter Bewegung */
.liste { display: none; padding: 0 var(--rand) 48px; }
.modus-liste .liste { display: block; }
.liste ol { list-style: none; padding: 0; }
.liste li { display: grid; grid-template-columns: 9.5rem minmax(0, 1fr) 11rem 9rem 7.5rem; gap: 6px 24px; align-items: center; padding: 16px 0; border-bottom: 1px solid var(--hauch); }
.liste .datum { line-height: 1.5; }
.liste h3 { font: 700 clamp(1.375rem, 2.2vw, 2rem)/1.1 'Schibsted Grotesk', sans-serif; letter-spacing: -0.01em; }
.liste p { color: var(--leise); margin-top: 4px; }
.liste .sp { display: inline-flex; align-items: center; gap: 8px; }
.liste .sp i { width: 12px; height: 12px; background: var(--farbe); }
.liste .tk { justify-self: end; height: 38px; padding: 0 16px; border: 1px solid var(--linie); border-radius: 999px; display: inline-flex; align-items: center; font-weight: 600; text-decoration: none; transition: background-color 200ms var(--ui-kurve), color 200ms var(--ui-kurve); }
.liste .tk:hover { background: var(--text); color: var(--grund); }
.liste .gesamt { padding: 18px 0 6px; color: var(--leise); }
.kein-webgl .liste .gesamt::before { content: 'WebGL ist hier nicht verfügbar, deshalb die Liste · '; color: var(--text); }

/* Werte, dezent */
.werte { position: absolute; z-index: 2; right: var(--rand); bottom: 18px; display: flex; flex-direction: column; align-items: flex-end; gap: 4px; padding: 8px 12px; background: rgba(239, 237, 233, .86); font: 500 10.5px/1.35 'IBM Plex Mono', monospace; letter-spacing: .08em; text-transform: uppercase; color: var(--leise); }
.werte b { color: var(--text); font-weight: 500; }
.karte.an ~ .werte { opacity: 0; }

/* Vorführung: gezeichneter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 40; width: 24px; height: 24px; pointer-events: none; opacity: 0; transition: opacity .25s; }
.vorfuehrung .zeiger { opacity: 1; }
.zeiger path { transition: transform 150ms; transform-origin: 3px 2px; }
.zeiger.drueckt path { transform: scale(.82); }

/* Vorschau: nur die Bühne */
.vorschau body { height: 100svh; overflow: hidden; }
.vorschau .nav ul, .vorschau .nav .vorlesen { display: none; }
.vorschau .kopf .rechts .ansicht { display: none; }

:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }

@media (max-width: 1080px) { .nav ul { display: none; } .liste li { grid-template-columns: 8rem minmax(0, 1fr) 9rem 7rem; } .liste .ort { display: none; } }
@media (max-width: 640px) {
  .nav { height: 56px; gap: 12px; }
  .logo { font-size: 1.125rem; }
  .logo svg { width: 26px; height: 26px; }
  .nav .vorlesen { display: none; }
  .ticket { height: 36px; padding: 0 14px; }
  .kopf { padding-top: 18px; align-items: flex-start; flex-direction: column; }
  h1 { font-size: min(2.75rem, 10.6vw); }
  .rechts { align-items: flex-start; width: 100%; }
  .ansicht { align-self: stretch; }
  .ansicht button { flex: 1; }
  .buehne { min-height: 0; }
  .hinweis { bottom: 14px; max-width: calc(100% - 2 * var(--rand)); }
  .werte { display: none; }
  .karte { top: auto; left: 0; right: 0; bottom: 0; width: auto; border-width: 1px 0 0; translate: 0 24px; overflow: hidden; }
  .karte dl, .karte .untertitel { display: none; }
  .karte .meta { display: block; }
  .karte h2 { font-size: 1.75rem; }
  .karte .innen { padding: 16px var(--rand) 20px; gap: 10px; }
  .karte .text { font-size: 1.0625rem; }
  .liste li { grid-template-columns: minmax(0, 1fr) auto; gap: 6px 12px; }
  .liste .datum { grid-column: 1 / -1; }
  .liste .sp { grid-column: 1; }
  .liste .tk { grid-column: 2; grid-row: 3; }
}
.nur-touch { display: none; }
@media (hover: none) {
  .nur-maus { display: none; } .nur-touch { display: inline; }
  .buehne canvas { cursor: default; }
  .ticket:hover { background: var(--text); }
  .liste .tk:hover { background: none; color: inherit; }
}
</style>
</head>
<body>
<header class="nav">
  <a class="logo" href="#start" aria-label="Kuppelhaus Beuel, Startseite">
    <svg viewBox="0 0 30 30" aria-hidden="true"><path d="M3 24a12 12 0 0 1 24 0z" fill="#0b0b0b"/><path d="M15 12v12M9.2 14.4 15 24M20.8 14.4 15 24" stroke="#efede9" stroke-width="1.6"/><rect x="2" y="24" width="26" height="3" fill="#0b0b0b"/></svg>
    Kuppelhaus Beuel
  </a>
  <ul>
    <li><a href="#programm" aria-current="page">Programm</a></li>
    <li><a href="#besuch">Besuch planen</a></li>
    <li><a href="#haus">Das Haus</a></li>
    <li><a href="#leichte-sprache">Leichte Sprache</a></li>
  </ul>
  <a class="vorlesen mono" href="#vorlesen"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M2 6h3l4-3v10L5 10H2z" fill="currentColor"/><path d="M11.5 5.5a3.5 3.5 0 0 1 0 5" stroke="currentColor" fill="none" stroke-width="1.5"/></svg>Vorlesen</a>
  <a class="ticket" href="#tickets">Tickets</a>
</header>

<section class="kopf" id="programm">
  <div>
    <h1>Winterprogramm</h1>
    <p class="unter mono">Nov 2026 – Feb 2027 · 28 Abende unter der Kuppel</p>
  </div>
  <div class="rechts">
    <ul class="sparten mono" aria-label="Sparten">
      <li><i style="background:var(--musik)"></i>Musik</li>
      <li><i style="background:var(--buehne)"></i>Theater &amp; Tanz</li>
      <li><i style="background:var(--kunst)"></i>Kunst</li>
      <li><i style="background:var(--film)"></i>Film</li>
    </ul>
    <div class="ansicht" role="group" aria-label="Ansicht des Programms">
      <button type="button" data-modus="kuppel" aria-pressed="true">Kuppel</button>
      <button type="button" data-modus="globus" aria-pressed="false">Globus</button>
      <button type="button" data-modus="liste" aria-pressed="false">Liste</button>
    </div>
  </div>
</section>

<main>
  <div class="buehne" data-buehne tabindex="0" role="region" aria-label="Programm als Kuppel. Pfeiltasten drehen, Enter öffnet die Veranstaltung in der Mitte. Die Ansicht Liste zeigt alle Termine als Text.">
    <canvas data-canvas aria-hidden="true"></canvas>
    <div class="ohne-webgl"><b>WebGL ist hier nicht verfügbar.</b><p>Das Programm steht vollständig als Liste bereit.</p></div>
    <div class="hinweis" aria-hidden="true">
      <p class="jetzt" data-jetzt></p>
      <p class="mono" data-anleitung>Ziehen zum Umsehen · <span class="nur-maus">Klicken</span><span class="nur-touch">Tippen</span> für Details</p>
    </div>
    <article class="karte" data-karte role="dialog" aria-modal="false" aria-labelledby="k-titel">
      <div class="balken"></div>
      <div class="innen">
        <div class="zeile1"><span class="sparte mono"><i></i><span data-k-sparte></span></span><button class="zu" type="button" data-zu>Zurück</button></div>
        <h2 id="k-titel" data-k-titel></h2>
        <p class="untertitel" data-k-unter></p>
        <dl class="mono" data-k-daten></dl>
        <p class="meta mono" data-k-meta></p>
        <p class="text" data-k-text></p>
        <div class="aktionen"><a class="ticket" href="#tickets" data-k-ticket>Tickets</a><button class="merken" type="button">Merken</button></div>
      </div>
    </article>
    <p class="werte" data-werte></p>
  </div>

  <section class="liste" data-liste aria-label="Programm als Liste">
    <p class="gesamt mono">28 Veranstaltungen · sortiert nach Datum</p>
    <ol data-liste-ol></ol>
  </section>
</main>

<svg class="zeiger" data-zeiger viewBox="0 0 22 22" aria-hidden="true"><path d="M3 2 19 11.2l-7.2 1.5-3.4 6.8Z" fill="#0b0b0b" stroke="#efede9" stroke-width="1.4" stroke-linejoin="round"/></svg>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<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 telefon = matchMedia('(max-width: 640px)');

// Alle Werte an einer Stelle
const W = {
  daempfung: 0.95,          // Geschwindigkeit × 0,95 pro Frame (bei 60 Hz, zeitbasiert)
  auto: 0.05,               // Autorotation rad/s
  autoPause: 1500,          // ms ohne Eingabe, bis die Autorotation zurückkommt
  flug: 1.2, flugKurve: 'power3.inOut',   // Kamerafahrt 1200 ms
  zurueck: 1.0,             // Rückfahrt 1000 ms
  intro: 0.8, introStagger: 0.02,         // Intro 20 ms pro Kachel
  wurfFenster: 80,          // Wurf zählt nur, wenn die letzte Bewegung jünger ist
  klick: 5,                 // px: darunter ist es ein Klick
  versatz: 0.2,             // Bildmitte weicht der Karte aus (Anteil der Breite bzw. Höhe)
};

const SPARTEN = {
  musik: { name: 'Musik', farbe: '#c4421a' },
  buehne: { name: 'Theater & Tanz', farbe: '#3d5f9a' },
  kunst: { name: 'Kunst', farbe: '#2f7d4a' },
  film: { name: 'Film', farbe: '#b0186b' },
};
const WT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
const PROGRAMM = [
  ['Nachtfahrt', 'Elektronische Musik unter der Kuppel', 'musik', '2026-11-07', '22:00', 'Kuppelsaal', '18 €', 'Vier Live-Sets, Licht nur von oben. Wer will, liegt auf dem Boden und schaut in die Kuppel.'],
  ['Das Wetter\nvon gestern', 'Ausstellung über Wolken, Messgeräte und Erinnerung', 'kunst', '2026-11-08', '11:00', 'Galerie Ost', 'Eintritt frei', 'Zwölf Positionen aus dem Rheinland. Eröffnung mit Gespräch um 11 Uhr, danach geöffnet bis 18 Uhr.'],
  ['Metropolis', 'Stummfilm mit Live-Orgel', 'film', '2026-11-09', '18:00', 'Kuppelsaal', '14 €', 'Die restaurierte Fassung auf der großen Leinwand, an der Orgel improvisiert das Haus-Ensemble.'],
  ['Die Fähre', 'Tanzstück für sechs Körper', 'buehne', '2026-11-13', '19:30', 'Studio', '22 €', 'Ein Abend über das Übersetzen von einem Ufer zum anderen. Uraufführung, danach Gespräch mit dem Ensemble.'],
  ['Jazz am\nMontag', 'Trio-Abend im Foyer', 'musik', '2026-11-16', '20:00', 'Foyer', '12 €', 'Jeden dritten Montag: Klavier, Bass, Schlagzeug, dazu Gäste aus der Bonner Szene. Barbetrieb ab 19 Uhr.'],
  ['Siebdruck\nfür alle', 'Offene Werkstatt, ab 12 Jahren', 'kunst', '2026-11-21', '14:00', 'Werkstatt', '8 €', 'Ein Motiv entwerfen, belichten, drucken. Material ist da, eigene T-Shirts dürfen mit.'],
  ['Kurzfilm-\nnacht', 'Neue Filme aus Rhein und Sieg', 'film', '2026-11-27', '20:00', 'Kino', '9 €', 'Vierzehn Filme unter 15 Minuten. Das Publikum vergibt den Preis per Stimmzettel.'],
  ['Der Mond hat\nSchluckauf', 'Theater für Kinder ab 5', 'buehne', '2026-11-29', '15:00', 'Studio', '7 €', 'Ein Mond, der nicht einschlafen kann, und zwei Kinder, die ihm helfen. Dauer 45 Minuten.'],
  ['Weihnachts-\noratorium', 'Kantaten I bis III', 'musik', '2026-12-05', '19:00', 'Kuppelsaal', 'ab 28 €', 'Die Kantorei Beuel mit Barockorchester. Der Saal klingt unter der Kuppel besonders weich.'],
  ['Nordlicht', 'Lichtinstallation am Rheinufer', 'kunst', '2026-12-11', '18:00', 'Rheinseite', 'Eintritt frei', 'Grüne Schleier über dem Wasser, jeden Abend bis 22 Uhr. Am besten von der Brücke aus.'],
  ['Reise zum\nRand', 'Kuppelfilm in 360 Grad', 'film', '2026-12-13', '16:00', 'Kuppelsaal', '10 €', 'Ein Flug bis an den Rand des Sonnensystems, projiziert auf die ganze Kuppel. Für alle ab 8.'],
  ['Bonn bleibt', 'Improvisationstheater', 'buehne', '2026-12-18', '20:00', 'Studio', '15 €', 'Das Publikum gibt die Stichworte, das Ensemble spielt die Stadt. Kein Abend ist wie der andere.'],
  ['Klangbad', 'Ambient zum Liegen', 'musik', '2026-12-27', '21:00', 'Kuppelsaal', '16 €', 'Matten und Decken liegen bereit. Zwei Stunden Klang zwischen den Jahren.'],
  ['Silvester\nim Rund', 'Konzert und Tanz bis zwei Uhr', 'musik', '2026-12-31', '22:00', 'Kuppelsaal', '45 €', 'Erst Bigband, um Mitternacht der Blick vom Dach auf das Feuerwerk über dem Rhein.'],
  ['Zeichnen\nunter der\nKuppel', 'Aktzeichnen mit Anleitung', 'kunst', '2027-01-08', '18:30', 'Kuppelsaal', '6 €', 'Papier und Kohle sind da. Für Anfängerinnen und Geübte, Anmeldung nicht nötig.'],
  ['Gezeiten', 'Tanzabend in zwei Teilen', 'buehne', '2027-01-09', '19:30', 'Studio', '22 €', 'Zwei Choreografien über Ebbe und Flut, getanzt von der Compagnie des Hauses.'],
  ['Nordlichter:\nIsland', 'Filmreihe mit Einführung', 'film', '2027-01-14', '20:00', 'Kino', '9 €', 'Zum Auftakt der Reihe ein isländischer Spielfilm im Original mit deutschen Untertiteln.'],
  ['Schosta-\nkowitsch 8', 'Streichquartett Beuel', 'musik', '2027-01-17', '17:00', 'Foyer', '19 €', 'Das achte Quartett, davor Haydn. Eine Stunde, keine Pause, danach Gespräch.'],
  ['Sternbilder', 'Fotografie bei Nacht', 'kunst', '2027-01-22', '19:00', 'Galerie Ost', 'Eintritt frei', 'Langzeitbelichtungen aus dem Siebengebirge. Eröffnung mit Musik, bis 7. März zu sehen.'],
  ['Stadt-\ngespräch', 'Hörspiel live', 'buehne', '2027-01-27', '20:00', 'Studio', '12 €', 'Ein Hörspiel über Beuel, live gesprochen und mit Geräuschen vor Publikum gemacht.'],
  ['Stummfilm-\nmatinee', 'Kurze Komödien mit Klavier', 'film', '2027-01-31', '11:00', 'Kino', '8 €', 'Drei Slapstick-Klassiker, am Flügel begleitet. Kaffee und Brötchen im Foyer ab 10 Uhr.'],
  ['Orgel &\nSynthesizer', 'Zwei Instrumente, ein Raum', 'musik', '2027-02-05', '20:00', 'Kuppelsaal', '18 €', 'Die alte Saalorgel trifft modulare Synthesizer. Dauer etwa 70 Minuten.'],
  ['Ein Fisch\nnamens Rhein', 'Figurentheater ab 4', 'buehne', '2027-02-07', '15:00', 'Studio', '7 €', 'Ein kleiner Fisch schwimmt von der Quelle bis zum Meer. Mit Puppen aus Treibholz.'],
  ['Die Kuppel\nvon innen', 'Führung durch das Haus', 'kunst', '2027-02-13', '14:00', 'Treffpunkt Foyer', '5 €', 'Vom Keller bis unter das Dach. Die Führung erzählt die Geschichte der alten Sternwarte.'],
  ['Lieder vom\nSiebengebirge', 'Chorkonzert', 'musik', '2027-02-20', '19:00', 'Foyer', '14 €', 'Volkslieder neu gesetzt, gesungen von drei Bonner Chören. Mitsingen erwünscht.'],
  ['Leichter\nals Luft', 'Akrobatik und Live-Musik', 'buehne', '2027-02-26', '19:30', 'Kuppelsaal', '24 €', 'Vier Artistinnen hängen unter der Kuppel. Ein Abend für alle ab 6 Jahren.'],
  ['Kino unter\nSternen', 'Dokumentarfilm über Nachthimmel', 'film', '2027-02-27', '20:00', 'Kuppelsaal', '10 €', 'Warum wir kaum noch Sterne sehen. Nach dem Film schalten wir das Licht im Saal ganz aus.'],
  ['Offenes\nAtelier', 'Sonntag in der Werkstatt', 'kunst', '2027-02-28', '12:00', 'Werkstatt', 'Eintritt frei', 'Zum Abschluss der Saison: Druckpresse, Ton und Papier stehen allen offen.'],
].map(([t, u, s, d, z, o, p, x], i) => {
  const dt = new Date(d + 'T12:00:00');
  const kurz = `${WT[dt.getDay()]} ${String(dt.getDate()).padStart(2, '0')}.${String(dt.getMonth() + 1).padStart(2, '0')}.`;
  return { i, t, titel: t.replace(/-\n/g, '').replace(/\n/g, ' '), u, s, d, kurz, lang: `${kurz}${dt.getFullYear()}`, z, o, p, x };
});

// ---- DOM
const buehne = document.querySelector('[data-buehne]');
const canvas = document.querySelector('[data-canvas]');
const jetzt = document.querySelector('[data-jetzt]');
const karte = document.querySelector('[data-karte]');
const werte = document.querySelector('[data-werte]');
const zeiger = document.querySelector('[data-zeiger]');
const modusKnoepfe = [...document.querySelectorAll('[data-modus]')];
const K = { meta: karte.querySelector('[data-k-meta]'), sparte: karte.querySelector('[data-k-sparte]'), titel: karte.querySelector('[data-k-titel]'), unter: karte.querySelector('[data-k-unter]'), daten: karte.querySelector('[data-k-daten]'), text: karte.querySelector('[data-k-text]'), ticket: karte.querySelector('[data-k-ticket]') };

// ---- Liste (Alternative und Standard bei reduzierter Bewegung)
document.querySelector('[data-liste-ol]').innerHTML = PROGRAMM.map(e => `
  <li style="--farbe:${SPARTEN[e.s].farbe}">
    <span class="datum mono">${e.lang}<br>${e.z} Uhr</span>
    <div><h3>${e.titel}</h3><p>${e.u}</p></div>
    <span class="sp mono"><i></i>${SPARTEN[e.s].name}</span>
    <span class="ort mono">${e.o}</span>
    <a class="tk" href="#tickets" aria-label="Tickets für ${e.titel}, ${e.p}">${e.p === 'Eintritt frei' ? 'Frei' : e.p}</a>
  </li>`).join('');

// ---- Plakate als Texturatlas (Canvas 2D)
const SP = 7, ZE = 4, ZW = 400, ZH = 500;
const atlas = document.createElement('canvas'); atlas.width = SP * ZW; atlas.height = ZE * ZH;
function plakat(c, e) {
  const x = (e.i % SP) * ZW, y = Math.floor(e.i / SP) * ZH, f = SPARTEN[e.s].farbe;
  const art = e.i % 5 === 2 ? 'hell' : e.i % 4 === 3 ? 'dunkel' : 'farbe';
  const grund = art === 'hell' ? '#efede9' : art === 'dunkel' ? '#0b0b0b' : f;
  const schrift = art === 'hell' ? '#0b0b0b' : art === 'dunkel' ? f : '#ffffff';
  const motiv = art === 'farbe' ? 'rgba(11,11,11,.22)' : f;
  c.save();
  c.clearRect(x, y, ZW, ZH);
  c.beginPath(); c.roundRect(x + 4, y + 4, ZW - 8, ZH - 8, 12); c.clip();
  c.fillStyle = grund; c.fillRect(x, y, ZW, ZH);
  if (art === 'hell') { c.strokeStyle = '#0b0b0b'; c.lineWidth = 6; c.strokeRect(x + 4, y + 4, ZW - 8, ZH - 8); }
  // Motiv je Sparte
  c.fillStyle = motiv; c.strokeStyle = motiv; c.lineWidth = 14;
  const mx = x + ZW / 2, my = y + 205;
  if (e.s === 'musik') { for (let r = 30; r < 190; r += 32) { c.beginPath(); c.arc(mx + 40, my, r, 0, Math.PI * 2); c.stroke(); } }
  else if (e.s === 'buehne') { c.beginPath(); c.arc(mx, my + 120, 170, Math.PI, 0); c.fill(); c.fillStyle = grund; c.beginPath(); c.arc(mx, my + 120, 96, Math.PI, 0); c.fill(); }
  else if (e.s === 'kunst') { for (let a = 0; a < 4; a++) for (let b = 0; b < 4; b++) if ((a + b + e.i) % 3) c.fillRect(x + 40 + a * 84, y + 70 + b * 70, 58, 46); }
  else { for (let b = 0; b < 7; b++) { c.fillRect(x + 28, y + 70 + b * 42, 26, 22); c.fillRect(x + ZW - 54, y + 70 + b * 42, 26, 22); } c.fillRect(x + 78, y + 86, ZW - 156, 240); }
  // Kopfzeile: Datum und Uhrzeit
  c.fillStyle = schrift; c.font = '500 25px "IBM Plex Mono", monospace'; c.textBaseline = 'top';
  c.fillText(e.kurz.toUpperCase(), x + 26, y + 24);
  c.textAlign = 'right'; c.fillText(e.z, x + ZW - 26, y + 24); c.textAlign = 'left';
  // Titel unten, Schibsted 800
  const zeilen = e.t.split('\n');
  const g = zeilen.length > 2 ? 50 : 58;
  c.font = `800 ${g}px "Schibsted Grotesk", sans-serif`; c.textBaseline = 'alphabetic';
  let breiteste = Math.max(...zeilen.map(z => c.measureText(z).width));
  const passt = Math.min(1, (ZW - 52) / breiteste), gg = Math.floor(g * passt);
  c.font = `800 ${gg}px "Schibsted Grotesk", sans-serif`;
  zeilen.forEach((z, k) => c.fillText(z, x + 24, y + ZH - 66 - (zeilen.length - 1 - k) * gg * 0.98));
  // Fußzeile: Sparte und Ort
  c.font = '500 17px "IBM Plex Mono", monospace';
  c.fillText(`${SPARTEN[e.s].name.toUpperCase()} · ${e.o.toUpperCase()}`, x + 26, y + ZH - 28);
  c.restore();
}
function atlasZeichnen() { const c = atlas.getContext('2d'); PROGRAMM.forEach(e => plakat(c, e)); if (textur) textur.needsUpdate = true; }

// ---- three.js
let renderer = null, textur = null;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' }); } catch (err) { renderer = null; }
const scene = new THREE.Scene();
scene.background = new THREE.Color('#efede9');
scene.fog = new THREE.Fog('#efede9', 8, 26);
const kamera = new THREE.PerspectiveCamera(75, 1, 0.1, 100);
const gruppe = new THREE.Group(); gruppe.rotation.order = 'XYZ'; scene.add(gruppe);
let kacheln = [], kern = null;
if (renderer) {
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  textur = new THREE.CanvasTexture(atlas);
  textur.colorSpace = THREE.SRGBColorSpace;
  textur.anisotropy = Math.min(8, renderer.capabilities.getMaxAnisotropy());
}
atlasZeichnen();
Promise.all([document.fonts.load('800 58px "Schibsted Grotesk"'), document.fonts.load('500 25px "IBM Plex Mono"')]).then(atlasZeichnen).catch(() => {});
const material = new THREE.MeshBasicMaterial({ map: textur, alphaTest: 0.5, side: THREE.FrontSide });
const raycaster = new THREE.Raycaster();

// Zustand
const S = { modus: 'kuppel', yaw: 0, pitch: 0, vYaw: 0, vPitch: 0, zoom: 0, versatz: 0, autoMix: 1, letzteEingabe: -1e9, richtung: 1, offen: -1, fliegt: false, hover: -1, zieht: null };

function geometrie(e, w, h) {
  const geo = new THREE.PlaneGeometry(w, h);
  const uv = geo.attributes.uv, c = e.i % SP, r = Math.floor(e.i / SP);
  for (let k = 0; k < uv.count; k++) uv.setXY(k, (c + uv.getX(k)) / SP, 1 - (r + 1 - uv.getY(k)) / ZE);
  return geo;
}
function aufbauen() {
  kacheln.forEach(m => { gruppe.remove(m); m.geometry.dispose(); }); kacheln = [];
  if (kern) { gruppe.remove(kern); kern.geometry.dispose(); kern = null; }
  const mobil = telefon.matches;
  if (S.modus === 'kuppel') {
    // Kuppel von innen: drei Ringe, Kamera in der Mitte
    const R = 10, ringe = mobil ? [[32, 7], [0, 8], [-32, 7]] : [[32, 9], [0, 10], [-32, 9]];
    let n = 0;
    ringe.forEach(([lat, anz], ri) => {
      for (let k = 0; k < anz && n < PROGRAMM.length; k++, n++) {
        const phi = THREE.MathUtils.degToRad(lat), theta = (k + (ri % 2 ? 0 : 0.5)) / anz * Math.PI * 2;
        const m = new THREE.Mesh(geometrie(PROGRAMM[n], 3.68, 4.6), material);
        m.position.set(R * Math.cos(phi) * Math.sin(theta), R * Math.sin(phi), -R * Math.cos(phi) * Math.cos(theta));
        m.lookAt(0, 0, 0);
        m.userData = { i: n, theta, phi };
        gruppe.add(m); kacheln.push(m);
      }
    });
    scene.fog.near = 100; scene.fog.far = 200;
  } else {
    // Globus von außen: Fibonacci-Kugel, goldener Winkel 2,39996
    const R = 6, n = mobil ? 22 : PROGRAMM.length;
    kern = new THREE.Mesh(new THREE.SphereGeometry(R * 0.93, 48, 32), new THREE.MeshBasicMaterial({ color: '#e2dfd8' }));
    gruppe.add(kern);
    for (let i = 0; i < n; i++) {
      const y = 1 - (2 * i + 1) / n, phi = Math.asin(y), theta = i * 2.39996;
      const f = Math.sqrt(PROGRAMM.length / n);   // weniger Kacheln, dafür größer
      const m = new THREE.Mesh(geometrie(PROGRAMM[i], 2.48 * f, 3.1 * f), material);
      m.position.set(R * Math.cos(phi) * Math.sin(theta), R * y, R * Math.cos(phi) * Math.cos(theta));
      m.lookAt(m.position.clone().multiplyScalar(2));
      m.userData = { i, theta, phi };
      gruppe.add(m); kacheln.push(m);
    }
  }
  buehne.setAttribute('aria-label', `Programm als ${S.modus === 'kuppel' ? 'Kuppel' : 'Globus'}. Pfeiltasten drehen, Enter öffnet die Veranstaltung in der Mitte. Die Ansicht Liste zeigt alle Termine als Text.`);
}
function intro() {
  if (reduziert()) { kacheln.forEach(m => m.scale.setScalar(1)); return; }
  // von der Bildmitte nach außen, 20 ms je Kachel
  const blick = new THREE.Vector3(0, 0, S.modus === 'kuppel' ? -1 : 1);
  const reihe = [...kacheln].sort((a, b) => a.getWorldPosition(new THREE.Vector3()).normalize().dot(blick) < b.getWorldPosition(new THREE.Vector3()).normalize().dot(blick) ? 1 : -1);
  reihe.forEach((m, k) => { m.scale.setScalar(0.001); gsap.to(m.scale, { x: 1, y: 1, z: 1, duration: W.intro, ease: W.flugKurve, delay: k * W.introStagger }); });
}

// ---- Kamera je Modus
const zielHoehe = () => telefon.matches ? 0.4 : 0.7;   // Anteil der Bühnenhöhe, den die Kachel nach der Fahrt einnimmt
const versatzZiel = () => telefon.matches ? 0.28 : W.versatz;
function kameraSetzen() {
  const b = buehne.clientWidth || 1, h = buehne.clientHeight || 1;
  kamera.aspect = b / h;
  if (S.modus === 'kuppel') {
    kamera.position.set(0, 0, 0);
    const basis = b / h < 0.8 ? 76 : 58;
    const nah = THREE.MathUtils.radToDeg(2 * Math.atan(0.23 / zielHoehe()));   // Kachel füllt den sichtbaren Teil
    kamera.fov = THREE.MathUtils.lerp(basis, nah, S.zoom);
  } else {
    kamera.fov = 35;
    const halb = THREE.MathUtils.degToRad(kamera.fov / 2);
    const passt = Math.min(Math.tan(halb), Math.tan(halb) * kamera.aspect);
    const fern = 7.6 / passt, nah = 6 + 3.1 / (zielHoehe() * 2 * Math.tan(halb));
    kamera.position.set(0, 0, THREE.MathUtils.lerp(fern, nah, S.zoom));
    scene.fog.near = kamera.position.z - 5; scene.fog.far = kamera.position.z + 9;
  }
  kamera.lookAt(0, 0, S.modus === 'kuppel' ? -1 : 0);
  const mobil = telefon.matches;
  kamera.setViewOffset(b, h, mobil ? 0 : S.versatz * b, mobil ? S.versatz * h : 0, b, h);
  kamera.updateProjectionMatrix();
}
function groesse() {
  if (!renderer) return;
  const b = buehne.clientWidth, h = buehne.clientHeight;
  if (!b || !h) return;
  renderer.setSize(b, h, false);
  kameraSetzen();
}
new ResizeObserver(groesse).observe(buehne);

// ---- Ziel einer Kachel: Kachel genau in die Blickrichtung drehen
const wrap = a => Math.atan2(Math.sin(a), Math.cos(a));
function ziel(m) {
  const { theta, phi } = m.userData;
  const y = S.modus === 'kuppel' ? theta : -theta, p = S.modus === 'kuppel' ? -phi : phi;
  return { yaw: S.yaw + wrap(y - S.yaw), pitch: p };
}
const pitchGrenze = () => S.modus === 'kuppel' ? 0.42 : 1.1;

// ---- Öffnen / Schließen
function oeffnen(idx) {
  const m = kacheln.find(k => k.userData.i === idx); if (!m) return;
  const e = PROGRAMM[idx], f = SPARTEN[e.s].farbe;
  S.offen = idx; S.fliegt = true; S.vYaw = S.vPitch = 0;
  karte.style.setProperty('--farbe', f);
  K.sparte.textContent = SPARTEN[e.s].name; K.titel.textContent = e.titel; K.unter.textContent = e.u; K.text.textContent = e.x;
  K.daten.innerHTML = `<dt>Datum</dt><dd>${e.lang}</dd><dt>Beginn</dt><dd>${e.z} Uhr</dd><dt>Ort</dt><dd>${e.o}</dd><dt>Preis</dt><dd>${e.p}</dd>`;
  K.meta.textContent = `${e.lang} · ${e.z} Uhr · ${e.o}`;
  K.ticket.textContent = e.p === 'Eintritt frei' ? 'Platz reservieren' : `Tickets · ${e.p}`;
  const z = ziel(m);
  const fertig = () => { S.fliegt = false; karte.classList.add('an'); if (!html.classList.contains('vorfuehrung')) karte.querySelector('[data-zu]').focus({ preventScroll: true }); };
  gsap.killTweensOf(S);
  if (reduziert()) { Object.assign(S, z, { zoom: 1, versatz: versatzZiel() }); return fertig(); }
  gsap.to(S, { yaw: z.yaw, pitch: z.pitch, zoom: 1, versatz: versatzZiel(), duration: W.flug, ease: W.flugKurve, onComplete: fertig });
  gsap.delayedCall(W.flug * 0.6, () => { if (S.offen === idx) karte.classList.add('an'); });
}
function schliessen() {
  if (S.offen < 0) return;
  S.offen = -1; S.fliegt = true;
  karte.classList.remove('an');
  gsap.killTweensOf(S);
  const fertig = () => { S.fliegt = false; S.letzteEingabe = performance.now(); };
  if (reduziert()) { Object.assign(S, { zoom: 0, versatz: 0 }); fertig(); }
  else gsap.to(S, { zoom: 0, versatz: 0, duration: W.zurueck, ease: W.flugKurve, onComplete: fertig });
  if (!html.classList.contains('vorfuehrung')) buehne.focus({ preventScroll: true });
}
karte.querySelector('[data-zu]').addEventListener('click', schliessen);

// ---- Treffer unter dem Zeiger
const ndc = new THREE.Vector2();
function treffer(cx, cy) {
  const r = canvas.getBoundingClientRect();
  ndc.set((cx - r.left) / r.width * 2 - 1, -((cy - r.top) / r.height) * 2 + 1);
  raycaster.setFromCamera(ndc, kamera);
  const t = raycaster.intersectObjects(kacheln, false)[0];
  return t ? t.object.userData.i : -1;
}

// ---- Ziehen mit Trägheit (gemeinsam für Zeiger und Vorführung)
function faktor() { const h = buehne.clientHeight || 1; return S.modus === 'kuppel' ? THREE.MathUtils.degToRad(kamera.fov) / h : 2.6 / h; }
function ziehStart(x, y) { if (S.fliegt) return; S.zieht = { x0: x, y0: y, x, y, t: performance.now(), weg: 0 }; S.vYaw = S.vPitch = 0; buehne.classList.add('zieht'); }
function ziehBewegung(x, y) {
  const z = S.zieht; if (!z) return;
  const dx = x - z.x, dy = y - z.y, jetztT = performance.now(), dt = Math.max(1, jetztT - z.t), k = faktor();
  const sx = S.modus === 'kuppel' ? -1 : 1, sy = S.modus === 'kuppel' ? -1 : 1;
  const dYaw = sx * dx * k, dPitch = sy * dy * k;
  S.yaw += dYaw; S.pitch = THREE.MathUtils.clamp(S.pitch + dPitch, -pitchGrenze(), pitchGrenze());
  S.vYaw = 0.7 * S.vYaw + 0.3 * (dYaw / dt); S.vPitch = 0.7 * S.vPitch + 0.3 * (dPitch / dt);
  z.weg += Math.hypot(dx, dy); z.x = x; z.y = y; z.t = jetztT;
  if (dx) S.richtung = Math.sign(dYaw);
}
function ziehEnde() {
  const z = S.zieht; if (!z) return null;
  S.zieht = null; buehne.classList.remove('zieht'); S.letzteEingabe = performance.now();
  if (performance.now() - z.t > W.wurfFenster || reduziert()) S.vYaw = S.vPitch = 0;
  return z;
}
canvas.addEventListener('pointerdown', e => {
  if (!renderer) return;
  canvas.setPointerCapture(e.pointerId);
  if (S.offen >= 0) { S.klickZu = true; return; }
  ziehStart(e.clientX, e.clientY);
});
canvas.addEventListener('pointermove', e => {
  if (S.zieht) return ziehBewegung(e.clientX, e.clientY);
  if (e.pointerType === 'mouse' && S.offen < 0 && !S.fliegt) hoverSetzen(treffer(e.clientX, e.clientY));
});
canvas.addEventListener('pointerup', e => {
  if (S.klickZu) { S.klickZu = false; return schliessen(); }
  const z = ziehEnde();
  if (z && z.weg < W.klick) { const i = treffer(e.clientX, e.clientY); if (i >= 0) oeffnen(i); }
});
canvas.addEventListener('pointercancel', () => { S.klickZu = false; ziehEnde(); });
canvas.addEventListener('pointerleave', () => { if (!S.zieht) hoverSetzen(-1); });

function hoverSetzen(i) {
  if (S.hover === i) return;
  S.hover = i;
  buehne.classList.toggle('zeigt', i >= 0);
  const e = PROGRAMM[i];
  jetzt.innerHTML = i >= 0 ? `<span>${e.kurz.toUpperCase()} ${e.z}</span>${e.titel}` : '';
}

// ---- Tastatur: Pfeile drehen, Enter öffnet die Kachel in der Mitte, Esc schließt
function mitte() {
  const v = new THREE.Vector3(), blick = new THREE.Vector3(0, 0, -1).applyQuaternion(kamera.quaternion);
  let best = -1, wert = -2;
  kacheln.forEach(m => {
    m.getWorldPosition(v);
    const d = S.modus === 'kuppel' ? v.normalize().dot(blick) : v.normalize().dot(new THREE.Vector3(0, 0, 1));
    if (d > wert) { wert = d; best = m.userData.i; }
  });
  return best;
}
buehne.addEventListener('keydown', e => {
  const s = 0.0016 * (S.modus === 'kuppel' ? -1 : 1);
  if (e.key === 'Escape' && S.offen >= 0) { e.preventDefault(); schliessen(); }
  else if (S.offen >= 0) return;
  else if (e.key === 'ArrowLeft') { e.preventDefault(); S.vYaw = reduziert() ? 0 : -s * 1.4; if (reduziert()) S.yaw -= 0.3 * (S.modus === 'kuppel' ? -1 : 1); S.letzteEingabe = performance.now(); }
  else if (e.key === 'ArrowRight') { e.preventDefault(); S.vYaw = reduziert() ? 0 : s * 1.4; if (reduziert()) S.yaw += 0.3 * (S.modus === 'kuppel' ? -1 : 1); S.letzteEingabe = performance.now(); }
  else if (e.key === 'ArrowUp') { e.preventDefault(); S.pitch = THREE.MathUtils.clamp(S.pitch + 0.15 * (S.modus === 'kuppel' ? 1 : -1), -pitchGrenze(), pitchGrenze()); }
  else if (e.key === 'ArrowDown') { e.preventDefault(); S.pitch = THREE.MathUtils.clamp(S.pitch - 0.15 * (S.modus === 'kuppel' ? 1 : -1), -pitchGrenze(), pitchGrenze()); }
  else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oeffnen(mitte()); }
});
addEventListener('keydown', e => { if (e.key === 'Escape' && S.offen >= 0) schliessen(); });

// ---- Ansicht wechseln
function setzeModus(m, sanft = true) {
  if (m !== 'liste' && !renderer) m = 'liste';
  if (S.offen >= 0) { S.offen = -1; karte.classList.remove('an'); }
  gsap.killTweensOf(S); S.fliegt = false; S.zoom = 0; S.versatz = 0; S.vYaw = S.vPitch = 0;
  modusKnoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
  html.classList.toggle('modus-liste', m === 'liste');
  const vorher = S.modus;
  S.modus = m;
  if (m === 'liste') return zeigeWerte();
  if (vorher !== m || !kacheln.length) { S.yaw = 0; S.pitch = m === 'kuppel' ? 0 : 0.18; aufbauen(); }
  groesse();
  if (sanft) intro();
  zeigeWerte();
}
modusKnoepfe.forEach(b => b.addEventListener('click', () => setzeModus(b.dataset.modus)));
telefon.addEventListener('change', () => { if (S.modus !== 'liste') { aufbauen(); groesse(); } });

function zeigeWerte() {
  werte.innerHTML = reduziert()
    ? '<span>Reduziert · <b>keine Autorotation, kein Schwung</b></span><span>Fahrt <b>sofort</b></span>'
    : `<span>Dämpfung <b>× ${String(W.daempfung).replace('.', ',')} / Frame</b> · Auto <b>${String(W.auto).replace('.', ',')} rad/s</b></span><span>Fahrt <b>${W.flug * 1000} ms power3.inOut</b> · Intro <b>${W.introStagger * 1000} ms / Kachel</b></span><span>${S.modus === 'kuppel' ? 'Ringe 32° · 0° · −32°, Kamera innen' : 'Fibonacci-Kugel, goldener Winkel 2,39996'}</span>`;
}
new MutationObserver(zeigeWerte).observe(html, { attributes: true, attributeFilter: ['class'] });

// ---- Schleife
let zuletzt = performance.now(), bild = 0;
function schleife(t) {
  bild = requestAnimationFrame(schleife);
  const dt = Math.min(50, t - zuletzt); zuletzt = t;
  if (!renderer || S.modus === 'liste') return;
  if (!S.zieht && !S.fliegt && S.offen < 0) {
    S.yaw += S.vYaw * dt; S.pitch += S.vPitch * dt;
    const f = Math.pow(W.daempfung, dt / 16.667); S.vYaw *= f; S.vPitch *= f;
    const ruhig = t - S.letzteEingabe > W.autoPause && Math.abs(S.vYaw) < 0.0002;
    S.autoMix += ((ruhig && !reduziert() ? 1 : 0) - S.autoMix) * Math.min(1, dt / 600);
    S.yaw += S.richtung * W.auto * S.autoMix * dt / 1000;
    if (S.pitch > pitchGrenze() || S.pitch < -pitchGrenze()) { S.pitch = THREE.MathUtils.clamp(S.pitch, -pitchGrenze(), pitchGrenze()); S.vPitch = 0; }
  }
  gruppe.rotation.set(S.pitch, S.yaw, 0);
  // Hover: Kachel tritt etwas vor
  kacheln.forEach(m => { const z = (m.userData.i === S.hover && S.offen < 0) ? 1.07 : 1; if (!gsap.isTweening(m.scale)) m.scale.setScalar(m.scale.x + (z - m.scale.x) * Math.min(1, dt / 90)); });
  kameraSetzen();
  renderer.render(scene, kamera);
}

// ---- Vorführung: gezeichneter Zeiger zieht, wirft, klickt
let uhren = [], laeuft = false, runde = 0;
const spaeter = (fn, ms) => uhren.push(setTimeout(fn, ms));
function zeigerNach(x, y, ms = 700) { zeiger.style.transition = `transform ${ms}ms cubic-bezier(0.76, 0, 0.24, 1), opacity .25s`; zeiger.style.transform = `translate(${x}px, ${y}px)`; }
function bildschirm(m) { const v = m.getWorldPosition(new THREE.Vector3()).project(kamera), r = canvas.getBoundingClientRect(); return [r.left + (v.x + 1) / 2 * r.width, r.top + (1 - v.y) / 2 * r.height, v.z]; }
function ablauf() {
  if (!laeuft) return;
  const r = buehne.getBoundingClientRect();
  const x0 = r.left + r.width * 0.62, y0 = r.top + r.height * 0.55;
  zeigerNach(x0, y0);
  // 1. ziehen und werfen
  spaeter(() => {
    zeiger.classList.add('drueckt'); ziehStart(x0, y0);
    const p = { x: x0 };
    gsap.to(p, { x: x0 - r.width * 0.22, duration: 0.55, ease: 'power2.in', onUpdate: () => { ziehBewegung(p.x, y0); zeiger.style.transition = 'opacity .25s'; zeiger.style.transform = `translate(${p.x}px, ${y0}px)`; }, onComplete: () => { ziehEnde(); zeiger.classList.remove('drueckt'); } });
  }, 800);
  // 2. Kachel nahe der Mitte anpeilen und öffnen
  spaeter(() => {
    const i = mitte(), m = kacheln.find(k => k.userData.i === i);
    if (!m) return;
    const [x, y] = bildschirm(m); zeigerNach(x, y, 600);
    spaeter(() => { zeiger.classList.add('drueckt'); hoverSetzen(-1); oeffnen(i); spaeter(() => zeiger.classList.remove('drueckt'), 160); }, 700);
  }, 3000);
  // 3. Karte schließen
  spaeter(() => { const b = karte.querySelector('[data-zu]').getBoundingClientRect(); zeigerNach(b.left + b.width / 2, b.top + b.height / 2, 600); }, 6400);
  spaeter(() => { zeiger.classList.add('drueckt'); schliessen(); spaeter(() => zeiger.classList.remove('drueckt'), 160); }, 7100);
  // 4. jede zweite Runde die Ansicht wechseln
  spaeter(() => { runde++; setzeModus(S.modus === 'kuppel' ? 'globus' : 'kuppel'); }, 8600);
  spaeter(ablauf, 9400);
}
function grundzustand() {
  uhren.forEach(clearTimeout); uhren = []; laeuft = false;
  gsap.killTweensOf(S); S.zieht = null; buehne.classList.remove('zieht');
  hoverSetzen(-1);
}
function neustart() {
  html.classList.remove('vorfuehrung'); grundzustand();
  setzeModus(reduziert() || !renderer ? 'liste' : 'kuppel');
  S.yaw = 0; S.pitch = 0;
}
function vorfuehren() {
  grundzustand();
  html.classList.add('vorfuehrung');
  setzeModus(renderer ? 'kuppel' : 'liste');
  if (!renderer) return;
  runde = 0; laeuft = true;
  const r = buehne.getBoundingClientRect();
  zeigerNach(r.left + r.width * 0.5, r.bottom - 40, 10);
  spaeter(ablauf, 300);
}
function pause() { uhren.forEach(clearTimeout); uhren = []; laeuft = false; }
function weiter() { if (!laeuft && html.classList.contains('vorfuehrung')) { laeuft = true; if (S.offen >= 0) schliessen(); spaeter(ablauf, 600); } }

// ---- Start
if (!renderer) { html.classList.add('kein-webgl'); modusKnoepfe.filter(b => b.dataset.modus !== 'liste').forEach(b => { b.disabled = true; b.title = 'WebGL ist nicht verfügbar'; }); }
setzeModus(reduziert() || !renderer ? 'liste' : 'kuppel');
requestAnimationFrame(schleife);
document.querySelectorAll('.nav a, .liste a, .karte a').forEach(a => a.addEventListener('click', e => e.preventDefault()));

</script>
</body>
</html>