Zum Inhalt
← Seitenübergänge

Scroll zum nächsten Projekt (scroll-gesteuerter Seitenwechsel)

Weiterscrollen öffnet das Bild des nächsten Projekts bis zum Vollbild, dann wechselt die Seite unsichtbar.

Am Ende einer Case Study steht der Teaser des nächsten Projekts. Sein Bild ist per clip-path inset auf ein schmales Fenster beschnitten und vergrößert. Weiteres Scrollen öffnet das Fenster bis zum Vollbild und skaliert das Bild auf 1. Ein Fortschrittsring (SVG stroke-dashoffset) füllt sich dabei. Bei 100 % wechselt die Seite von selbst zum nächsten Projekt. Dessen Hero sieht genau aus wie der Endzustand, deshalb bemerkt man den Wechsel kaum.

Die Lektüre geht ohne Klick weiter. Der Nutzer bestimmt das Tempo selbst und spürt den Übergang als Bewegung, nicht als Ladepause. Weil Endzustand und neuer Hero deckungsgleich sind, wirken zwei Seiten wie eine.

Wann einsetzen

Bei Portfolios mit einer Abfolge von Case Studies, bei Editorial-Serien und Lookbooks. Auch bei Exposés, die man nacheinander durchblättert.

Wann nicht

Wenn Nutzer am Seitenende Footer, Kontakt oder rechtliche Links erreichen müssen und diese nicht vor dem Teaser stehen. Der Footer darf nicht unerreichbar werden.

Varianten

  • Automatischer Wechsel nach Schwelle, mit Ring und scrub 1 (Thibault Guignand)
  • Klick-Bestätigung statt automatischem Wechsel
  • Linear an den Scroll gekoppelt, mit Fortschrittsbalken neben „Scroll Down“, Sektion 200 vh, Inhalt sticky (Locomotive)
  • Natives Scrubben einer View Transition über currentTime (Bramus Van Damme, CSS Day 2026)
  • Scroll-Effekt mit Seitenübergang nach einem Dribbble-Shot von Vitalii Burhonskyi (Codrops)

Umsetzung

Teaser-Sektion 200 vh hoch, Inhalt sticky (oder ScrollTrigger mit pin, end '+=100%'). ScrollTrigger mit start 'top top' und scrub 1. Pro Update mit p = self.progress: clipPath = inset(max(0, 20 - 20p)% max(0, 40 - 40p)%), Bild scale = 1.3 - 0.3p, Ring strokeDashoffset = Umfang · (1 - p). Gewechselt wird nur, wenn p > 0,99 ist, die Scroll-Geschwindigkeit unter 2000 px/s liegt und vorher einmal p < 0,2 erreicht wurde. Dann nach 250 ms navigieren. Der Hero der Zielseite muss in Größe, Bildausschnitt und Titelposition dem Endzustand gleichen. Für echte Seitenwechsel auf beiden Seiten @view-transition { navigation: auto; } setzen und per location.href zur nächsten URL gehen. Fallstricke: ScrollTrigger nach dem Wechsel aufräumen, ebenso Lenis, falls ein Eigenprojekt es nutzt (optional, standardmäßig aus; viele Rezepte schließen es aus). Kein Auto-Wechsel bei schnellem Flick-Scroll (Velocity-Grenze).

Robust umsetzen

Telefon. Mit nativem Touch-Scroll gut. Den automatischen Wechsel hier besser durch einen Button „Nächstes Projekt“ ersetzen, damit niemand versehentlich wechselt. Das Fenster breiter anlegen, sonst wird es auf schmalen Bildschirmen zum Schlitz.

Ohne Bewegung. Statischer Teaser mit Link, ohne Scrub und ohne automatischen Wechsel.

Barrierefrei. Immer einen echten Link zum nächsten Projekt anbieten, für Tastatur und Screenreader. Footer und rechtliche Links vor dem Teaser platzieren, damit sie erreichbar bleiben. Den doppelten Titel im Teaser-Bild mit aria-hidden ausblenden.

Code

<!DOCTYPE html>
<!--
  Scroll zum nächsten Projekt (scroll-gesteuerter Seitenwechsel)
  Fundus · Seitenübergänge · https://cecodesign.de/lab/fundus/seitenuebergaenge/scroll-naechstes-projekt/
  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>Scroll zum nächsten Projekt (scroll-gesteuerter Seitenwechsel) · 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: 'Libre Caslon Display'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/libre-caslon-display@latest/latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; }
@font-face { font-family: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  --grund: #fcfaf5; --sand: #efe8df; --marke: #02385c; --text: #313131; --linie: #b5b2af; --cta: #ed7f1a;
  --leise: rgba(49, 49, 49, .72);
  --ui: cubic-bezier(0.165, 0.84, 0.44, 1);   /* easeOutQuart, 400 ms */
  --rand: clamp(20px, 5.5vw, 88px);
  --kopf: 60px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font: 400 1.0625rem/1.55 'Hanken Grotesk', system-ui, sans-serif; -webkit-font-smoothing: antialiased; scroll-behavior: auto; }
body { overflow-x: clip; }
a { color: inherit; }
ul { list-style: none; }
.label { font: 500 .75rem/1.3 'Hanken Grotesk', system-ui, sans-serif; letter-spacing: .09em; text-transform: uppercase; }
.serif { font-family: 'Libre Caslon Display', Georgia, serif; font-weight: 400; letter-spacing: -.005em; line-height: 1.08; }
.zahl { font-variant-numeric: tabular-nums lining-nums; }

/* Kopf: bleibt stehen, Bewertung und Suche immer erreichbar */
.kopf { position: fixed; z-index: 30; inset: 0 0 auto; height: var(--kopf); display: flex; align-items: center; gap: 28px; padding: 0 var(--rand); background: var(--grund); border-bottom: 1px solid rgba(181, 178, 175, .45); }
.marke { text-decoration: none; display: grid; line-height: 1.1; }
.marke b { font: 600 .9375rem/1.1 'Hanken Grotesk', sans-serif; letter-spacing: .16em; text-transform: uppercase; color: var(--marke); }
.marke span { font-size: .6875rem; letter-spacing: .09em; text-transform: uppercase; color: var(--leise); }
.nav { display: flex; gap: 26px; margin-left: auto; font-size: .9375rem; }
.nav a { text-decoration: none; color: var(--text); transition: color 200ms var(--ui); }
.nav a:hover { color: var(--marke); }
.nav a[aria-current] { color: var(--marke); box-shadow: inset 0 -1px 0 var(--marke); }
.zaehler { color: var(--leise); white-space: nowrap; }
.zaehler b { color: var(--marke); font-weight: 600; }
.kopf .knopf { margin-left: 4px; }
.knopf { display: inline-flex; align-items: center; gap: 10px; height: 42px; padding: 0 18px; border-radius: 2px; font: 500 .9375rem/1 'Hanken Grotesk', sans-serif; text-decoration: none; border: 1px solid var(--marke); color: var(--marke); background: transparent; cursor: pointer; transition: background-color 200ms var(--ui), color 200ms var(--ui); white-space: nowrap; }
.knopf:hover { background: var(--marke); color: var(--grund); }
.knopf--cta { background: var(--cta); border-color: var(--cta); color: #1c1712; }   /* dunkle Schrift auf Orange: 6,5:1 (Weiß nur 2,7:1) */
.knopf--cta:hover { background: #d96f0f; border-color: #d96f0f; color: #1c1712; }

/* Bühne: Objektbild mit Titel. Hero und Teaser-Endzustand sind deckungsgleich. */
.hero { position: relative; height: 100svh; overflow: hidden; background: #1a2532; }
.buehne { position: absolute; inset: 0; overflow: hidden; color: #fff; }
.bild { position: absolute; inset: 0; transform-origin: 50% 50%; }
.bild svg { display: block; width: 100%; height: 100%; }
.bild::after { content: ''; position: absolute; inset: 0; opacity: .16; mix-blend-mode: overlay; pointer-events: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='k'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23k)'/%3E%3C/svg%3E"); }
.verlauf { position: absolute; inset: 0; background: linear-gradient(to top, rgba(4, 18, 32, .72) 0%, rgba(4, 18, 32, .32) 34%, rgba(4, 18, 32, 0) 58%); }
.titelblock { position: absolute; left: var(--rand); right: var(--rand); bottom: clamp(28px, 8svh, 76px); display: grid; gap: 18px; }
.titelblock .kicker { color: rgba(255, 255, 255, .82); }
.titelblock .h1 { font-size: clamp(2.25rem, 1.4rem + 3.2vw, 4.5rem); max-width: 13ch; text-wrap: balance; }
.fakten { display: flex; flex-wrap: wrap; gap: 10px clamp(24px, 3.4vw, 48px); padding-top: 18px; border-top: 1px solid rgba(255, 255, 255, .28); max-width: 640px; }
.fakten li { display: grid; gap: 2px; opacity: 0; translate: 0 8px; transition: opacity 400ms var(--ui), translate 400ms var(--ui); transition-delay: calc(var(--i) * 60ms + 80ms); }
.fakten span { font-size: .75rem; letter-spacing: .09em; text-transform: uppercase; color: rgba(255, 255, 255, .7); }
.fakten b { font-weight: 500; font-size: 1.125rem; }
.hero.angekommen .fakten li { opacity: 1; translate: 0 0; }
.fenster .fakten { visibility: hidden; }

/* Exposé-Inhalt */
.inhalt { padding: clamp(64px, 11vw, 150px) var(--rand) clamp(56px, 8vw, 110px); display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 4fr); gap: clamp(32px, 6vw, 110px); align-items: start; }
.inhalt .label { color: var(--leise); margin-bottom: 22px; }
.inhalt h2 { font-size: clamp(2rem, 1.3rem + 2.2vw, 3.25rem); color: var(--marke); max-width: 17ch; text-wrap: balance; margin-bottom: 30px; }
.inhalt p + p { margin-top: 1em; }
.text { max-width: 60ch; }
.eckdaten { position: sticky; top: calc(var(--kopf) + 32px); background: var(--sand); padding: clamp(24px, 2.6vw, 36px); display: grid; gap: 22px; }
.eckdaten dl { display: grid; grid-template-columns: auto 1fr; font-size: .9375rem; }
.eckdaten dt, .eckdaten dd { padding: 9px 0; border-bottom: 1px solid rgba(181, 178, 175, .6); }
.eckdaten dt { color: var(--leise); padding-right: 18px; }
.eckdaten dd { text-align: right; font-variant-numeric: tabular-nums lining-nums; color: var(--text); }
.eckdaten .preis { font-size: 1.375rem; color: var(--marke); }
.eckdaten small { font-size: .75rem; color: var(--leise); line-height: 1.45; }

.plan { margin: 0 var(--rand); padding: clamp(28px, 4vw, 56px); background: var(--sand); display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 3fr); gap: clamp(24px, 4vw, 64px); align-items: center; }
.plan .label { color: var(--leise); }
.plan h3 { font: 500 1.25rem/1.3 'Hanken Grotesk', sans-serif; color: var(--marke); margin: 12px 0 10px; }
.plan p { font-size: .9375rem; color: var(--leise); }
.plan svg { width: 100%; height: auto; display: block; }

.fuss { padding: clamp(56px, 8vw, 110px) var(--rand) clamp(40px, 5vw, 64px); display: grid; gap: 40px; }
.person { display: flex; gap: 22px; align-items: center; flex-wrap: wrap; }
.person .bildchen { width: 64px; height: 64px; border-radius: 50%; background: var(--marke); color: var(--grund); display: grid; place-items: center; font: 400 1.5rem/1 'Libre Caslon Display', serif; }
.person p { font-size: .9375rem; }
.person b { display: block; font-weight: 500; font-size: 1.0625rem; color: var(--marke); }
.person .knopf { margin-left: auto; }
.recht { display: flex; flex-wrap: wrap; gap: 6px 22px; padding-top: 22px; border-top: 1px solid rgba(181, 178, 175, .6); font-size: .8125rem; color: var(--leise); }
.recht a { text-decoration: none; }
.recht a:hover { color: var(--marke); }

/* Teaser des nächsten Objekts: 200 svh hoch, Inhalt klebt (Locomotive: sticky statt pin) */
.naechstes { position: relative; height: 200svh; background: var(--sand); }
.klebe { position: sticky; top: 0; height: 100svh; overflow: hidden; }
.oben { position: absolute; left: 0; right: 0; top: calc(var(--kopf) + 3svh); text-align: center; color: var(--marke); z-index: 3; }
.oben b { font-weight: 600; }
.vorab { position: absolute; top: 50%; translate: 0 -50%; z-index: 1; }
.vorab--links { left: var(--rand); width: calc(40% - var(--rand) - 28px); }
.vorab--links .serif { font-size: clamp(2rem, 1.1rem + 2.4vw, 3.5rem); color: var(--marke); text-wrap: balance; }
.vorab--links .label { color: var(--leise); margin-top: 18px; }
.vorab--rechts { right: var(--rand); width: calc(40% - var(--rand) - 28px); display: grid; gap: 0; justify-items: end; }
.vorab--rechts div { display: flex; justify-content: space-between; gap: 18px; width: min(100%, 260px); padding: 10px 0; border-bottom: 1px solid rgba(181, 178, 175, .7); font-size: .9375rem; }
.vorab--rechts dt { color: var(--leise); }
.vorab--rechts dd { font-variant-numeric: tabular-nums lining-nums; color: var(--marke); }
.fenster { z-index: 2; will-change: clip-path; clip-path: inset(20% 40%); }
.fenster .bild { will-change: transform; transform: scale(1.3); }
.ring { position: absolute; z-index: 3; left: 50%; top: 50%; translate: -50% -50%; width: 76px; height: 76px; color: #fff; pointer-events: none; }
.ring svg { width: 100%; height: 100%; rotate: -90deg; overflow: visible; }
.ring { filter: drop-shadow(0 1px 8px rgba(4, 18, 32, .45)); }
.ring circle { fill: none; stroke-width: 1.5; }
.ring .spur { stroke: rgba(255, 255, 255, .35); }
.ring .fuell { stroke: #fff; stroke-dasharray: 213.63; stroke-dashoffset: 213.63; }
.ring span { position: absolute; inset: 0; display: grid; place-items: center; font: 500 .75rem/1 'Hanken Grotesk', sans-serif; letter-spacing: .09em; }
.unten { position: absolute; z-index: 3; left: var(--rand); right: var(--rand); bottom: clamp(20px, 4svh, 40px); display: flex; align-items: center; justify-content: space-between; gap: 18px; color: var(--marke); }
.balken { display: none; align-items: center; gap: 14px; }
.balken i { display: block; width: clamp(90px, 14vw, 180px); height: 1px; background: rgba(2, 56, 92, .22); position: relative; overflow: hidden; }
.balken i::after { content: ''; position: absolute; inset: 0; background: var(--marke); transform-origin: 0 50%; transform: scaleX(var(--p, 0)); }
.hinweis { display: flex; align-items: center; gap: 10px; }
.direkt { text-decoration: none; display: inline-flex; gap: 8px; align-items: center; padding: 8px 0; box-shadow: inset 0 -1px 0 currentColor; }
.bestaetigen { position: absolute; z-index: 4; left: 50%; top: 50%; translate: -50% -50%; opacity: 0; visibility: hidden; scale: .96; transition: opacity 400ms var(--ui), scale 400ms var(--ui), visibility 0s 400ms; background: var(--grund); border-color: var(--grund); color: var(--marke); height: 50px; padding: 0 24px; }
.bestaetigen:hover { background: #fff; color: var(--marke); }
.bestaetigen.bereit { opacity: 1; visibility: visible; scale: 1; transition-delay: 0s; }
[data-modus-aktiv='balken'] .ring { display: none; }
[data-modus-aktiv='balken'] .balken { display: flex; }
[data-modus-aktiv='balken'] .hinweis--text { display: none; }

/* View Transition (same-document) beim simulierten Seitenwechsel */
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 250ms; animation-timing-function: linear; }
.kopf { view-transition-name: kopf; }
.regler { view-transition-name: regler; }
::view-transition-group(kopf), ::view-transition-group(regler) { animation: none; }

/* Demo-Steuerung: dezent, Geist Mono */
.regler { position: fixed; z-index: 40; right: 14px; bottom: 14px; max-width: min(460px, calc(100% - 28px)); padding: 10px 12px; display: grid; gap: 8px; background: rgba(9, 22, 34, .84); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); color: rgba(255, 255, 255, .62); border-radius: 4px; font: 500 10.5px/1.5 'Geist Mono', ui-monospace, monospace; letter-spacing: .06em; }
.regler .mono { text-transform: uppercase; letter-spacing: .14em; }
.regler b { color: #fff; font-weight: 500; }
.modi { display: flex; gap: 4px; flex-wrap: wrap; }
.modi button { font: inherit; text-transform: uppercase; letter-spacing: .12em; color: rgba(255, 255, 255, .7); background: none; border: 1px solid rgba(255, 255, 255, .18); border-radius: 999px; height: 26px; padding: 0 10px; cursor: pointer; transition: background-color 200ms var(--ui), color 200ms var(--ui); }
.modi button[aria-pressed='true'] { background: #fff; color: #091622; border-color: #fff; }
.live { display: flex; flex-wrap: wrap; gap: 2px 14px; }
.live .pfad { color: #f2c27a; }

/* Reduzierte Bewegung: statischer Teaser mit Link, kein Scrub, kein Auto-Wechsel */
.reduziert .naechstes { height: auto; }
.reduziert .klebe { position: relative; }
.reduziert .ring, .reduziert .unten, .reduziert .modi { display: none !important; }
.reduziert .bestaetigen { opacity: 1; visibility: visible; scale: 1; left: auto; right: var(--rand); top: calc(50% + 96px); translate: none; transition: none; background: var(--marke); border-color: var(--marke); color: var(--grund); }
.reduziert .fakten li { translate: 0 0; transition: opacity 200ms linear; transition-delay: 0s; }
.reduziert ::view-transition-old(root), .reduziert ::view-transition-new(root) { animation: none; }

.vorschau body { overflow: hidden; }
.vorschau .modi, .vorschau .regler .werte { display: none; }

@media (max-width: 1100px) { .nav { display: none; } .zaehler { margin-left: auto; } }
@media (max-width: 900px) {
  .inhalt { grid-template-columns: 1fr; }
  .eckdaten { position: static; }
  .plan { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
  :root { --kopf: 56px; }
  .kopf { gap: 14px; }
  .kopf .knopf { display: none; }
  .marke b { font-size: .8125rem; letter-spacing: .14em; }
  .oben { top: calc(var(--kopf) + 14px); }
  .vorab { translate: none; text-align: center; }
  .vorab--links { left: var(--rand); right: var(--rand); width: auto; top: calc(var(--kopf) + 40px); }
  .vorab--links .serif { font-size: 2rem; }
  .vorab--links .label { display: none; }
  .fenster { clip-path: inset(22% 16%); }
  .vorab--rechts { left: var(--rand); right: var(--rand); width: auto; top: auto; bottom: 156px; display: flex; justify-content: center; gap: 10px; }
  .vorab--rechts div { width: auto; display: flex; gap: 10px; border: 0; padding: 0; font-size: .875rem; }
  .vorab--rechts dt { display: none; }
  .vorab--rechts div + div::before { content: '·'; color: var(--linie); }
  .unten { bottom: 106px; }
  .reduziert .bestaetigen { display: none; }
  .reduziert .unten { display: flex !important; }
  .unten { justify-content: center; }
  .unten .hinweis--text, .unten .balken { display: none !important; }
  .person .knopf { margin-left: 0; }
  .regler { left: 12px; right: 12px; bottom: 12px; max-width: none; }
  .regler { gap: 6px; padding: 8px 10px; }
  .modi { flex-wrap: nowrap; }
  .regler .werte, .regler .lang { display: none; }
  .titelblock { bottom: 110px; }
}
@media (hover: none) { .nav a:hover { color: var(--text); } }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#" aria-label="Ostner und Paulus Immobilien, Startseite"><b>Ostner &amp; Paulus</b><span>Immobilien · Regensburg</span></a>
  <nav class="nav" aria-label="Hauptnavigation">
    <a href="#" aria-current="page">Kaufen</a><a href="#">Verkaufen</a><a href="#">Marktbericht</a><a href="#">Über uns</a>
  </nav>
  <span class="zaehler label">Exposé <b class="zahl" data-nr>01</b> / 03</span>
  <a class="knopf" href="#">Immobilie bewerten</a>
</header>

<main id="seite"></main>

<aside class="regler" aria-label="Demo-Steuerung">
  <div class="modi" role="group" aria-label="Variante">
    <button type="button" data-modus="ring" aria-pressed="true">Ring<span class="lang"> · automatisch</span></button>
    <button type="button" data-modus="balken" aria-pressed="false">Balken<span class="lang"> · linear</span></button>
    <button type="button" data-modus="klick" aria-pressed="false"><span class="lang">Mit </span>Klick</button>
  </div>
  <p class="werte" data-werte></p>
  <p class="live"><span>p <b class="zahl" data-p>0,00</b></span><span>v <b class="zahl" data-v>0</b> px/s</span><span class="pfad" data-pfad>/objekte/patrizierturm</span></p>
</aside>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script>
(() => {
  const html = document.documentElement;
  if (/[?&]reduziert/.test(location.search)) html.classList.add('reduziert');
  const reduziert = () => html.classList.contains('reduziert');
  gsap.registerPlugin(ScrollTrigger);

  /* ---------- Bildwelt: eigene SVG-Szenen der drei Objekte ---------- */
  let uid = 0;
  const zufall = s => () => (s = (s * 16807) % 2147483647) / 2147483647;
  const svg = (inhalt, extra = '') => `<svg viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true" ${extra}>${inhalt}</svg>`;
  const fenster = (x, y, w, h, an, r, glut) => {
    let s = '';
    if (an) s += `<circle cx="${x + w / 2}" cy="${y + h / 2}" r="${Math.max(w, h) * .9}" fill="url(#${glut})"/>`;
    s += `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${r || 0}" fill="${an ? '#f4c983' : '#2a2724'}"/>`;
    if (an) s += `<rect x="${x}" y="${y}" width="${w}" height="${h * .42}" rx="${r || 0}" fill="#ffe2ad" opacity=".55"/>`;
    return s;
  };

  const bogen = (x, y, w, h, an, glut) => {
    const d = `M${x} ${y + h}V${y + w / 2}A${w / 2} ${w / 2} 0 0 1 ${x + w} ${y + w / 2}V${y + h}z`;
    let s = an ? `<circle cx="${x + w / 2}" cy="${y + h / 2}" r="${h * .8}" fill="url(#${glut})"/>` : '';
    s += `<path d="${d}" fill="${an ? '#f4c983' : '#2a2320'}"/><rect x="${x - 3}" y="${y + h}" width="${w + 6}" height="5" fill="#3b1c12"/>`;
    if (an) s += `<rect x="${x + w / 2 - 1.5}" y="${y + 6}" width="3" height="${h - 6}" fill="#5a2a1a"/><rect x="${x}" y="${y + h * .55}" width="${w}" height="3" fill="#5a2a1a"/>`;
    return s;
  };

  function szeneTurm() {
    const u = ++uid, z = zufall(7), g = `gl${u}`;
    let s = `<defs>
      <linearGradient id="h${u}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#101f31"/><stop offset=".5" stop-color="#2e4a66"/><stop offset=".78" stop-color="#a98a74"/><stop offset="1" stop-color="#e2ae7c"/></linearGradient>
      <linearGradient id="st${u}" x1="0" x2="1"><stop offset="0" stop-color="#a39280"/><stop offset=".7" stop-color="#857565"/><stop offset="1" stop-color="#6c5f53"/></linearGradient>
      <linearGradient id="hs${u}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c7ae90"/><stop offset="1" stop-color="#8f7a64"/></linearGradient>
      <linearGradient id="d${u}" x1="0" y1="0" x2="0" y2="1"><stop offset=".55" stop-color="#08121c" stop-opacity="0"/><stop offset="1" stop-color="#08121c" stop-opacity=".85"/></linearGradient>
      <radialGradient id="${g}"><stop offset="0" stop-color="#ffcf87" stop-opacity=".26"/><stop offset="1" stop-color="#ffcf87" stop-opacity="0"/></radialGradient>
      <radialGradient id="sl${u}" cx=".5" cy="1" r=".7"><stop offset="0" stop-color="#ffd9a3" stop-opacity=".55"/><stop offset="1" stop-color="#ffd9a3" stop-opacity="0"/></radialGradient>
    </defs><rect width="1600" height="1000" fill="url(#h${u})"/>
    <ellipse cx="1150" cy="780" rx="900" ry="260" fill="url(#sl${u})"/>`;
    // Domtürme in der Ferne
    s += `<g fill="#2a405a" opacity=".9"><path d="M250 1000V420h40l20-210 20 210h50l20-210 20 210h40v580z"/><rect x="236" y="560" width="208" height="440"/><path d="M300 210l10-60 10 60z"/><path d="M380 210l10-60 10 60z"/></g>`;
    // Dächerlinie hinten
    let x = 0, d = 'M0 1000V700';
    while (x < 1600) { const w = 70 + z() * 90, h = 640 + z() * 70; d += `L${x} ${h + 40}L${x + w / 2} ${h}L${x + w} ${h + 40}`; x += w; }
    s += `<path d="${d}L1600 1000z" fill="#22344a"/>`;
    for (let i = 0; i < 26; i++) { const wx = 20 + z() * 1560, wy = 730 + z() * 140; if (wx > 420 && wx < 1220) continue; s += `<rect x="${wx | 0}" y="${wy | 0}" width="9" height="13" fill="#f0bb72" opacity="${(.5 + z() * .4).toFixed(2)}"/>`; }
    // linkes Giebelhaus
    s += `<path d="M430 1000V470L565 300L700 470V1000z" fill="url(#hs${u})"/><path d="M422 474L565 292L708 474" fill="none" stroke="#3b302a" stroke-width="10"/>`;
    for (let r = 0; r < 5; r++) for (let c = 0; c < 3; c++) s += fenster(468 + c * 70, 500 + r * 92, 34, 54, z() > .45, 0, g);
    s += fenster(548, 390, 34, 50, true, 17, g);
    // rechtes Traufhaus
    s += `<path d="M936 420H1214L1184 352H966z" fill="#3a3330"/><rect x="936" y="420" width="278" height="580" fill="#a9937c"/><rect x="936" y="420" width="278" height="580" fill="url(#d${u})" opacity=".5"/>`;
    for (let r = 0; r < 5; r++) for (let c = 0; c < 4; c++) s += fenster(962 + c * 64, 452 + r * 96, 30, 52, z() > .5, 0, g);
    // Haus ganz rechts
    s += `<path d="M1214 1000V520L1330 410L1446 520V1000z" fill="#5f5246"/><path d="M1446 1000V560H1600V1000z" fill="#3e3a3a"/>`;
    for (let r = 0; r < 4; r++) for (let c = 0; c < 3; c++) s += fenster(1250 + c * 64, 560 + r * 96, 28, 48, z() > .62, 0, g);
    // Patrizierturm in der Mitte
    s += `<rect x="700" y="150" width="200" height="850" fill="url(#st${u})"/><rect x="900" y="160" width="36" height="840" fill="#54493f"/>`;
    s += `<g stroke="#3e352e" stroke-width="1" opacity=".18">`;
    for (let y = 172; y < 1000; y += 22) s += `<line x1="700" y1="${y}" x2="900" y2="${y}"/>`;
    s += `</g><rect x="690" y="138" width="256" height="16" fill="#5f5448"/><path d="M694 138H942L900 102H736z" fill="#332e2c"/>`;
    s += `<rect x="726" y="172" width="148" height="50" fill="#f2c27a"/><rect x="726" y="172" width="148" height="20" fill="#ffe0a8" opacity=".6"/><g fill="#3b332c"><rect x="762" y="172" width="4" height="50"/><rect x="798" y="172" width="4" height="50"/><rect x="834" y="172" width="4" height="50"/><rect x="722" y="208" width="156" height="3"/></g><circle cx="800" cy="196" r="120" fill="url(#${g})"/>`;
    const an = [1, 0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0];
    for (let r = 0; r < 7; r++) for (let c = 0; c < 2; c++) s += fenster(742 + c * 76, 270 + r * 100, 40, 62, an[r * 2 + c], 0, g);
    // Straße, Laterne
    s += `<rect x="1300" y="760" width="5" height="240" fill="#17202a"/><circle cx="1302" cy="756" r="9" fill="#ffe2ad"/><circle cx="1302" cy="756" r="120" fill="url(#${g})"/>`;
    s += `<rect width="1600" height="1000" fill="url(#d${u})"/>`;
    return svg(s);
  }

  function szeneVilla() {
    const u = ++uid, z = zufall(3);
    let s = `<defs>
      <linearGradient id="h${u}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#c9d3d3"/><stop offset=".55" stop-color="#e9e2d2"/><stop offset="1" stop-color="#f4dcbc"/></linearGradient>
      <linearGradient id="fa${u}" x1="0" x2="1"><stop offset="0" stop-color="#f3ebdd"/><stop offset=".62" stop-color="#e8dcc8"/><stop offset="1" stop-color="#cdbfa8"/></linearGradient>
      <linearGradient id="da${u}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#6e3d30"/><stop offset="1" stop-color="#94563f"/></linearGradient>
      <linearGradient id="gl${u}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#55666e"/><stop offset=".45" stop-color="#2f3d45"/><stop offset=".5" stop-color="#8fa2a8"/><stop offset=".56" stop-color="#34434b"/><stop offset="1" stop-color="#26323a"/></linearGradient>
      <linearGradient id="ra${u}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9aa67c"/><stop offset="1" stop-color="#5f6d4a"/></linearGradient>
      <linearGradient id="d${u}" x1="0" y1="0" x2="0" y2="1"><stop offset=".55" stop-color="#1b2418" stop-opacity="0"/><stop offset="1" stop-color="#101810" stop-opacity=".8"/></linearGradient>
      <radialGradient id="so${u}" cx=".78" cy=".2" r=".6"><stop offset="0" stop-color="#fff3d9" stop-opacity=".9"/><stop offset="1" stop-color="#fff3d9" stop-opacity="0"/></radialGradient>
    </defs><rect width="1600" height="1000" fill="url(#h${u})"/><rect width="1600" height="1000" fill="url(#so${u})"/>`;
    // Baumkronen hinten
    const krone = (cx, cy, r, f) => { let k = ''; for (let i = 0; i < 9; i++) k += `<circle cx="${(cx + (z() - .5) * r * 1.4) | 0}" cy="${(cy + (z() - .5) * r * .9) | 0}" r="${(r * (.45 + z() * .35)) | 0}" fill="${f}"/>`; return k; };
    s += krone(330, 520, 260, '#6f7d5c') + krone(1300, 500, 280, '#66744f') + krone(120, 640, 200, '#56643f') + krone(1520, 640, 210, '#4f5c3b');
    s += `<rect x="0" y="820" width="1600" height="180" fill="url(#ra${u})"/>`;
    // Villa
    s += `<rect x="520" y="480" width="560" height="360" fill="url(#fa${u})"/>`;
    s += `<path d="M486 486H1114L1012 330H588z" fill="url(#da${u})"/><path d="M486 486H1114" stroke="#5a3226" stroke-width="8"/>`;
    s += `<rect x="640" y="250" width="26" height="92" fill="#8a4c39"/><rect x="934" y="250" width="26" height="92" fill="#8a4c39"/><rect x="636" y="246" width="34" height="8" fill="#5a3226"/><rect x="930" y="246" width="34" height="8" fill="#5a3226"/>`;
    s += `<g fill="#f3ebdd"><path d="M690 420h44v-26l-22-16-22 16z"/><path d="M866 420h44v-26l-22-16-22 16z"/></g><rect x="700" y="396" width="24" height="22" fill="url(#gl${u})"/><rect x="876" y="396" width="24" height="22" fill="url(#gl${u})"/>`;
    // Mittelrisalit mit Giebel
    s += `<rect x="716" y="430" width="168" height="410" fill="#f6efe3"/><path d="M700 438L800 358L900 438z" fill="#f6efe3"/><path d="M694 442L800 352L906 442" fill="none" stroke="#c9bba3" stroke-width="6"/><circle cx="800" cy="406" r="16" fill="url(#gl${u})" stroke="#d9cdb8" stroke-width="5"/>`;
    s += `<rect x="520" y="650" width="560" height="10" fill="#d6c9b2"/><rect x="510" y="832" width="580" height="14" fill="#cbbda5"/>`;
    const fe = (x, y, w, h) => `<rect x="${x - 6}" y="${y - 6}" width="${w + 12}" height="${h + 12}" fill="#fbf7ef"/><rect x="${x}" y="${y}" width="${w}" height="${h}" fill="url(#gl${u})"/><rect x="${x + w / 2 - 1.5}" y="${y}" width="3" height="${h}" fill="#fbf7ef"/><rect x="${x}" y="${y + h * .34}" width="${w}" height="3" fill="#fbf7ef"/><rect x="${x - 10}" y="${y + h + 6}" width="${w + 20}" height="6" fill="#d6c9b2"/>`;
    for (const x of [556, 636, 912, 992]) s += fe(x, 530, 52, 92) + fe(x, 696, 52, 108);
    s += fe(752, 520, 96, 100);
    s += `<rect x="752" y="690" width="96" height="140" fill="#fbf7ef"/><rect x="760" y="698" width="38" height="132" fill="url(#gl${u})"/><rect x="802" y="698" width="38" height="132" fill="url(#gl${u})"/><path d="M748 690a52 30 0 0 1 104 0z" fill="#e8dcc8"/>`;
    s += `<rect x="1020" y="480" width="60" height="360" fill="#000" opacity=".06"/>`;
    // Weg, Hecken, Baum vorn
    s += `<path d="M770 846H830L900 1000H700z" fill="#d9ccb4" opacity=".9"/>`;
    s += `<rect x="440" y="800" width="290" height="60" rx="26" fill="#4f5e3c"/><rect x="870" y="800" width="290" height="60" rx="26" fill="#4f5e3c"/>`;
    s += `<rect x="1386" y="560" width="22" height="440" fill="#3a3326"/>` + krone(1400, 470, 230, '#4b5937') + krone(1420, 430, 150, '#5d6b44');
    s += `<rect width="1600" height="1000" fill="url(#d${u})"/>`;
    return svg(s);
  }

  function szeneSpeicher() {
    const u = ++uid, z = zufall(11), g = `gl${u}`;
    let s = `<defs>
      <linearGradient id="h${u}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0b1726"/><stop offset=".6" stop-color="#1d3349"/><stop offset=".76" stop-color="#3f5468"/></linearGradient>
      <linearGradient id="zi${u}" x1="0" x2="1"><stop offset="0" stop-color="#8a4a32"/><stop offset=".65" stop-color="#6e3826"/><stop offset="1" stop-color="#55291c"/></linearGradient>
      <linearGradient id="wa${u}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1a2c3d"/><stop offset="1" stop-color="#070f18"/></linearGradient>
      <linearGradient id="d${u}" x1="0" y1="0" x2="0" y2="1"><stop offset=".6" stop-color="#050b12" stop-opacity="0"/><stop offset="1" stop-color="#050b12" stop-opacity=".7"/></linearGradient>
      <radialGradient id="${g}"><stop offset="0" stop-color="#ffc77a" stop-opacity=".28"/><stop offset="1" stop-color="#ffc77a" stop-opacity="0"/></radialGradient>
    </defs><rect width="1600" height="1000" fill="url(#h${u})"/>`;
    for (let i = 0; i < 40; i++) s += `<circle cx="${(z() * 1600) | 0}" cy="${(z() * 380) | 0}" r="${(z() * 1.4 + .4).toFixed(1)}" fill="#dfe7ef" opacity="${(z() * .5 + .2).toFixed(2)}"/>`;
    // Steinbrücke links
    let b = 'M0 760V610H600V760';
    for (let x = 560; x >= 0; x -= 120) b += `H${x + 100}A50 56 0 0 0 ${x} 760`;
    s += `<path d="${b}z" fill="#283746"/><rect x="0" y="600" width="600" height="12" fill="#344556"/>`;
    s += `<path d="M1100 760V500H1420V760z" fill="#303b47"/><path d="M1420 760V560H1600V760z" fill="#26303a"/><path d="M1094 504H1426L1390 452H1130z" fill="#1f2730"/>`;
    for (let r = 0; r < 4; r++) for (let c = 0; c < 5; c++) s += fenster(1126 + c * 58, 530 + r * 56, 24, 34, z() > .6, 0, g);
    // Speicher mit Treppengiebel
    s += `<path d="M560 760V420H600V380H640V340H680V300H720V262H880V300H920V340H960V380H1000V420H1040V760z" fill="url(#zi${u})"/>`;
    s += `<g stroke="#3b1c12" stroke-width="1" opacity=".22">`;
    for (let y = 280; y < 760; y += 14) s += `<line x1="560" y1="${y}" x2="1040" y2="${y}"/>`;
    s += `</g><rect x="1040" y="420" width="60" height="340" fill="#4a2418"/>`;
    const an = [[1, 0, 1, 1, 0], [0, 1, 1, 1, 1], [1, 0, 0, 1, 0], [0, 1, 0, 1, 1]];
    for (let r = 0; r < 4; r++) for (let c = 0; c < 5; c++) {
      const x = 600 + c * 86, y = 448 + r * 74, hell = an[r][c];
      if (r === 1 && c > 0 && c < 4) continue;
      s += bogen(x + 3, y - 4, 40, 60, hell, g);
    }
    s += `<circle cx="795" cy="556" r="190" fill="url(#${g})"/><path d="M686 590V552A109 34 0 0 1 904 552V590z" fill="#f6cf8e"/><path d="M686 562V552A109 34 0 0 1 904 552V562z" fill="#ffe6b8" opacity=".7"/><g fill="#4a2418"><rect x="738" y="522" width="4" height="68"/><rect x="793" y="518" width="4" height="72"/><rect x="848" y="522" width="4" height="68"/><rect x="686" y="562" width="218" height="3"/></g><rect x="680" y="590" width="230" height="7" fill="#3b1c12"/>`;
    s += `<circle cx="800" cy="330" r="20" fill="#f4c983"/><circle cx="800" cy="330" r="20" fill="none" stroke="#4a2418" stroke-width="4"/>`;
    // Kaimauer und Donau mit Spiegelungen
    s += `<rect x="0" y="752" width="1600" height="20" fill="#3e4954"/><rect x="0" y="772" width="1600" height="228" fill="url(#wa${u})"/>`;
    for (let r = 0; r < 4; r++) for (let c = 0; c < 5; c++) {
      if (!an[r][c] || (r === 1 && c > 0 && c < 4)) continue;
      const x = 600 + c * 86;
      for (let k = 0; k < 6; k++) s += `<rect x="${x + (z() - .5) * 16}" y="${790 + r * 26 + k * 22}" width="${46 - k * 4 + z() * 10}" height="3" fill="#f4c983" opacity="${(.5 - k * .07).toFixed(2)}"/>`;
    }
    for (let k = 0; k < 9; k++) s += `<rect x="${700 + (z() - .5) * 40}" y="${800 + k * 18}" width="${190 - k * 12}" height="3" fill="#f6cf8e" opacity="${(.55 - k * .05).toFixed(2)}"/>`;
    for (let k = 0; k < 30; k++) s += `<rect x="${(z() * 1600) | 0}" y="${(790 + z() * 200) | 0}" width="${(30 + z() * 90) | 0}" height="1.5" fill="#9fb2c4" opacity=".14"/>`;
    s += `<rect width="1600" height="1000" fill="url(#d${u})"/>`;
    return svg(s);
  }

  /* ---------- Grundriss als Linienzeichnung ---------- */
  function grundriss(ebenen, m) {
    const S = 34, luft = 56, kw = ebenen.reduce((a, e) => a + e.w * S, 0) + luft * (ebenen.length - 1);
    const hoch = Math.max(...ebenen.map(e => e.h)) * S;
    let x0 = 0, s = '';
    for (const e of ebenen) {
      s += `<g transform="translate(${x0} 0)">`;
      for (const r of e.raeume) {
        const [x, y, w, h, n, f] = r;
        s += `<rect x="${x * S}" y="${y * S}" width="${w * S}" height="${h * S}" fill="${n === 'Terrasse' || n === 'Loggia' ? 'rgba(2,56,92,.06)' : 'none'}" stroke="#02385c" stroke-width="1.6"/>`;
        if (n) s += `<text x="${(x + w / 2) * S}" y="${(y + h / 2) * S - (f ? 4 : -4)}" text-anchor="middle" font-family="Hanken Grotesk, sans-serif" font-size="${w * S < 90 ? 10 : 12}" letter-spacing="1.1" fill="#02385c">${n.toUpperCase()}</text>`;
        if (f) s += `<text x="${(x + w / 2) * S}" y="${(y + h / 2) * S + 13}" text-anchor="middle" font-family="Hanken Grotesk, sans-serif" font-size="11" fill="#5a5a5a" style="font-variant-numeric:tabular-nums">${f} m²</text>`;
      }
      s += `<rect x="0" y="0" width="${e.w * S}" height="${e.h * S}" fill="none" stroke="#02385c" stroke-width="5"/>`;
      if (e.tueren) for (const [tx, ty, tw, senk] of e.tueren) s += senk ? `<rect x="${tx * S - 3}" y="${ty * S}" width="6" height="${tw * S}" fill="#efe8df"/>` : `<rect x="${tx * S}" y="${ty * S - 3}" width="${tw * S}" height="6" fill="#efe8df"/>`;
      s += `<text x="0" y="${hoch + 28}" font-family="Hanken Grotesk, sans-serif" font-size="11" letter-spacing="1.3" fill="#5a5a5a">${e.name.toUpperCase()}</text></g>`;
      x0 += e.w * S + luft;
    }
    return `<svg viewBox="-4 -4 ${kw + 8} ${hoch + 44}" role="img" aria-label="${m}">${s}</svg>`;
  }

  /* ---------- Inhalte: drei Exposés ---------- */
  const OBJEKTE = [
    { pfad: 'patrizierturm', ort: 'Altstadt', titel: 'Vier Etagen im Patrizierturm', szene: szeneTurm,
      fakten: [['Zimmer', '4'], ['Wohnfläche', '186 m²'], ['Kaufpreis', '1.480.000 €']],
      satz: 'Wohnen, wo Regensburg früher seinen Wohlstand zeigte.',
      text: ['Der Turm stammt im Kern aus dem 13. Jahrhundert. 2021 wurde er in enger Abstimmung mit der Denkmalpflege saniert: neue Haustechnik, alte Balken, Kalkputz.', 'Jede Etage ist ein Raum. Unten Küche und Essen, darüber Wohnen, Bibliothek und Schlafen. Ganz oben liegt eine Loggia mit Blick auf die Domtürme.'],
      daten: [['Objektart', 'Wohnung im Baudenkmal'], ['Etagen', '4 + Loggia'], ['Baujahr', 'um 1260, saniert 2021'], ['Heizung', 'Fernwärme'], ['Energieausweis', 'nicht erforderlich (Denkmal)']],
      planTitel: 'Vier Räume, vier Ebenen',
      planText: 'Vier gestapelte Räume mit je rund 40 m². Treppe und Bad liegen in der Nordostecke.',
      plan: [
        { name: 'Erdgeschoss', w: 6.4, h: 6.4, raeume: [[0, 0, 6.4, 6.4, 'Küche', '38'], [4.2, 4.2, 2.2, 2.2, 'Treppe']], tueren: [[2.4, 6.4, 1.2]] },
        { name: '1. Obergeschoss', w: 6.4, h: 6.4, raeume: [[0, 0, 6.4, 6.4, 'Wohnen', '41'], [4.2, 4.2, 2.2, 2.2, 'Treppe']] },
        { name: '2. Obergeschoss', w: 6.4, h: 6.4, raeume: [[0, 0, 4.2, 6.4, 'Bibliothek', '27'], [4.2, 0, 2.2, 4.2, 'Bad', '9'], [4.2, 4.2, 2.2, 2.2, 'Treppe']] },
        { name: '3. OG und Loggia', w: 6.4, h: 6.4, raeume: [[0, 0, 6.4, 4.2, 'Schlafen', '27'], [0, 4.2, 4.2, 2.2, 'Loggia', '9'], [4.2, 4.2, 2.2, 2.2, 'Treppe']] }] },
    { pfad: 'stadtvilla-kumpfmuehl', ort: 'Kumpfmühl', titel: 'Stadtvilla mit Gartensaal', szene: szeneVilla,
      fakten: [['Zimmer', '7'], ['Wohnfläche', '264 m²'], ['Kaufpreis', '1.950.000 €']],
      satz: 'Ein Haus von 1912, das seinen Garten ernst nimmt.',
      text: ['Hohe Räume, Eichenparkett im Fischgrät und ein Gartensaal mit drei Flügeltüren zur Terrasse. Das Dach wurde 2017 neu gedeckt, die Fenster sind nach historischem Vorbild erneuert.', 'Das Grundstück misst 912 m² und ist von alten Linden gesäumt. Zum Hauptbahnhof sind es zehn Minuten zu Fuß, zur Altstadt fünfzehn.'],
      daten: [['Objektart', 'Einfamilienhaus, Villa'], ['Grundstück', '912 m²'], ['Baujahr', '1912, Dach 2017'], ['Heizung', 'Gas-Brennwert'], ['Energieausweis', 'Bedarf 118 kWh/(m²·a), D']],
      planTitel: 'Erdgeschoss mit Terrasse',
      planText: 'Erdgeschoss mit Enfilade: Diele, Salon und Gartensaal liegen in einer Achse.',
      plan: [
        { name: 'Erdgeschoss', w: 15, h: 10, raeume: [[0, 0, 5, 6, 'Küche', '30'], [5, 0, 5, 4, 'Diele', '20'], [10, 0, 5, 6, 'Salon', '30'], [0, 6, 4, 4, 'WC'], [4, 4, 11, 6, 'Gartensaal', '62']], tueren: [[6.5, 0, 2], [6, 10, 3]] },
        { name: 'Terrasse', w: 6, h: 10, raeume: [[0, 0, 6, 10, 'Terrasse', '60']] }] },
    { pfad: 'loft-getreidespeicher', ort: 'Ostenviertel', titel: 'Loft im alten Getreidespeicher', szene: szeneSpeicher,
      fakten: [['Zimmer', '3'], ['Wohnfläche', '142 m²'], ['Kaufpreis', '895.000 €']],
      satz: 'Backstein, Eichenbalken und die Donau vor dem Fenster.',
      text: ['Der Speicher von 1898 wurde 2018 zu elf Wohnungen umgebaut. Das Loft liegt im dritten Obergeschoss, mit 4,10 m Raumhöhe und dem großen Bogenfenster zum Fluss.', 'Ein offener Wohn- und Kochbereich von 78 m², zwei Schlafräume, eine Loggia nach Norden. Tiefgaragenstellplatz und Kellerabteil gehören dazu.'],
      daten: [['Objektart', 'Etagenwohnung, Loft'], ['Etage', '3. Obergeschoss, Aufzug'], ['Baujahr', '1898, Umbau 2018'], ['Heizung', 'Fernwärme'], ['Energieausweis', 'Verbrauch 74 kWh/(m²·a), B']],
      planTitel: '142 m² auf einer Ebene',
      planText: 'Ein großer Raum zum Fluss, die Schlafräume liegen ruhig zum Hof.',
      plan: [
        { name: '3. Obergeschoss', w: 16, h: 9, raeume: [[0, 0, 11, 9, 'Wohnen und Kochen', '78'], [11, 0, 5, 4.5, 'Schlafen', '22'], [11, 4.5, 5, 4.5, 'Arbeiten', '18'], [8, 6, 3, 3, 'Bad', '11']], tueren: [[3, 0, 5]] },
        { name: 'Loggia', w: 4, h: 9, raeume: [[0, 0, 4, 9, 'Loggia', '13']] }] },
  ];

  const nr = i => String(i + 1).padStart(2, '0');
  const faktenHTML = o => `<ul class="fakten">${o.fakten.map((f, i) => `<li style="--i:${i}"><span>${f[0]}</span><b class="zahl">${f[1]}</b></li>`).join('')}</ul>`;
  const buehneHTML = (o, imTeaser) => `
    <div class="buehne${imTeaser ? ' fenster' : ''}"${imTeaser ? ' aria-hidden="true"' : ''}>
      <div class="bild">${o.szene()}</div><div class="verlauf"></div>
      <div class="titelblock"><p class="kicker label">${o.ort} · Kaufen</p>
        ${imTeaser ? `<p class="h1 serif">${o.titel}</p>` : `<h1 class="h1 serif">${o.titel}</h1>`}
        ${faktenHTML(o)}</div>
    </div>`;

  function seiteHTML(i) {
    const o = OBJEKTE[i], n = (i + 1) % OBJEKTE.length, z = OBJEKTE[n];
    return `
    <section class="hero" aria-label="Exposé ${o.titel}">${buehneHTML(o, false)}</section>
    <section class="inhalt">
      <div><p class="label">Das Objekt · ${o.ort}</p><h2 class="serif">${o.satz}</h2><div class="text">${o.text.map(t => `<p>${t}</p>`).join('')}</div></div>
      <aside class="eckdaten" aria-label="Eckdaten">
        <dl><dt>Kaufpreis</dt><dd class="preis">${o.fakten[2][1]}</dd><dt>Wohnfläche</dt><dd>${o.fakten[1][1]}</dd><dt>Zimmer</dt><dd>${o.fakten[0][1]}</dd>${o.daten.map(d => `<dt>${d[0]}</dt><dd>${d[1]}</dd>`).join('')}</dl>
        <a class="knopf knopf--cta" href="#">Besichtigung anfragen</a>
        <small>Käuferprovision 3,57 % inkl. MwSt. vom Kaufpreis, fällig bei notariellem Abschluss.</small>
      </aside>
    </section>
    <section class="plan"><div><p class="label">Grundriss</p><h3>${o.planTitel}</h3><p>${o.planText}</p></div>${grundriss(o.plan, 'Grundriss ' + o.titel)}</section>
    <footer class="fuss">
      <div class="person"><span class="bildchen" aria-hidden="true">JO</span><p><b>Johanna Ostner</b>Ihre Ansprechpartnerin für dieses Objekt</p><a class="knopf" href="#">Termin vereinbaren</a></div>
      <div class="recht"><span>© 2026 Ostner &amp; Paulus Immobilien GmbH, Regensburg</span><span>Erlaubnis nach § 34c GewO</span><a href="#">Impressum</a><a href="#">Datenschutz</a><a href="#">Suchauftrag anlegen</a></div>
    </footer>
    <section class="naechstes" aria-label="Nächstes Objekt">
      <div class="klebe">
        <p class="oben label">Nächstes Objekt · <b class="zahl">${nr(n)}</b> / 03</p>
        <div class="vorab vorab--links"><p class="serif">${z.titel}</p><p class="label">${z.ort} · Regensburg</p></div>
        <dl class="vorab vorab--rechts">${z.fakten.map(f => `<div><dt>${f[0]}</dt><dd>${f[1]}</dd></div>`).join('')}</dl>
        ${buehneHTML(z, true)}
        <div class="ring" aria-hidden="true"><svg viewBox="0 0 76 76"><circle class="spur" cx="38" cy="38" r="34"/><circle class="fuell" cx="38" cy="38" r="34"/></svg><span class="zahl">${nr(n)}</span></div>
        <button type="button" class="knopf bestaetigen" data-weiter>Exposé öffnen <span aria-hidden="true">→</span></button>
        <div class="unten label">
          <span class="hinweis hinweis--text">Weiter scrollen</span>
          <span class="balken" aria-hidden="true">Weiter scrollen <i></i></span>
          <a class="direkt" href="#${z.pfad}" data-weiter>Direkt zum Exposé <span aria-hidden="true">→</span></a>
        </div>
      </div>
    </section>`;
  }

  /* ---------- Effekt ---------- */
  const seite = document.getElementById('seite');
  const anzeige = { p: document.querySelector('[data-p]'), v: document.querySelector('[data-v]'), pfad: document.querySelector('[data-pfad]'), werte: document.querySelector('[data-werte]'), nr: document.querySelector('[data-nr]') };
  const hatVT = typeof document.startViewTransition === 'function';
  const RING = 2 * Math.PI * 34;          // 213,63
  const SCHWELLE = 0.99, NIEDRIG = 0.2, VMAX = 2000, COMMIT = 250;
  let aktuell = 0, modus = matchMedia('(hover: none)').matches ? 'klick' : 'ring';
  let st = null, tween = null, teile = null, gesehenNiedrig = false, festgelegt = false, wechselt = false, commitUhr = null;
  const schmal = () => matchMedia('(max-width: 640px)').matches;
  const insets = () => schmal() ? [22, 16] : [20, 40];

  // eigene Scroll-Geschwindigkeit in px/s (steht still: 0)
  let vY = scrollY, vT = performance.now(), v = 0;
  addEventListener('scroll', () => {
    const t = performance.now(), dt = t - vT;
    if (dt > 0) v = v * .6 + ((scrollY - vY) / dt * 1000) * .4;
    vY = scrollY; vT = t;
    clearTimeout(ruheUhr); ruheUhr = setTimeout(() => { v = 0; pruefen(); }, 140);
  }, { passive: true });
  let ruheUhr = null;
  const geschw = () => performance.now() - vT > 140 ? 0 : Math.abs(v);

  function anwenden(p) {
    if (!teile) return;
    const [V, H] = insets();
    teile.fenster.style.clipPath = `inset(${Math.max(0, V - V * p)}% ${Math.max(0, H - H * p)}%)`;
    teile.bild.style.transform = `scale(${1.3 - 0.3 * p})`;
    teile.fuell.style.strokeDashoffset = RING * (1 - p);
    teile.klebe.style.setProperty('--p', p);
    const weg = Math.max(0, 1 - p * 1.6);
    teile.oben.style.opacity = weg; teile.unten.style.opacity = weg;
    teile.titel.style.opacity = Math.min(1, Math.max(0, (p - 0.55) / 0.45));
    teile.ring.style.opacity = p < .85 ? 1 : Math.max(0, 1 - (p - .85) / .15);
    anzeige.p.textContent = p.toFixed(2).replace('.', ',');
    if (p < NIEDRIG) gesehenNiedrig = true;
    teile.bestaetigen.classList.toggle('bereit', modus === 'klick' && p > SCHWELLE);
    pruefen();
  }

  // Auto-Navigation mit drei Wachen: Schwelle, Geschwindigkeit, vorher wenig Fortschritt gesehen
  function pruefen() {
    if (!tween || modus === 'klick' || reduziert() || wechselt || festgelegt) return;
    const p = tween.progress();
    if (p > SCHWELLE && gesehenNiedrig && geschw() < VMAX) {
      festgelegt = true;
      commitUhr = setTimeout(() => { if (tween && tween.progress() > SCHWELLE) wechseln(); else festgelegt = false; }, COMMIT);
    }
  }

  function aufbauen() {
    if (st) st.kill(); if (tween) tween.kill();
    st = tween = null; gesehenNiedrig = false; festgelegt = false; clearTimeout(commitUhr);
    const sek = seite.querySelector('.naechstes');
    sek.dataset.modusAktiv = modus;
    teile = { klebe: sek.querySelector('.klebe'), fenster: sek.querySelector('.fenster'), bild: sek.querySelector('.fenster .bild'), titel: sek.querySelector('.fenster .titelblock'), fuell: sek.querySelector('.fuell'), ring: sek.querySelector('.ring'), oben: sek.querySelector('.oben'), unten: sek.querySelector('.unten'), bestaetigen: sek.querySelector('.bestaetigen') };
    sek.querySelectorAll('[data-weiter]').forEach(el => el.addEventListener('click', e => { e.preventDefault(); stopDurchNutzer(); wechseln(); }));
    if (reduziert()) { teile.fenster.style.clipPath = ''; teile.bild.style.transform = ''; werteZeigen(); return; }
    const proxy = { p: 0 };
    tween = gsap.to(proxy, { p: 1, ease: 'none', paused: true, onUpdate: () => anwenden(proxy.p) });
    st = ScrollTrigger.create({ trigger: sek, start: 'top top', end: 'bottom bottom', scrub: modus === 'balken' ? true : 1, animation: tween });
    anwenden(0);
    werteZeigen();
  }

  function zeigen(i) {
    aktuell = i;
    seite.innerHTML = seiteHTML(i);
    anzeige.nr.textContent = nr(i);
    anzeige.pfad.textContent = '/objekte/' + OBJEKTE[i].pfad;
    scrollTo(0, 0);
    aufbauen();
  }
  function ankommen() {
    const hero = seite.querySelector('.hero');
    requestAnimationFrame(() => requestAnimationFrame(() => hero.classList.add('angekommen')));
  }

  // simulierter Seitenwechsel: same-document View Transition statt echter Navigation
  function wechseln() {
    if (wechselt) return;
    wechselt = true; clearTimeout(commitUhr);
    const ziel = (aktuell + 1) % OBJEKTE.length;
    const fertig = () => { wechselt = false; ankommen(); if (vf) vf.gewechselt = true; };
    if (hatVT && !reduziert()) {
      const vt = document.startViewTransition(() => zeigen(ziel));
      vt.finished.finally(fertig);
    } else { zeigen(ziel); fertig(); }
  }

  function werteZeigen() {
    const [V, H] = insets();
    if (reduziert()) { anzeige.werte.innerHTML = `Reduzierte Bewegung: <b>statischer Teaser</b> mit Fenster <b>inset(${V}% ${H}%)</b>, kein Scrub, kein Auto-Wechsel · Wechsel nur per Link, ohne View Transition`; return; }
    const basis = `<b>clip-path inset(${V}% ${H}%)</b> → 0 · Bild <b>scale 1,3</b> → 1 · Sektion <b>200 svh</b>, Inhalt sticky · `;
    anzeige.werte.innerHTML = basis + ({
      ring: `<b>scrub 1</b> · Ring <b>stroke-dashoffset</b> · Wechsel bei <b>p > 0,99</b> nach <b>250 ms</b>, nur bei <b>v < 2000 px/s</b> und vorher <b>p < 0,2</b> · View Transition <b>250 ms linear</b>`,
      balken: `<b>scrub true</b> (linear, ohne Nachlauf) · Balken <b>scaleX(p)</b> · Wechsel wie Ring: <b>p > 0,99</b>, <b>250 ms</b>, <b>v < 2000 px/s</b>`,
      klick: `<b>scrub 1</b> · kein Auto-Wechsel · ab <b>p > 0,99</b> erscheint <b>Exposé öffnen</b> (400 ms easeOutQuart)`,
    })[modus];
  }

  /* ---------- Varianten ---------- */
  const knoepfe = [...document.querySelectorAll('[data-modus]')];
  function modusSetzen(m) {
    modus = m;
    knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
    aufbauen();
    ScrollTrigger.refresh();
  }
  knoepfe.forEach(b => b.addEventListener('click', () => { const lief = !!vf; pause(); modusSetzen(b.dataset.modus); if (lief) vorfuehren(); }));
  knoepfe.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === modus)));

  /* ---------- Vorführung: Selbst-Scroll durch den Teaser, Wechsel, zurück nach unten ---------- */
  let vf = null, uhr = null;
  const sanft = q => .5 - .5 * Math.cos(Math.PI * Math.min(1, Math.max(0, q)));
  const teaserStart = () => seite.querySelector('.naechstes').offsetTop;
  const teaserWeg = () => seite.querySelector('.naechstes').offsetHeight - innerHeight;
  function schritt(t) {
    if (!vf) return;
    const e = t - vf.t0;
    if (vf.phase === 'halten') { if (e > 500) { vf.phase = 'teaser'; vf.t0 = t; } }
    else if (vf.phase === 'teaser') {
      const y0 = teaserStart(), q = e / 4000;
      scrollTo(0, y0 + teaserWeg() * sanft(q));
      if (q >= 1) { vf.phase = 'warten'; vf.t0 = t; }
    } else if (vf.phase === 'warten') {
      if (modus === 'klick' && teile.bestaetigen.classList.contains('bereit') && !vf.klickUhr) vf.klickUhr = setTimeout(() => { if (vf) wechseln(); }, 900);
      if (vf.gewechselt) { vf.phase = 'hero'; vf.t0 = t; vf.gewechselt = false; vf.klickUhr = null; }
      else if (e > 4000) wechseln();
    } else if (vf.phase === 'hero') { if (e > 1700) { vf.phase = 'runter'; vf.t0 = t; } }
    else if (vf.phase === 'runter') {
      const q = e / 2200;
      scrollTo(0, teaserStart() * sanft(q));
      if (q >= 1) { vf.phase = 'halten'; vf.t0 = t; }
    }
    uhr = requestAnimationFrame(schritt);
  }
  function vorfuehren() {
    pause();
    if (reduziert()) { vf = { phase: 'statisch', t0: performance.now() }; return statisch(); }
    scrollTo(0, teaserStart());
    ScrollTrigger.update();
    vf = { phase: 'halten', t0: performance.now(), gewechselt: false };
    uhr = requestAnimationFrame(schritt);
  }
  // Reduziert: statischer Teaser, Wechsel per Programm-Klick
  function statisch() {
    const runde = () => {
      if (!vf) return;
      scrollTo(0, teaserStart());
      uhr = setTimeout(() => { if (!vf) return; wechseln(); uhr = setTimeout(runde, 2200); }, 2400);
    };
    runde();
  }
  function pause() { cancelAnimationFrame(uhr); clearTimeout(uhr); if (vf && vf.klickUhr) clearTimeout(vf.klickUhr); uhr = null; vf = null; }
  function stopDurchNutzer() { if (vf) pause(); }
  ['wheel', 'touchstart', 'keydown'].forEach(ev => addEventListener(ev, stopDurchNutzer, { passive: true }));
  function neustart() { pause(); zeigen(0); ankommen(); }
  function weiter() { if (!vf) vorfuehren(); }

  zeigen(0); ankommen();
  addEventListener('resize', () => { if (teile && tween) anwenden(tween.progress()); werteZeigen(); });

})();
</script>
</body>
</html>