Clip-Path-Reveal der neuen Seite (Inset, Kreis, Polygon)
Die neue Seite schneidet sich mit harter Kante über die alte: als Polygon von unten, als Fenster aus der Mitte oder als Kreis vom Klickpunkt.
Beim Seitenwechsel liegt die neue Seite über der alten und wird per clip-path aufgedeckt. Die alte Seite bleibt stehen oder weicht leicht zurück: Sie rutscht nach oben, skaliert auf 0.95 oder wird dunkler. Gängige Formen: Kante von unten (inset(100% 0 0 0) oder polygon auf volle Fläche), Fenster aus der Mitte (inset(20% 40%) auf inset(0)) und Kreis vom Klickpunkt (circle(0 at x y) auf einen Radius bis zur fernsten Ecke). Gleichzeitig schrumpfen die Bilder der neuen Seite von 1.1 bis 1.3 auf 1.0.
Harte, präzise Kanten statt Weichzeichnung wirken redaktionell und teuer. Die wachsende Maske und das schrumpfende Bild laufen gegeneinander. Das erzeugt Tiefe ohne 3D.
Wann einsetzen
Bei bildstarken Seiten wie Portfolios, Agenturen und Magazinen. Der Kreis vom Klickpunkt passt zu Themenwechseln (Hell/Dunkel) und CTA-Klicks, weil er sichtbar an der Handlung ansetzt.
Wann nicht
Wenn die neue Seite nicht oben beginnt. Ohne Scroll-Reset deckt die Maske einen falschen Ausschnitt auf. Auch nicht bei sehr häufigen Wechseln in Werkzeugen und Formularen: 800 ms bremsen dort.
Varianten
- Polygon oder Inset von unten, alte Seite rutscht nach oben (Thibault Guignand)
- Fenster aus der Mitte mit Rundung, alte Seite skaliert auf 0.95 (ähnlich COLLINS)
- Kreis vom Cursor oder Tap-Punkt
- Von oben nach unten: inset(0 0 100% 0) auf inset(0), 0,8 s power2.out (Green Stack, Menübilder)
Umsetzung
Nativ mit View Transitions: ::view-transition-old(root) { animation: none } und ::view-transition-new(root) { animation: reveal .8s cubic-bezier(.87,0,.13,1) both } mit @keyframes reveal { from { clip-path: inset(100% 0 0 0) } }. Für Seiten mit echtem Seitenwechsel (MPA) zusätzlich @view-transition { navigation: auto; } in beiden Seiten. Für den Kreis die Klickkoordinaten als CSS-Variablen auf <html> setzen (beim Klick oder im pageswap-Event). Ohne View Transitions: neue Seite als position: fixed-Ebene über die alte legen, clip-path per Web Animations oder GSAP tweenen und die Bilder darin gegenläufig skalieren. Die alte Seite vorher auf Viewport-Größe einfrieren (Inhalt um scrollY versetzen), danach entfernen und auf 0 scrollen. Fallstrick: clip-path-Formen interpolieren nur bei gleicher Art und gleicher Punktzahl, Polygone brauchen identische Vertex-Anzahl.
Robust umsetzen
Telefon. Gut geeignet. Der Kreis startet auch am Tap-Punkt. Das Fenster aus der Mitte ist auf schmalen Bildschirmen anfangs sehr schmal, wirkt aber trotzdem klar.
Ohne Bewegung. Clip-Path und Bewegung abschalten, die neue Seite in 150 bis 200 ms per Opacity einblenden.
Barrierefrei. Nach dem Wechsel den Fokus auf die Hauptüberschrift der neuen Seite setzen (tabindex="-1"), die alte Seite während des Übergangs inert schalten. Bei Tastaturbedienung den Kreis von der Mitte des Links starten.
Code
<!DOCTYPE html>
<!--
Clip-Path-Reveal der neuen Seite (Inset, Kreis, Polygon)
Fundus · Seitenübergänge · https://cecodesign.de/lab/fundus/seitenuebergaenge/clip-path-reveal-seite/
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>Clip-Path-Reveal der neuen Seite · 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: '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-style: normal; font-display: swap; }
@font-face { font-family: 'Hanken Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/hanken-grotesk:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; font-style: italic; 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; }
/* Rezept „architektur-ruhiges-raster“: eine Familie, zwei Gewichte, achromatisch, die Ansichten liefern die Farbe */
:root {
--kurve: cubic-bezier(0.87, 0, 0.13, 1); /* expo.inOut, gemessen bei Thibault Guignand */
--raus: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out für Hover */
--rand: clamp(16px, 3.4vw, 48px);
--schwarz: #0b0b0a;
--fokus: #0022ff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--schwarz); font-family: 'Hanken Grotesk', system-ui, sans-serif; font-weight: 400; letter-spacing: -.012em; -webkit-font-smoothing: antialiased; scroll-behavior: auto; }
body { min-height: 100svh; }
a { color: inherit; text-decoration: none; }
em { font-style: italic; font-weight: inherit; }
.mono { font: 500 11px/1.45 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
.meta { font-size: 13px; line-height: 1.45; letter-spacing: 0; font-variant-numeric: tabular-nums; }
.label { font-size: 11.5px; line-height: 1.4; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; }
/* ---------- Seiten ---------- */
.seite { --linie: color-mix(in srgb, var(--fg) 14%, transparent); --leise: color-mix(in srgb, var(--fg) 66%, transparent);
background: var(--bg); color: var(--fg); min-height: 100svh; }
.seite .meta, .seite .label { color: var(--leise); }
/* Während des Wechsels liegen beide Seiten als Viewport-große Ebenen übereinander */
.seite.fest { position: fixed; inset: 0; overflow: hidden; z-index: 2; }
.seite.fest.geht { z-index: 1; }
.innen { padding-bottom: 160px; }
/* Ansichten (eigene SVG-Zeichnungen) */
.ansicht { display: block; width: 100%; height: 100%; }
.ansicht svg { display: block; width: 100%; height: 100%; }
.nav { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px; height: 64px; padding: 0 var(--rand); }
.nav > :last-child { justify-self: end; }
.nav__punkte { display: flex; gap: clamp(14px, 2vw, 28px); font-size: 14px; }
.nav__punkte span:first-child { text-decoration: underline; text-underline-offset: .35ex; text-decoration-thickness: 8%; }
.marke { display: inline-flex; align-items: center; gap: .55em; font-size: 15px; font-weight: 500; letter-spacing: -.01em; white-space: nowrap; }
.marke i { flex: none; width: 15px; height: 15px; border: 1.5px solid currentColor; background: linear-gradient(currentColor, currentColor) 62% 0 / 1.5px 100% no-repeat, linear-gradient(currentColor, currentColor) 0 58% / 62% 1.5px no-repeat; }
.marke small { font-size: inherit; font-weight: 400; color: var(--leise); }
.zurueck { display: inline-flex; gap: .6em; align-items: center; font-size: 14px; }
.zurueck span { transition: translate .15s linear; }
.zurueck:hover span { translate: -4px 0; }
/* Übersicht */
.aufmacher { display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 4fr); align-items: end; gap: 20px 48px; padding: clamp(32px, 9vh, 112px) var(--rand) clamp(32px, 6vh, 72px); }
.titel { font-size: clamp(38px, 5.4vw, 88px); font-weight: 430; line-height: .98; letter-spacing: -.035em; text-wrap: balance; }
.titel em { color: var(--leise); }
.titel:focus { outline: none; }
.intro { font-size: clamp(15px, 1.12vw, 17px); line-height: 1.45; max-width: 44ch; color: var(--leise); text-wrap: pretty; }
.abschnitt { display: flex; justify-content: space-between; padding: 0 var(--rand) 14px; }
.raster { list-style: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: clamp(12px, 1.4vw, 20px); padding: 0 var(--rand); }
.karte { display: grid; gap: 12px; }
.karte__bild { display: block; aspect-ratio: 4 / 3; overflow: hidden; background: #e1e3e3; }
.karte__bild .ansicht { transition: transform .9s var(--raus); }
.karte__text { display: grid; grid-template-columns: auto 1fr auto; align-items: baseline; gap: 2px 10px; }
.karte__text b { font-weight: 500; font-size: clamp(15px, 1.15vw, 17px); letter-spacing: -.012em; }
.karte__text .meta:last-child { grid-column: 2 / 4; }
.pfeil { opacity: 0; translate: -6px 0; transition: opacity .15s linear, translate .6s var(--raus); }
@media (hover: hover) {
.karte:hover .karte__bild .ansicht { transform: scale(1.04); }
.karte:hover .pfeil { opacity: 1; translate: 0 0; }
}
.karte:focus-visible, .naechstes:focus-visible, .zurueck:focus-visible { outline: 2px solid var(--fokus); outline-offset: 6px; }
.fuss { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px 20px; margin: clamp(64px, 22vh, 240px) var(--rand) 0; padding-top: 16px; border-top: 1px solid var(--linie); }
.fuss p { line-height: 1.5; }
/* Projektseite */
.held { height: clamp(300px, 54svh, 640px); overflow: hidden; margin: 0 var(--rand); background: var(--bg); }
.held__unter { display: flex; justify-content: space-between; gap: 16px; padding: 10px var(--rand) 0; }
.kopf { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 360px); gap: 32px 48px; align-items: end; padding: clamp(28px, 6vh, 64px) var(--rand) 0; }
.kopf .titel { margin: 12px 0 14px; font-size: clamp(38px, 5vw, 82px); }
.unter { font-size: clamp(19px, 1.9vw, 27px); line-height: 1.2; font-weight: 400; max-width: 30ch; }
.unter em { color: var(--leise); }
.fakten div { display: grid; grid-template-columns: 8.5em 1fr; gap: 12px; padding: 9px 0; border-top: 1px solid var(--linie); }
.fakten dd { color: var(--fg); }
.text { display: grid; grid-template-columns: repeat(2, minmax(0, 50ch)); gap: 24px 48px; padding: clamp(36px, 7vh, 80px) var(--rand) 0; font-size: clamp(16px, 1.15vw, 18px); line-height: 1.5; }
.naechstes { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 24px; margin: clamp(56px, 11vh, 128px) var(--rand) 0; padding-top: 18px; border-top: 1px solid var(--linie); }
.naechstes__titel { display: block; margin-top: 8px; font-size: clamp(32px, 4.6vw, 76px); font-weight: 430; letter-spacing: -.035em; line-height: 1; }
.naechstes__bild { width: clamp(120px, 16vw, 240px); aspect-ratio: 4 / 3; overflow: hidden; }
/* ---------- Demo-Steuerung ---------- */
.dock { position: fixed; z-index: 40; left: 50%; bottom: 16px; translate: -50% 0; width: max-content; max-width: calc(100% - 24px);
display: grid; gap: 8px; padding: 10px 12px; border-radius: 10px; color: #f3f4f4;
background: rgba(31, 35, 35, .8); -webkit-backdrop-filter: blur(14px) saturate(1.1); backdrop-filter: blur(14px) saturate(1.1); box-shadow: 0 10px 40px rgba(0, 0, 0, .18); }
.dock__zeile { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.dock .mono { color: rgba(243, 244, 244, .56); }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: rgba(243, 244, 244, .72); background: none; border: 1px solid rgba(243, 244, 244, .18); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: background-color .15s linear, color .15s linear; }
.modi button:hover { color: #f3f4f4; }
.modi button[aria-pressed='true'] { background: #f3f4f4; color: #1f2323; border-color: #f3f4f4; }
.modi button:focus-visible { outline: 2px solid #8fa0ff; outline-offset: 2px; }
.werte { font-size: 10px; line-height: 1.6; letter-spacing: .06em; text-transform: none; }
.werte > span { display: block; white-space: nowrap; }
.werte b { color: #f3f4f4; font-weight: 500; }
/* Gedachter Zeiger für die Vorführung */
.zeiger { position: fixed; z-index: 50; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border-radius: 50%; border: 1.5px solid #fff; background: rgba(255, 255, 255, .22); mix-blend-mode: difference; pointer-events: none; opacity: 0; transition: opacity .15s linear; }
.zeiger.an { opacity: 1; }
/* Reduzierte Bewegung: kein Clip, kein Scale, nur kurzes Einblenden (im JS) */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .werte { display: none; }
@media (hover: none) {
.pfeil { opacity: 1; translate: 0 0; }
.modi button { height: 34px; }
}
@media (max-width: 1100px) {
.nav__punkte span:nth-child(n+4) { display: none; }
}
@media (max-width: 900px) {
.aufmacher, .kopf { grid-template-columns: minmax(0, 1fr); }
.raster { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 24px; }
.text { grid-template-columns: minmax(0, 50ch); }
.fuss { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.nav__punkte { display: none; }
}
@media (max-width: 720px) {
.werte > span { white-space: normal; }
}
@media (max-width: 560px) {
.dock__zeile > span.mono { display: none; }
.nav { grid-template-columns: auto 1fr; }
.nav .nav__mitte { display: none; }
.nav__info { display: none; }
.marke small { display: none; }
.held { height: 46svh; margin: 0; }
.held__unter .meta:last-child { display: none; }
.naechstes { grid-template-columns: minmax(0, 1fr); }
.naechstes__bild { display: none; }
.werte .lang { display: none; }
.dock { padding: 8px 10px; bottom: 12px; width: calc(100% - 24px); }
}
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<div id="buehne"></div>
<aside class="dock" aria-label="Demo-Steuerung">
<div class="dock__zeile">
<span class="mono">Übergang</span>
<div class="modi mono" role="group" aria-label="Form des Clip-Path">
<button type="button" data-variante="polygon" aria-pressed="true">Polygon</button>
<button type="button" data-variante="inset" aria-pressed="false">Inset</button>
<button type="button" data-variante="kreis" aria-pressed="false">Kreis</button>
</div>
</div>
<p class="werte mono" data-werte></p>
</aside>
<div class="zeiger" aria-hidden="true"></div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const buehne = document.getElementById('buehne');
const zeiger = document.querySelector('.zeiger');
const werteFeld = document.querySelector('[data-werte]');
/* Werte: Kurve und Dauer gemessen bei Thibault Guignand (::view-transition, .8s) */
const KURVE = 'cubic-bezier(0.87, 0, 0.13, 1)';
const DAUER = 800; // Clip-Path der neuen Seite und Bewegung der alten
const GEGEN = 1.2; // Bild der neuen Seite skaliert 1.2 → 1 (Gegenbewegung)
const GEGEN_DAUER = 1000;
const FADE = 180; // reduzierte Bewegung: nur Opacity
const VARIANTEN = {
polygon: {
neu: () => ['polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)', 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)'],
alt: h => ({ transform: ['translateY(0px)', `translateY(${Math.round(-0.35 * h)}px)`], opacity: [1, 0.5] }),
text: () => '<span><b>neu</b> polygon(0 100%, 100% 100%, 100% 100%, 0 100%) → polygon(0 0, 100% 0, 100% 100%, 0 100%)</span><span><b>alt</b> translateY(0 → −35 %) · opacity 1 → 0.5</span>'
},
inset: {
neu: () => ['inset(20% 40% round 24px)', 'inset(0% 0% round 0px)'],
alt: () => ({ transform: ['scale(1)', 'scale(0.95)'], borderRadius: ['0px', '16px'], opacity: [1, 0.6] }),
text: () => '<span><b>neu</b> inset(20% 40% round 24px) → inset(0 round 0)</span><span><b>alt</b> scale 1 → 0.95 · radius 0 → 16px · opacity 1 → 0.6</span>'
},
kreis: {
neu: p => [`circle(0px at ${p.x}px ${p.y}px)`, `circle(${p.r}px at ${p.x}px ${p.y}px)`],
alt: null,
text: p => `<span><b>neu</b> circle(0 at ${p ? p.x + 'px ' + p.y + 'px' : 'Klickpunkt'}) → circle(${p ? p.r + 'px' : 'Abstand zur fernsten Ecke'})</span><span><b>alt</b> bleibt stehen</span>`
}
};
let variante = 'polygon', letzterPunkt = null;
function zeigeWerte() {
const v = VARIANTEN[variante];
werteFeld.innerHTML = reduziert()
? '<span><b>reduziert</b> opacity 0 → 1 · 180 ms linear · kein Clip, kein Scale</span>'
: v.text(variante === 'kreis' ? letzterPunkt : null) + `<span class="lang"><b>${DAUER} ms</b> · ${KURVE} · Bild scale ${GEGEN} → 1 in ${GEGEN_DAUER} ms</span>`;
}
function setzeVariante(name) {
variante = name;
document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === name)));
zeigeWerte();
}
/* ---------- Bildwelt: eigene Ansichten als SVG (Fassaden im Maßstab, keine Fotos) ---------- */
let uid = 0;
const R = (x, y, w, h, f, extra = '') => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${f}"${extra}/>`;
const rund = n => Math.round(n * 10) / 10;
// Fenster mit Laibungsschatten, Glasreflex und Sohlbank
function fenster(x, y, w, h, o = {}) {
const glas = o.glas || '#2c3133', reflex = o.reflex || '#8a9aa0', bank = o.bank || 'rgba(255,255,255,.55)';
return R(x, y, w, h, glas) +
`<path d="M${x} ${y + h}L${x + w * .55} ${y}H${x + w * .8}L${x + w * .25} ${y + h}Z" fill="${reflex}" opacity=".15"/>` +
R(x, y, w, Math.max(3, h * .07), '#000', ' opacity=".28"') +
(o.sprosse ? R(x + w / 2 - .75, y, 1.5, h, o.sprosse) : '') +
(o.ohneBank ? '' : R(x - 2, y + h, w + 4, 3, bank));
}
function baum(x, boden, s, farbe = '#6f7c5d', licht = '#8b9873') {
const k = (dx, dy, r, f) => `<circle cx="${rund(x + dx * s)}" cy="${rund(boden + dy * s)}" r="${rund(r * s)}" fill="${f}"/>`;
return R(rund(x - 3 * s), rund(boden - 70 * s), rund(6 * s), rund(70 * s), '#4a4740') +
k(-34, -112, 46, farbe) + k(30, -118, 50, farbe) + k(0, -160, 54, farbe) + k(-10, -92, 40, farbe) +
k(-14, -168, 30, licht) + k(26, -134, 24, licht);
}
function mensch(x, boden, s = 1, f = '#2a2d2e') {
return `<g fill="${f}"><circle cx="${x}" cy="${boden - 46 * s}" r="${5.2 * s}"/><path d="M${x - 7 * s} ${boden - 39 * s}h${14 * s}l${2 * s} ${22 * s}h${-4 * s}l-1 ${17 * s}h${-3 * s}l-1 ${-15 * s}l-1 ${15 * s}h${-3 * s}l-1 ${-17 * s}h${-4 * s}z"/></g>`;
}
const ANSICHTEN = {
// Neubau in Klinker, fünf Geschosse plus Staffel, Loggien in Doppelachsen
hulsberg(u) {
const boden = 640, x0 = 170, b = 860, g = 80, oben = boden - 5 * g;
let s = `<defs><pattern id="${u}k" width="24" height="16" patternUnits="userSpaceOnUse"><path d="M0 7.5h24M0 15.5h24M12 0v7.5M0 8v7.5M24 8v7.5" stroke="#3a160c" stroke-opacity=".2" fill="none"/></pattern>
<linearGradient id="${u}l" x1="0" x2="1"><stop offset="0" stop-color="#fff" stop-opacity=".06"/><stop offset="1" stop-color="#000" stop-opacity=".14"/></linearGradient></defs>`;
s += R(-200, 0, 1600, 800, '#dde2e3');
s += `<g fill="#b9c2bd">${[-120, 60, 1110, 1180, 1340].map((x, i) => `<circle cx="${x}" cy="${560 - (i % 3) * 10}" r="${70 + (i % 3) * 8}"/>`).join('')}</g>`;
// Nachbarhäuser angeschnitten, blass wie in einer Wettbewerbsansicht
s += R(-200, 360, 300, 280, '#c9c4bd') + R(1100, 330, 300, 310, '#c4c7c4');
for (let f = 0; f < 3; f++) for (let a = 0; a < 3; a++) { s += R(-170 + a * 90, 390 + f * 80, 38, 50, '#9ea2a1') + R(1135 + a * 90, 360 + f * 86, 38, 52, '#9ea2a1'); }
// Staffelgeschoss in Zink
s += R(x0 + 64, oben - 74, b - 128, 74, '#8f9493');
for (let x = x0 + 64; x < x0 + b - 64; x += 18) s += R(x, oben - 74, 1, 74, '#000', ' opacity=".12"');
s += R(x0 + 40, oben - 4, b - 80, 6, '#d6d8d6');
for (let i = 0; i < 6; i++) s += fenster(x0 + 120 + i * 118, oben - 60, 62, 52, { ohneBank: true });
// Klinkerkörper
s += R(x0, oben, b, 5 * g, '#a4593f') + R(x0, oben, b, 5 * g, `url(#${u}k)`) + R(x0, oben, b, 5 * g, `url(#${u}l)`);
s += R(x0 - 4, oben - 6, b + 8, 8, '#cfc9c2');
const achsen = 10, p = b / achsen;
for (let f = 1; f < 5; f++) {
const y = oben + (4 - f) * g;
for (let a = 0; a < achsen; a++) {
const loggia = (a === 2 || a === 6) && true;
if (a === 3 || a === 7) continue;
const cx = x0 + p * a + p / 2;
if (loggia) {
const lx = x0 + p * a + 12, lw = p * 2 - 24;
s += R(lx, y + 10, lw, g - 10, '#5e2e22');
s += fenster(lx + 18, y + 18, lw - 36, g - 18, { sprosse: '#4b2219', ohneBank: true });
s += R(lx, y + g - 30, lw, 2, '#d9d4cc') + R(lx, y + g - 2, lw, 3, '#cfc9c2');
for (let r = lx + 6; r < lx + lw; r += 9) s += R(r, y + g - 30, 1.2, 28, '#d9d4cc', ' opacity=".7"');
} else s += fenster(cx - 20, y + 14, 40, 54);
}
}
// Erdgeschoss: hohe Öffnungen, Eingang mit Vordach
for (let a = 0; a < achsen; a++) {
const cx = x0 + p * a + p / 2;
if (a === 4) { s += R(cx - 34, boden - 70, 68 + p, 70, '#2f3537') + R(cx - 44, boden - 78, 88 + p, 6, '#d6d8d6'); continue; }
if (a === 5) continue;
s += fenster(cx - 26, boden - 66, 52, 62, { sprosse: '#20262a', ohneBank: true });
}
s += R(x0, boden - 4, b, 4, '#5a2b1f');
// Boden, Gehweg, Straße
s += R(-200, boden, 1600, 160, '#cfd2cf') + R(-200, boden + 46, 1600, 2, '#b4b8b5') + R(-200, boden + 80, 1600, 80, '#babdbb');
s += baum(110, boden, 1.15) + baum(1090, boden, 1.05, '#66735a') + baum(-130, boden, .9, '#748160') + baum(1330, boden, .95, '#6f7c5d') + baum(600, boden + 2, .55, '#76835f');
s += mensch(470, boden + 30) + mensch(492, boden + 32, .82, '#3b3f40') + mensch(860, boden + 26, .95);
return s;
},
// Umbau eines Getreidespeichers am Hafen, neue Öffnungen in Stahl und Glas
speicher(u) {
const boden = 610, x0 = 380, b = 440, g = 50, oben = boden - 8 * g, first = oben - 120;
let s = `<defs><pattern id="${u}k" width="20" height="12" patternUnits="userSpaceOnUse"><path d="M0 5.5h20M0 11.5h20M10 0v5.5M0 6v5.5M20 6v5.5" stroke="#1d0a05" stroke-opacity=".24" fill="none"/></pattern>
<linearGradient id="${u}h" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#cfd5d6"/><stop offset="1" stop-color="#e8eaea"/></linearGradient>
<clipPath id="${u}c"><path d="M${x0} ${boden}V${oben}L600 ${first}L${x0 + b} ${oben}V${boden}Z"/></clipPath></defs>`;
s += R(-200, 0, 1600, 800, `url(#${u}h)`);
// Nachbar links: Bürohaus mit Fensterbändern
s += R(110, boden - 230, 270, 230, '#b9bab6');
for (let i = 0; i < 4; i++) s += R(110, boden - 210 + i * 52, 270, 26, '#3c4446') + R(110, boden - 184 + i * 52, 270, 2, '#e3e4e2');
// Kran rechts
s += `<g stroke="#5f6668" stroke-width="5" fill="none"><path d="M960 ${boden}V170M960 190H1150M960 190L1040 150M1120 190V300"/></g>` + R(1110, 300, 20, 16, '#5f6668');
// Speicher
s += `<g clip-path="url(#${u}c)">` + R(x0, first, b, boden - first, '#6d392c') + R(x0, first, b, boden - first, `url(#${u}k)`) + R(x0 + b / 2, first, b / 2, boden - first, '#000', ' opacity=".1"') + '</g>';
s += `<path d="M${x0 - 10} ${oben + 4}L600 ${first - 8}L${x0 + b + 10} ${oben + 4}" fill="none" stroke="#c9c3ba" stroke-width="7"/>`;
s += `<circle cx="600" cy="${oben - 44}" r="22" fill="#2c3133"/><circle cx="600" cy="${oben - 44}" r="22" fill="none" stroke="#c9c3ba" stroke-width="4"/>`;
// alte Lochfenster
const spalten = [x0 + 30, x0 + 92, x0 + 330, x0 + 392];
for (let f = 0; f < 8; f++) spalten.forEach(x => { s += fenster(x, oben + f * g + 12, 22, 28, { bank: '#c9c3ba' }); });
// neue Mittelachse: raumhohe Verglasung, Stahlrahmen
s += R(x0 + 140, oben + 8, 160, 7 * g - 8, '#2d3132');
for (let f = 0; f < 7; f++) {
const y = oben + 14 + f * g;
s += R(x0 + 148, y, 144, g - 12, '#b9c7cb') + `<path d="M${x0 + 148} ${y + g - 12}L${x0 + 220} ${y}H${x0 + 250}L${x0 + 178} ${y + g - 12}Z" fill="#fff" opacity=".28"/>` + R(x0 + 219, y, 2, g - 12, '#2d3132');
s += R(x0 + 140, y + g - 18, 160, 2, '#8d9596');
}
// Erdgeschoss offen, Kai
s += R(x0 + 140, boden - 50, 160, 50, '#2d3132') + R(x0 + 150, boden - 44, 140, 44, '#e3d9c4', ' opacity=".9"');
s += R(-200, boden, 1600, 40, '#8f918d') + R(-200, boden, 1600, 4, '#6f726f');
for (let x = -140; x < 1400; x += 210) s += R(x, boden - 14, 14, 14, '#3f4445');
s += mensch(596, boden, .9) + mensch(330, boden, .85, '#3b3f40') + mensch(720, boden, .95);
// Wasser mit Spiegelung
s += R(-200, boden + 40, 1600, 160, '#5f7277');
s += `<g opacity=".22">` + R(x0, boden + 44, b, 140, '#2a1712') + R(x0 + 140, boden + 44, 160, 130, '#dfe8ea') + '</g>';
for (let i = 0; i < 18; i++) s += R(-160 + (i * 173) % 1500, boden + 52 + i * 10, 80 + (i * 37) % 120, 2, '#9fb1b5', ' opacity=".5"');
return s;
},
// Zeilenbau der 50er, saniert und um zwei Geschosse in Holz aufgestockt
vahr(u) {
const boden = 640, x0 = 70, b = 1060, g = 70, oben = boden - 3 * g, top = oben - 2 * 74;
let s = `<defs><pattern id="${u}h" width="9" height="10" patternUnits="userSpaceOnUse"><rect width="9" height="10" fill="#b4875a"/><rect x="7" width="2" height="10" fill="#7d5735" opacity=".55"/></pattern></defs>`;
s += R(-200, 0, 1600, 800, '#e3e7e6');
// Aufstockung Holz
s += R(x0 + 20, top, b - 40, oben - top, `url(#${u}h)`) + R(x0 + 20, top - 8, b - 40, 8, '#7d8b5f') + R(x0 + 14, top - 10, b - 28, 3, '#5a5e57');
for (let f = 0; f < 2; f++) {
const y = top + f * 74;
for (let i = 0; i < 10; i++) {
const x = x0 + 50 + i * 100;
s += fenster(x, y + 12, 58, 56, { sprosse: '#6b4a2c', ohneBank: true });
if (i % 2 === 0) s += R(x - 10, y + 66, 78, 6, '#4a4f50') + R(x - 10, y + 44, 78, 1.5, '#4a4f50') + [0, 26, 52, 77].map(d => R(x - 10 + d, y + 44, 1.5, 22, '#4a4f50')).join('');
}
}
// Bestand Putz
s += R(x0, oben, b, 3 * g, '#d9dad5') + R(x0 + b * .55, oben, b * .45, 3 * g, '#000', ' opacity=".05"') + R(x0, oben - 3, b, 3, '#9a9c97');
for (let f = 0; f < 3; f++) {
const y = oben + f * g;
for (let i = 0; i < 16; i++) {
if (i === 4 || i === 11) continue;
const x = x0 + 38 + i * 64.5;
s += fenster(x, y + 14, 36, 42, { bank: '#f2f2ef' });
}
}
// Treppenhäuser mit Glasstreifen
[4, 11].forEach(i => { const x = x0 + 38 + i * 64.5; s += R(x + 4, oben + 8, 28, 3 * g - 8, '#2f3638') + R(x + 8, oben + 12, 20, 3 * g - 60, '#9fb0b5', ' opacity=".45"') + R(x - 10, boden - 54, 56, 5, '#eceeea'); });
// Rasen, Weg, Bäume
s += R(-200, boden, 1600, 160, '#aab38f') + R(-200, boden + 22, 1600, 22, '#d4d6cf');
s += baum(40, boden + 10, 1.35, '#6c7b56', '#869466') + baum(1150, boden + 6, 1.25, '#65744f', '#7f8e60') + baum(560, boden + 20, .62, '#748458', '#8b9a6c') + baum(-150, boden + 14, 1.1, '#748458', '#8b9a6c') + baum(1340, boden + 12, 1.0, '#6c7b56', '#869466');
s += mensch(300, boden + 40, 1) + mensch(780, boden + 38, .9, '#3b3f40') + mensch(804, boden + 40, .62, '#3b3f40');
return s;
},
// Lückenschluss zwischen zwei Bremer Häusern, Sichtbeton mit Eichenlaibungen
ostertor(u) {
const boden = 650;
let s = R(-200, 0, 1600, 800, '#e1e5e6');
s += R(-200, 330, 260, boden - 330, '#c9ccc8') + R(1140, 300, 260, boden - 300, '#d2cdc1');
for (let f = 0; f < 3; f++) { [-160, -60].forEach(x => { s += R(x, 356 + f * 98, 46, 70, '#a5aaa9'); }); [1180, 1290].forEach(x => { s += R(x, 330 + f * 104, 48, 74, '#aaa69b'); }); }
// Nachbar links: Altbremer Haus, Putz, Gesimse, Erker
s += R(60, 300, 330, boden - 300, '#d3cbb8') + `<path d="M50 300L120 236H330L400 300Z" fill="#7b7f7d"/>` + R(160, 248, 60, 40, '#c8c0ae') + fenster(174, 256, 32, 30, { ohneBank: true });
[400, 500].forEach(y => { s += R(52, y - 6, 346, 6, '#c2b9a5'); });
for (let f = 0; f < 3; f++) [100, 290].forEach(x => { s += fenster(x, 320 + f * 105, 50, 76, { sprosse: '#f3f1ea', bank: '#efece4' }); });
s += R(170, 330, 100, 220, '#cbc2ae') + fenster(186, 350, 68, 80, { sprosse: '#f3f1ea' }) + fenster(186, 452, 68, 80, { sprosse: '#f3f1ea' });
s += R(150, boden - 82, 50, 82, '#4b3a2f') + R(60, boden - 8, 330, 8, '#a59d8c');
// Nachbar rechts: Haus mit Satteldach und Gaube
s += R(810, 320, 330, boden - 320, '#a9b6b8') + `<path d="M800 322L975 228L1150 322Z" fill="#6f7577"/>` + R(945, 260, 60, 40, '#9aa7a9') + fenster(960, 268, 30, 28, { ohneBank: true });
for (let f = 0; f < 3; f++) [850, 945, 1040].forEach(x => { s += fenster(x, 344 + f * 100, 52, 72, { sprosse: '#eef1f1', bank: '#eef1f1' }); });
s += R(810, boden - 8, 330, 8, '#8d999b');
// Neubau: vier Geschosse plus Staffel, Sichtbeton, tiefe Eichenlaibungen
const x0 = 390, b = 420;
s += R(x0 + 30, 186, b - 60, 60, '#8e8d88') + R(x0 + 40, 196, b - 80, 42, '#3a3f41', ' opacity=".9"');
s += R(x0, 246, b, boden - 246, '#b0afa8') + R(x0 + b / 2, 246, b / 2, boden - 246, '#000', ' opacity=".04"');
for (let y = 246; y < boden; y += 50) s += R(x0, y, b, 1, '#000', ' opacity=".09"');
for (let x = x0 + 70; x < x0 + b; x += 140) s += R(x, 246, 1, boden - 246, '#000', ' opacity=".06"');
for (let f = 0; f < 4; f++) {
const y = 266 + f * 92;
[x0 + 44, x0 + 236].forEach((x, i) => {
s += R(x - 10, y - 10, 160, 82, '#c59a68') + R(x - 10, y - 10, 10, 82, '#9a7146') + R(x - 10, y - 10, 160, 8, '#8b6640');
s += fenster(x, y, 140, 72, { sprosse: i ? '' : '#2a2e30', ohneBank: true, reflex: '#c9d6da' });
});
}
s += R(x0 + 30, boden - 46, b - 60, 46, '#3a3f41') + R(x0 + 150, boden - 46, 70, 46, '#c59a68') + R(x0 + 157, boden - 40, 56, 40, '#5a4330');
// Gehweg, Straße, Baum
s += R(-200, boden, 1600, 150, '#cdd0cd') + R(-200, boden + 40, 1600, 4, '#a7aba8') + R(-200, boden + 44, 1600, 110, '#b7bab7');
s += baum(1185, boden + 4, 1.05, '#6d7a5a', '#86936d') + baum(-110, boden + 4, .9, '#748160', '#8b9873') + mensch(560, boden + 28) + mensch(250, boden + 24, .9, '#3b3f40');
s += `<g stroke="#2a2d2e" stroke-width="2.5" fill="none"><circle cx="690" cy="${boden + 18}" r="15"/><circle cx="735" cy="${boden + 18}" r="15"/><path d="M690 ${boden + 18}L705 ${boden - 6}H728L735 ${boden + 18}M705 ${boden - 6}L712 ${boden + 18}L728 ${boden - 6}"/></g>`;
return s;
}
};
function ansicht(id, label) {
const u = 'z' + (++uid);
return `<span class="ansicht" data-gegen><svg viewBox="-200 0 1600 800" preserveAspectRatio="xMidYMid slice" ${label ? `role="img" aria-label="${label}"` : 'aria-hidden="true"'}>${ANSICHTEN[id](u)}</svg></span>`;
}
/* ---------- Inhalte: erfundenes Architekturbüro Lindgren Weiss, Bremen ---------- */
const PROJEKTE = [
{ id: 'hulsberg', name: 'Wohnhof Hulsberg', art: 'Neubau', ort: 'Bremen-Hulsberg', jahr: '2025', bg: '#1f2323', fg: '#eef0f0',
unter: '48 Wohnungen um einen Hof, <em>gemauert für hundert Jahre.</em>',
fakten: [['Bauherr', 'Wohnungsgenossenschaft'], ['Leistung', 'LPH 1–8'], ['Wohnungen', '48, davon 16 gefördert'], ['Fläche', '5 240 m² BGF']],
text: ['Auf einem früheren Klinikgelände stehen jetzt zwei Häuser um einen gemeinsamen Hof. Die Wohnungen gehen alle durch das Gebäude, jede hat Morgen- und Abendlicht.', 'Der Klinker kommt aus einem Werk an der Weser und nimmt die Farbe der Nachbarschaft auf. Die Loggien liegen in Doppelachsen und bleiben trotz Tiefe hell.'] },
{ id: 'speicher', name: 'Speicher am Holzhafen', art: 'Umbau', ort: 'Bremen-Überseestadt', jahr: '2024', bg: '#e1e3e3', fg: '#231f20',
unter: 'Ein Getreidespeicher von 1926 <em>wird zum Wohnhaus.</em>',
fakten: [['Bauherr', 'Privat'], ['Leistung', 'LPH 1–9, Denkmalpflege'], ['Wohnungen', '36 Lofts, Gewerbe im EG'], ['Fläche', '4 100 m² BGF']],
text: ['Die Hülle bleibt, die alten Lochfenster auch. Nur die Ladeachse in der Mitte öffnen wir über alle Geschosse und setzen eine Verglasung in schlanken Stahlprofilen ein.', 'Hinter den dicken Mauern liegen hohe Lofts mit den originalen Pilzstützen. Im Erdgeschoss öffnet eine Bäckerei zum Kai, abends bleibt das Licht an.'] },
{ id: 'vahr', name: 'Aufstockung Gartenstadt Vahr', art: 'Bestand', ort: 'Bremen-Vahr', jahr: '2023', bg: '#1f2323', fg: '#eef0f0',
unter: 'Zwei Geschosse Holz <em>auf einem Zeilenbau von 1957.</em>',
fakten: [['Bauherr', 'Kommunale Gesellschaft'], ['Leistung', 'LPH 1–8, Tragwerk mit Partnern'], ['Wohnungen', '+ 24 neue, 60 saniert'], ['Bauzeit', '14 Monate, bewohnt']],
text: ['Statt abzureißen bauen wir weiter. Die Aufstockung aus vorgefertigten Holzelementen wiegt so wenig, dass der alte Mauerwerksbau sie ohne neue Fundamente trägt.', 'Die Mieterinnen und Mieter blieben während der Bauzeit in ihren Wohnungen. Neue Balkone hängen vor der Fassade, die Treppenhäuser bekommen Aufzüge.'] },
{ id: 'ostertor', name: 'Stadthaus Ostertor', art: 'Lückenschluss', ort: 'Bremen-Ostertor', jahr: '2022', bg: '#e1e3e3', fg: '#231f20',
unter: 'Ein schmales Haus <em>zwischen zwei Altbremern.</em>',
fakten: [['Bauherr', 'Baugemeinschaft'], ['Leistung', 'LPH 1–9'], ['Wohnungen', '5 auf 420 m² BGF'], ['Grundstück', '8,40 m breit']],
text: ['Die Baulücke war seit dem Krieg ein Parkplatz. Das neue Haus übernimmt Traufhöhe und Fensterachsen der Nachbarn, spricht aber eine eigene Sprache.', 'Die Fassade ist aus eingefärbtem Sichtbeton, die tiefen Laibungen aus Eiche. Fünf Familien haben gemeinsam gebaut, jede Etage ist anders geschnitten.'] }
];
const UEBERSICHT = { bg: '#ffffff', fg: '#231f20' };
const nav = (links, mitte, rechts) => `<header class="nav">${links}<span class="nav__mitte">${mitte}</span>${rechts}</header>`;
const marke = '<span class="marke"><i aria-hidden="true"></i>Lindgren Weiss <small>Architekten</small></span>';
const zeile = p => `${p.art} • ${p.ort.replace('Bremen-', '')} • ${p.jahr}`;
function seiteUebersicht() {
const karten = PROJEKTE.map((p, i) => `
<li><a class="karte" href="#${p.id}" data-ziel="${p.id}">
<span class="karte__bild">${ansicht(p.id)}</span>
<span class="karte__text"><span class="meta">0${i + 1}</span><b>${p.name}</b><span class="pfeil" aria-hidden="true">→</span><span class="meta">${zeile(p)}</span></span>
</a></li>`).join('');
return `<section class="seite" data-seite="index" aria-label="Übersicht" style="--bg:${UEBERSICHT.bg};--fg:${UEBERSICHT.fg}"><div class="innen">
${nav(marke, '<span class="nav__punkte"><span>Projekte</span><span>Büro</span><span>Team</span><span>Aktuelles</span><span>Kontakt</span></span>', '<span class="meta nav__info">Bremen</span>')}
<div class="aufmacher">
<h1 class="titel" tabindex="-1">Wohnungsbau und <em>Bauen im Bestand.</em></h1>
<p class="intro">Seit 2009 planen wir Wohnhäuser in Bremen und im Umland: Neubauten für Genossenschaften, Aufstockungen, alte Speicher und Lückenschlüsse in gewachsenen Straßen.</p>
</div>
<div class="abschnitt label"><span>Ausgewählte Projekte</span><span>04 / 31</span></div>
<ul class="raster">${karten}</ul>
<footer class="fuss meta">
<p><b style="font-weight:500;color:var(--fg)">Lindgren Weiss Architekten</b><br>Partnerschaft mbB</p>
<p>Am Wall 118<br>28195 Bremen</p>
<p>Mitglied der Architektenkammer Bremen</p>
<p>Wir suchen ab März 2027<br>eine Projektleitung LPH 5–8</p>
</footer>
</div></section>`;
}
function seiteProjekt(id) {
const i = PROJEKTE.findIndex(p => p.id === id), p = PROJEKTE[i], n = PROJEKTE[(i + 1) % PROJEKTE.length];
return `<section class="seite" data-seite="${p.id}" aria-label="${p.name}" style="--bg:${p.bg};--fg:${p.fg}"><div class="innen">
${nav('<a class="zurueck" href="#index" data-ziel="index"><span aria-hidden="true">←</span> Alle Projekte</a>', marke, `<span class="meta">0${i + 1} / 04</span>`)}
<figure class="held">${ansicht(p.id, 'Ansicht ' + p.name)}</figure>
<div class="held__unter"><span class="meta">${p.name} • ${p.ort} • ${p.jahr}</span><span class="meta">Ansicht Straßenseite, M 1:200</span></div>
<div class="kopf">
<div><p class="label">Projekt 0${i + 1} · ${p.art}</p><h1 class="titel" tabindex="-1">${p.name}</h1><p class="unter">${p.unter}</p></div>
<dl class="fakten meta">${p.fakten.map(([a, b]) => `<div><dt>${a}</dt><dd>${b}</dd></div>`).join('')}</dl>
</div>
<div class="text">${p.text.map(t => `<p>${t}</p>`).join('')}</div>
<a class="naechstes" href="#${n.id}" data-ziel="${n.id}">
<span><span class="label">Nächstes Projekt</span><span class="naechstes__titel">${n.name} →</span></span>
<span class="naechstes__bild">${ansicht(n.id).replace(' data-gegen', '')}</span>
</a>
</div></section>`;
}
function baue(ziel) {
const t = document.createElement('template');
t.innerHTML = (ziel === 'index' ? seiteUebersicht() : seiteProjekt(ziel)).trim();
return t.content.firstElementChild;
}
/* ---------- Der Übergang ---------- */
let aktuell = null, laeuft = false;
function uebergang(ziel, punkt, mitFokus) {
if (laeuft) return;
laeuft = true;
const w = innerWidth, h = innerHeight, y = scrollY;
const alt = aktuell, neu = baue(ziel);
// Scrollleiste während des Wechsels halten, damit die Breite nicht springt
if (html.scrollHeight > h && !html.classList.contains('vorschau')) html.style.overflowY = 'scroll';
// Alte Seite einfrieren: Viewport-große Ebene, Inhalt um die Scroll-Position versetzt
if (alt) {
alt.classList.add('fest', 'geht');
alt.firstElementChild.style.transform = `translateY(${-y}px)`;
alt.inert = true;
}
neu.classList.add('fest'); // neue Seite beginnt immer oben (Scroll-Reset)
buehne.append(neu);
const animationen = [];
zeigeWerte();
if (reduziert()) {
animationen.push(neu.animate({ opacity: [0, 1] }, { duration: FADE, easing: 'linear' }));
} else {
const v = VARIANTEN[variante];
let p = punkt || { x: w / 2, y: h / 2 };
p = { x: Math.round(p.x), y: Math.round(p.y) };
p.r = Math.ceil(Math.hypot(Math.max(p.x, w - p.x), Math.max(p.y, h - p.y)));
if (variante === 'kreis') letzterPunkt = p;
animationen.push(neu.animate({ clipPath: v.neu(p) }, { duration: DAUER, easing: KURVE }));
if (alt && v.alt) animationen.push(alt.animate(v.alt(h), { duration: DAUER, easing: KURVE, fill: 'forwards' }));
neu.querySelectorAll('[data-gegen]').forEach(el =>
animationen.push(el.animate({ transform: [`scale(${GEGEN})`, 'scale(1)'] }, { duration: GEGEN_DAUER, easing: KURVE })));
}
// Aufräumen, sobald die Maske offen ist
const fertig = () => {
if (alt) alt.remove();
neu.classList.remove('fest');
html.style.overflowY = '';
scrollTo(0, 0);
aktuell = neu;
laeuft = false;
if (mitFokus) neu.querySelector('h1').focus({ preventScroll: true });
};
const maske = animationen[0];
maske.finished.then(fertig, fertig);
}
// Klicks auf Projekte, Zurück und Nächstes Projekt
buehne.addEventListener('click', e => {
const a = e.target.closest('[data-ziel]');
if (!a || !buehne.contains(a)) return;
e.preventDefault();
if (a.dataset.ziel === aktuell.dataset.seite) return;
stoppeVorfuehrung();
let punkt;
if (e.detail > 0) punkt = { x: e.clientX, y: e.clientY }; // Maus oder Finger
else { const r = a.getBoundingClientRect(); punkt = { x: r.left + r.width / 2, y: r.top + r.height / 2 }; } // Tastatur
uebergang(a.dataset.ziel, punkt, true);
});
document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { stoppeVorfuehrung(); setzeVariante(b.dataset.variante); }));
// Erster Aufbau: Übersicht deckt sich über Schwarz auf
function start(mitAuftritt) {
buehne.textContent = ''; aktuell = null; laeuft = false; scrollTo(0, 0);
if (mitAuftritt) { uebergang('index', null, false); }
else { aktuell = baue('index'); buehne.append(aktuell); }
}
/* ---------- Vorführung: gedachter Zeiger klickt sich durch ---------- */
const REIHE = ['polygon', 'inset', 'kreis'];
let runde = 0, uhr = null, lief = false, schritt = 0, projekt = 0, zx = 0, zy = 0;
const warte = (ms, t) => new Promise(r => { uhr = setTimeout(() => r(t === runde), ms); });
function zeigerZu(x, y, t) {
const von = `translate(${zx}px, ${zy}px)`, nach = `translate(${x}px, ${y}px)`;
zx = x; zy = y;
zeiger.animate({ transform: [von, nach] }, { duration: reduziert() ? 1 : 750, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' });
return warte(reduziert() ? 200 : 820, t);
}
function klickPuls() {
zeiger.animate({ scale: [1, 0.7, 1] }, { duration: 260, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
}
async function spiel(t) {
if (!(await warte(900, t))) return;
while (t === runde) {
const aufUebersicht = aktuell && aktuell.dataset.seite === 'index';
const ziel = aufUebersicht ? PROJEKTE[projekt].id : 'index';
const el = aktuell.querySelector(`[data-ziel="${ziel}"]`);
const r = el.getBoundingClientRect();
const x = Math.round(r.left + r.width * (aufUebersicht ? 0.5 : 0.35)), y = Math.round(r.top + r.height * (aufUebersicht ? 0.4 : 0.5));
if (!(await zeigerZu(x, y, t))) return;
klickPuls();
setzeVariante(REIHE[schritt % REIHE.length]);
if (!(await warte(140, t))) return;
uebergang(ziel, { x, y }, false);
if (!aufUebersicht) projekt = (projekt + 1) % PROJEKTE.length;
schritt++;
if (!(await warte(DAUER + (aufUebersicht ? 1700 : 1100), t))) return;
}
}
let nachStart = null;
function stoppeVorfuehrung() { runde++; clearTimeout(uhr); clearTimeout(nachStart); lief = false; zeiger.classList.remove('an'); }
function neustart() {
stoppeVorfuehrung();
start(false);
nachStart = setTimeout(() => { if (!laeuft) uebergang(PROJEKTE[0].id, { x: innerWidth / 2, y: innerHeight / 2 }, false); }, 400);
}
function vorfuehren() {
stoppeVorfuehrung();
start(false);
schritt = 0; projekt = 0; lief = true;
zx = Math.round(innerWidth * 0.62); zy = Math.round(innerHeight * 0.3);
zeiger.animate({ transform: [`translate(${zx}px, ${zy}px)`] }, { duration: 1, fill: 'forwards' });
zeiger.classList.add('an');
spiel(runde);
}
function pause() { const war = lief; stoppeVorfuehrung(); lief = war; }
function weiter() { if (lief) { const t = ++runde; zeiger.classList.add('an'); spiel(t); } }
const q = new URLSearchParams(location.search);
setzeVariante('polygon');
start(!reduziert() && !q.has('reduziert') && !q.has('vorschau'));
addEventListener('load', zeigeWerte);
})();
</script>
</body>
</html>