{"v":41,"css":"/* ==========================================================================\n KARLS LIVING \u2014 Ultra Edition\n Farben & Schriften aus dem bestehenden Webflow-Auftritt \u00fcbernommen.\n ========================================================================== */\n\n@font-face {\n font-family: \"Daily Spark Sans\";\n src: url(\"https://cdn.prod.website-files.com/6a97e2884291c0c32e3b2095/6aa917d194a1147298cdb15a_Daily%20Spark%20Sans.otf\") format(\"opentype\");\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}\n\n:root {\n --navy: #003049;\n --navy-deep: #00243a;\n --cream: #FAF5ED;\n --cream-2: #F5EDD6;\n --red: #C1111F;\n --blue: #669BBC;\n --teal: #2E6F8E;\n --plan: #C9C9C9;\n --map: #F6F8EB;\n\n --f-display: \"Daily Spark Sans\", \"Arial Black\", Impact, sans-serif;\n --f-body: \"Inter Tight\", \"Inter\", system-ui, -apple-system, \"Segoe UI\", sans-serif;\n\n --gutter: clamp(16px, 3.2vw, 56px);\n --radius: clamp(20px, 2.4vw, 40px);\n --nav-h: 84px;\n --ease-out: cubic-bezier(.16, 1, .3, 1);\n --ease-io: cubic-bezier(.76, 0, .24, 1);\n}\n/* Webflow Starter: Das Pflicht-Badge \u201eMade in Webflow\u201c (160 \u00d7 28 px, 12 px vom Rand) liegt fest unten rechts \u00fcber allem.\n Nur wenn es da ist, r\u00fccken Hero-Zeile, Kartenhinweis, Footer-Leiste und die Projekt-Pille aus seinem Bereich\n (Vercel und ein bezahlter Webflow-Tarif ohne Badge bleiben unver\u00e4ndert). */\nhtml:has(.w-webflow-badge) { --wf-badge: 44px; }\n\n/* ---------- Reset & Basis ---------- */\n*, *::before, *::after { box-sizing: border-box; }\n/* Webflow l\u00e4dt vorher sein Site-CSS, das p, h1-h6, li, label, a, strong, em und img eigene Werte gibt\n (Kreis-Icon an li, graue p mit 2em Zeilenh\u00f6he, Inter 400 an \u00dcberschriften, fette label).\n Hier zur\u00fcck auf Browser-Standard; lokal \u00e4ndert das nichts. Die eigenen Regeln folgen danach. */\nh1, h2, h3, h4, h5, h6, p, li, label, a, strong, em, blockquote, figure {\n margin: revert; padding: revert; min-height: revert; display: revert; align-items: revert;\n font-family: revert; font-size: revert; font-weight: revert; font-style: revert;\n line-height: revert; letter-spacing: revert; text-align: revert; color: revert; background: revert;\n}\nimg { vertical-align: revert; }\n/* Chrome auf dem iPhone packt erkannte Jahreszahlen, Orte, Zeitangaben in mit gepunkteter Linie.\n Abgeschaltet per im Kopf; falls eine Chrome-Fassung das nicht\n beachtet, wenigstens ohne Linie */\nchrome_annotation { border-bottom-style: none !important; }\n/* scrollbar-gutter: Platz f\u00fcr die Windows-Scrollleiste bleibt frei; sonst sprang die Seite um 15 px, wenn Men\u00fc,\n Lightbox oder Preloader das Scrollen sperren (Leiste weg) und wieder freigeben */\nhtml { scrollbar-gutter: stable; -webkit-tap-highlight-color: transparent; -webkit-text-size-adjust: 100%; overflow-anchor: none; background: var(--navy); } /* kein Scroll-Anchoring: verhindert Spr\u00fcnge durch Pin-Spacer; Navy auch, solange body noch verborgen ist */\nhtml.lenis, html.lenis body { height: auto; }\n.lenis.lenis-smooth { scroll-behavior: auto !important; }\n.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }\n.lenis.lenis-stopped { overflow: hidden; }\nhtml.is-locked { overflow: hidden; }\n\nbody {\n margin: 0;\n background: var(--navy);\n color: var(--navy);\n font-family: var(--f-body);\n font-size: clamp(15px, 1.05vw, 18px);\n line-height: 1.55;\n overflow-x: clip;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n text-rendering: optimizeLegibility;\n}\n\nimg, svg { display: block; }\nimg { max-width: none; user-select: none; -webkit-user-drag: none; }\na { color: inherit; text-decoration: none; }\nbutton { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }\nh1, h2, h3, h4, p, ul, ol, dl, dd, figure, address { margin: 0; padding: 0; }\nul, ol { list-style: none; }\n::selection { background: var(--red); color: var(--cream); }\n:focus-visible { outline: 2px solid var(--red); outline-offset: 4px; border-radius: 6px; }\n/* Sprungziele (main.js: focusIn, \u00dcberspringen) bekommen den Fokus nur, damit Tab dort weitergeht: kein Rahmen um den ganzen Abschnitt */\nmain [tabindex=\"-1\"]:focus-visible, main[tabindex=\"-1\"]:focus-visible { outline: none; }\n\n.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }\n.sr-only { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n/* ---------- Bausteine ---------- */\n.kicker {\n display: inline-flex; align-items: center; gap: .7em;\n font-size: 12px; font-weight: 600; letter-spacing: .18em; text-transform: uppercase;\n}\n.kicker svg { width: 1.25em; height: auto; color: var(--red); flex: none; }\n.kicker--light { color: var(--cream); }\n.kontakt .kicker svg { color: var(--cream); }\n\n.tag {\n display: inline-flex; align-items: center;\n padding: 9px 16px; border-radius: 99px;\n font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase;\n color: var(--cream); background: rgba(250, 245, 237, .14);\n border: 1px solid rgba(250, 245, 237, .35);\n -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);\n}\n.tag--solid { background: var(--red); border-color: var(--red); }\n\n/* Roll-Hover (Buchstaben rollen einzeln) */\n.roll { position: relative; display: inline-flex; overflow: hidden; vertical-align: top; line-height: 1.25; }\n.roll__a, .roll__b { display: inline-flex; white-space: pre; }\n.roll__b { position: absolute; left: 0; top: 0; }\n.roll .c { display: inline-block; white-space: pre; transition: transform .55s var(--ease-io); transition-delay: calc(var(--i) * 13ms); }\n.roll__b .c { transform: translateY(110%); }\n/* Hover-Effekte nur mit echter Maus: iOS h\u00e4lt :hover nach dem Antippen fest (Knopf blieb gef\u00fcllt, Men\u00fcpunkt rot,\n Namens-Pille an der Punkte-Leiste stand bei jedem Wiedererscheinen da) */\n@media (hover: hover) {\n :is(a, button):hover .roll__a .c { transform: translateY(-110%); }\n :is(a, button):hover .roll__b .c { transform: translateY(0); }\n}\n\n/* Button */\n.btn {\n position: relative; isolation: isolate; overflow: hidden;\n display: inline-flex; align-items: center; gap: 14px;\n padding: 19px 28px; border-radius: 99px; border: 1.5px solid currentColor;\n font-size: 13px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase;\n transition: color .5s var(--ease-io);\n}\n.btn__fill { position: absolute; inset: -2px; z-index: -1; border-radius: inherit; background: var(--cream); transform: translateY(102%); transition: transform .6s var(--ease-io); }\n.btn svg { width: 20px; height: auto; transform: rotate(-90deg); transition: transform .5s var(--ease-io); }\n.btn--light { color: var(--cream); }\n@media (hover: hover) {\n .btn:hover .btn__fill { transform: translateY(0); }\n .btn:hover { color: var(--red); }\n .btn:hover svg { transform: rotate(-90deg) translateY(4px); }\n .btn--light:hover { color: var(--navy); }\n}\n\n/* Odometer-Zahlen */\n.odo { display: inline-flex; height: var(--oh, 1.1em); line-height: 1; overflow: hidden; vertical-align: top; }\n.odo__col { display: block; height: var(--oh, 1.1em); overflow: hidden; }\n.odo__strip { display: flex; flex-direction: column; will-change: transform; }\n.odo__strip > span { display: block; height: var(--oh, 1.1em); line-height: var(--oh, 1.1em); text-align: center; }\n.odo__static { line-height: var(--oh, 1.1em); }\n\n/* ==========================================================================\n PRELOADER\n ========================================================================== */\n.loader { position: fixed; inset: 0; z-index: 90; pointer-events: none; color: var(--cream); display: none; }\nhtml.is-loading .loader { display: block; }\n/* Bis main.js den Preloader startet (.loader-on), nur die Navy-Fl\u00e4che zeigen. Sonst sind Hero und Loader-Teile\n gleichzeitig zu sehen, solange Schriften und Scripts noch laden (fiel auf Webflow auf). */\nhtml.is-loading:not(.loader-on) body { visibility: hidden; }\n/* Seite ohne Preloader freigegeben (Notausgang im Kopf): kein Flipbook-Fenster \u00fcber dem Hero */\nhtml:not(.is-loading) .hero__flip { display: none; }\n.loader__corner { position: absolute; top: calc(var(--gutter) * .8); }\n.loader__corner--tl { left: var(--gutter); }\n.loader__corner--tr { right: var(--gutter); display: grid; gap: 4px; text-align: right; font-size: 11px; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; overflow: hidden; }\n.loader__corner--tr span { display: block; }\n.loader__logo { width: clamp(34px, 3.4vw, 50px); height: auto; overflow: visible; }\n.loader__logo path { fill: rgba(250, 245, 237, 0); stroke: var(--cream); stroke-width: 1.2; }\n.loader__count { position: absolute; left: var(--gutter); right: var(--gutter); bottom: calc(var(--gutter) + 18px); display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }\n.loader__num { font-family: var(--f-display); font-size: clamp(96px, 18vw, 300px); line-height: 1.1; overflow: hidden; }\n.loader__odo { display: inline-flex; }\n.loader__label { display: grid; overflow: hidden; text-align: right; font-size: 12px; font-weight: 600; letter-spacing: .18em; text-transform: uppercase; padding-bottom: .6em; }\n.loader__label span { display: block; }\n.loader__bar { position: absolute; left: var(--gutter); right: var(--gutter); bottom: var(--gutter); height: 2px; background: rgba(250, 245, 237, .16); overflow: hidden; }\n.loader__bar i { position: absolute; inset: 0; background: var(--cream); transform-origin: 0 50%; transform: scaleX(0); }\n.loader__skip {\n position: absolute; left: 50%; top: calc(var(--gutter) * .8); transform: translateX(-50%); pointer-events: auto;\n display: inline-flex; align-items: center; gap: 10px; padding: 11px 18px; border-radius: 99px;\n border: 1.5px solid rgba(250, 245, 237, .45); color: var(--cream);\n font-size: 11px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase;\n opacity: 0; animation: skipIn .8s var(--ease-out) 1.1s forwards; transition: background .3s ease, color .3s ease, border-color .3s ease;\n}\n.loader__skip svg { width: 14px; height: auto; transform: rotate(-90deg); }\n.loader__skip:hover, .loader__skip:focus-visible { background: var(--cream); border-color: var(--cream); color: var(--navy); }\n@keyframes skipIn { from { opacity: 0; translate: 0 -8px; } to { opacity: 1; translate: 0 0; } }\n\n/* \u00dcberspringen-Knopf f\u00fcr lange Scroll-Abschnitte */\n.skip {\n position: fixed; left: 50%; bottom: calc(var(--gutter) * .5 + env(safe-area-inset-bottom, 0px)); z-index: 74;\n display: inline-flex; align-items: center; gap: 10px; padding: 7px 16px 7px 7px; border-radius: 99px;\n background: rgba(0, 48, 73, .88); color: var(--cream); box-shadow: 0 14px 34px rgba(0, 20, 35, .28);\n -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);\n font-size: 11px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; white-space: nowrap;\n transform: translate(-50%, 16px); opacity: 0; visibility: hidden; pointer-events: none;\n transition: transform .55s var(--ease-out), opacity .35s ease, visibility 0s linear .55s, background .3s ease, color .3s ease, left .6s var(--ease-io), bottom .6s var(--ease-io);\n}\n.skip.is-on { transform: translate(-50%, 0); opacity: 1; visibility: visible; pointer-events: auto; transition-delay: 0s; }\n.skip[data-theme=\"light\"] { background: rgba(250, 245, 237, .9); color: var(--navy); }\n.skip:focus-visible { background: var(--red); color: var(--cream); }\n@media (hover: hover) { .skip:hover { background: var(--red); color: var(--cream); } }\n.skip__ring { width: 26px; height: 26px; flex: none; transform: rotate(-90deg); }\n.skip__ring circle { fill: none; stroke: currentColor; stroke-width: 2.5; opacity: .22; }\n.skip__ring .skip__prog { opacity: 1; stroke: var(--red); stroke-dasharray: 100; stroke-dashoffset: 100; stroke-linecap: round; }\n.skip:focus-visible .skip__prog { stroke: var(--cream); }\n@media (hover: hover) { .skip:hover .skip__prog { stroke: var(--cream); } }\n.skip__txt b { font-weight: 700; }\n.skip__arrow { width: 13px; height: auto; flex: none; animation: skipBob 1.6s var(--ease-io) infinite; }\n@keyframes skipBob { 0%, 100% { translate: 0 -2px; } 50% { translate: 0 2px; } }\n.skip[data-at=\"raum\"] { bottom: calc(26px + 16px + env(safe-area-inset-bottom, 0px)); }\n@media (min-width: 761px) {\n .skip[data-at=\"projekt\"] { left: calc(var(--gutter) + (100% - 2 * var(--gutter)) * .8); } /* unter der Infospalte statt \u00fcber dem Bild */\n /* rechts versetzt: unter ~1440 px Breite l\u00e4ge sie sonst auf dem Webflow-Badge */\n html:has(.w-webflow-badge) .skip[data-at=\"projekt\"] { bottom: calc(var(--gutter) * .5 + var(--wf-badge) + env(safe-area-inset-bottom, 0px)); }\n}\n@media (max-width: 760px) {\n .skip__txt b { display: none; }\n .skip__txt { text-transform: uppercase; }\n}\nhtml.is-loading .skip, html.menu-open .skip { opacity: 0 !important; visibility: hidden !important; }\n\n/* ==========================================================================\n NAV / MEN\u00dc / HUD / CURSOR\n ========================================================================== */\n.nav { position: fixed; top: 0; left: 0; right: 0; z-index: 80; pointer-events: none; color: var(--navy); transition: color .45s ease; }\n.nav[data-theme=\"light\"] { color: var(--cream); }\nhtml.menu-open .nav { color: var(--cream); }\nhtml.is-loading .nav { opacity: 0; }\nhtml.is-loading .nav * { pointer-events: none !important; }\n.nav__inner { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px var(--gutter); transition: transform .7s var(--ease-io); }\n.nav__inner.is-hidden { transform: translateY(-130%); }\n.nav a, .nav button { pointer-events: auto; }\n.nav__logo { display: flex; align-items: center; gap: 12px; }\n.nav__logo svg { width: 32px; height: auto; }\n.nav__brand { font-family: var(--f-display); font-size: 22px; line-height: 1; text-transform: uppercase; letter-spacing: .02em; }\n.nav__links { display: flex; gap: clamp(18px, 2.2vw, 38px); font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; }\n.nav__right { display: flex; align-items: center; gap: 10px; }\n.nav__sound { position: relative; display: flex; align-items: center; gap: 10px; padding: 12px 16px; border-radius: 99px; border: 1.5px solid currentColor; font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }\n.eq { display: flex; align-items: flex-end; gap: 2px; height: 12px; }\n.eq i { width: 2px; height: 3px; border-radius: 1px; background: currentColor; transition: height .3s ease; }\n.nav__sound.is-on .eq i { animation: eq .8s ease-in-out infinite alternate; }\n.nav__sound.is-on .eq i:nth-child(2) { animation-delay: -.35s; }\n.nav__sound.is-on .eq i:nth-child(3) { animation-delay: -.6s; }\n.nav__sound.is-on .eq i:nth-child(4) { animation-delay: -.15s; }\n@keyframes eq { 0% { height: 3px; } 100% { height: 12px; } }\n.nav__sound.is-hint::after { content: \"\"; position: absolute; inset: -6px; border-radius: 99px; border: 1.5px solid var(--red); animation: hint 1.6s ease-out 3; pointer-events: none; }\n@keyframes hint { 0% { opacity: 1; transform: scale(.9); } 100% { opacity: 0; transform: scale(1.25); } }\n.nav__btn { display: flex; align-items: center; gap: 14px; padding: 12px 18px 12px 20px; border-radius: 99px; border: 1.5px solid currentColor; font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }\n.nav__burger { position: relative; width: 18px; height: 9px; }\n.nav__burger i { position: absolute; left: 0; right: 0; height: 2px; border-radius: 2px; background: currentColor; transition: transform .6s var(--ease-io); }\n.nav__burger i:first-child { top: 0; }\n.nav__burger i:last-child { bottom: 0; }\nhtml.menu-open .nav__burger i:first-child { transform: translateY(3.5px) rotate(45deg); }\nhtml.menu-open .nav__burger i:last-child { transform: translateY(-3.5px) rotate(-45deg); }\n\n.menu { position: fixed; inset: 0; z-index: 78; visibility: hidden; }\n.menu__panel {\n position: absolute; inset: 0; overflow: hidden;\n background: var(--navy); color: var(--cream);\n clip-path: circle(0px at 100% 0%);\n display: grid; grid-template-columns: 1.5fr 1fr; align-items: end; gap: 40px;\n padding: calc(var(--nav-h) + 4vh) var(--gutter) var(--gutter);\n}\n.menu__nav { display: grid; gap: .1em; position: relative; z-index: 2; }\n.menu__link { display: flex; align-items: flex-start; gap: 18px; width: fit-content; font-family: var(--f-display); font-size: clamp(34px, 6vw, 112px); line-height: .92; text-transform: uppercase; white-space: nowrap; overflow: hidden; padding-top: .04em; }\n.menu__idx { font-family: var(--f-body); font-size: 12px; font-weight: 700; letter-spacing: .12em; color: var(--blue); margin-top: .9em; min-width: 2ch; }\n.menu__txt { display: inline-block; transition: color .35s ease, transform .6s var(--ease-io); }\n@media (hover: hover) { .menu__link:hover .menu__txt { color: var(--red); transform: translateX(18px) skewX(-6deg); } }\n.menu__aside { position: relative; z-index: 2; display: grid; gap: 22px; font-size: 15px; color: rgba(250, 245, 237, .8); padding-bottom: 1em; }\n.menu__aside a:hover { color: var(--cream); text-decoration: underline; text-underline-offset: 4px; }\n.menu__shape { position: absolute; z-index: 1; height: auto; }\n.menu__shape--1 { width: clamp(90px, 12vw, 220px); right: 8%; top: 16%; color: var(--blue); }\n.menu__shape--2 { width: clamp(50px, 5vw, 90px); right: 30%; top: 8%; color: var(--red); }\n.menu__shape--3 { width: clamp(60px, 7vw, 130px); right: 4%; top: 48%; color: var(--cream-2); }\n.menu__shape--4 { width: clamp(80px, 10vw, 180px); right: 22%; top: 34%; color: rgba(102, 155, 188, .45); }\n\n/* Abschnitts-Leiste rechts (ersetzt das fr\u00fchere HUD): ein Punkt je Abschnitt, Klick springt hin */\n.secnav { position: fixed; right: clamp(6px, .6vw, 12px); top: 50%; z-index: 72; transform: translateY(-50%); transition: opacity .45s ease, transform .6s var(--ease-out); }\nhtml.menu-open .secnav { opacity: 0 !important; pointer-events: none !important; } /* !important: schl\u00e4gt die Handy-Regeln (html.lite \u2026) */\n.secnav__list { display: flex; flex-direction: column; align-items: center; padding: 10px 0; border-radius: 99px; background: rgba(0, 48, 73, .5); border: 1px solid rgba(250, 245, 237, .16); -webkit-backdrop-filter: blur(12px) saturate(1.3); backdrop-filter: blur(12px) saturate(1.3); box-shadow: 0 10px 30px rgba(0, 20, 35, .16); }\nhtml.is-loading .secnav__list { opacity: 0; }\n.secnav__btn { position: relative; display: grid; place-items: center; width: 34px; height: 28px; }\n.secnav__btn::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: var(--cream); opacity: .62; transition: width .45s var(--ease-out), height .45s var(--ease-out), opacity .3s ease, background-color .3s ease, box-shadow .3s ease; }\n.secnav__btn::after { content: \"\"; position: absolute; left: 50%; top: 50%; width: 8px; height: 8px; margin: -4px 0 0 -4px; border-radius: 50%; background: var(--red); transform: scale(0); transition: transform .45s var(--ease-out); }\n.secnav__btn.is-active::before { width: 20px; height: 20px; opacity: 1; }\n.secnav__btn.is-active::after { transform: scale(1); }\n.secnav__btn:focus-visible::before, .secnav__btn.is-pick::before { width: 24px; height: 24px; opacity: 1; background: rgba(0, 36, 58, .55); box-shadow: 0 0 0 1.5px var(--red); }\n.secnav__btn:focus-visible::after, .secnav__btn.is-pick::after { transform: scale(.75); }\n.secnav__btn:focus-visible { outline: none; }\n.secnav__label { position: absolute; right: calc(100% + 10px); top: 50%; padding: 7px 13px 6px; border-radius: 99px; background: var(--cream); color: var(--navy); font-size: 11px; font-weight: 700; line-height: 1.2; letter-spacing: .14em; text-transform: uppercase; white-space: nowrap; pointer-events: none; opacity: 0; transform: translate(8px, -50%); transition: opacity .25s ease, transform .4s var(--ease-out); box-shadow: 0 6px 18px rgba(0, 20, 35, .18); }\n.secnav__btn:focus-visible .secnav__label, .secnav__btn.is-pick .secnav__label { opacity: 1; transform: translate(0, -50%); }\n/* Touch nutzt .is-pick (Finger abfahren); :hover nur mit Maus, sonst blieb am iPhone die Namens-Pille des zuletzt getippten Punkts stehen */\n@media (hover: hover) {\n .secnav__btn:hover::before { width: 24px; height: 24px; opacity: 1; background: rgba(0, 36, 58, .55); box-shadow: 0 0 0 1.5px var(--red); }\n .secnav__btn:hover::after { transform: scale(.75); }\n .secnav__btn:hover .secnav__label { opacity: 1; transform: translate(0, -50%); }\n}\n/* Tablets und schmale Desktops: Der Seitenrand (--gutter) ist hier schmaler als die Leiste, sie lag auf Text\n (Kiez-Einleitung, \u201e\u03a3\u201c-Pille im Projekt). Deshalb die schlanke Handy-Gr\u00f6\u00dfe, weiter dauerhaft sichtbar. */\n@media (min-width: 761px) and (max-width: 1180px) {\n .secnav { right: 4px; }\n .secnav__list { padding: 6px 0; }\n .secnav__btn { width: 22px; height: 24px; }\n .secnav__btn::before { width: 4px; height: 4px; }\n .secnav__btn::after { width: 6px; height: 6px; margin: -3px 0 0 -3px; }\n .secnav__btn.is-active::before { width: 13px; height: 13px; }\n .secnav__btn:focus-visible::before, .secnav__btn.is-pick::before { width: 19px; height: 19px; }\n @media (hover: hover) { .secnav__btn:hover::before { width: 19px; height: 19px; } }\n}\n/* Handy: schmal, erscheint beim Scrollen; mit dem Finger abfahren, loslassen springt. An html.lite statt an der\n Breite: Das Einblenden (main.js) h\u00e4ngt an LITE; lief die Breite nach dem Laden \u00fcber 760 px, blieb sie sonst unsichtbar */\nhtml.lite .secnav { right: 3px; opacity: 0; pointer-events: none; transform: translate(8px, -50%); touch-action: none; }\nhtml.lite .secnav.is-shown, html.lite .secnav.is-touch { opacity: 1; transform: translate(0, -50%); }\n/* antippbar erst, wenn das Bild steht (is-idle, main.js): solange es rollt, gehen Daumen-Wischer am rechten Rand\n durch die Leiste hindurch, statt zu einem anderen Abschnitt zu springen */\nhtml.lite .secnav.is-shown.is-idle, html.lite .secnav.is-touch { pointer-events: auto; }\nhtml.lite .secnav::before { content: \"\"; position: absolute; inset: -12px -3px -12px -7px; }\nhtml.lite .secnav__list { padding: 6px 0; background: rgba(0, 48, 73, .42); }\nhtml.lite .secnav.is-touch .secnav__list { background: rgba(0, 48, 73, .72); }\nhtml.lite .secnav__btn { width: 22px; height: 21px; }\nhtml.lite .secnav__btn::before { width: 4px; height: 4px; }\nhtml.lite .secnav__btn::after { width: 6px; height: 6px; margin: -3px 0 0 -3px; }\nhtml.lite .secnav__btn.is-active::before { width: 13px; height: 13px; }\nhtml.lite .secnav__btn.is-pick::before { width: 19px; height: 19px; }\nhtml.lite .secnav__label { right: calc(100% + 14px); padding: 8px 14px 7px; font-size: 12px; }\n\n.cursor { position: fixed; left: 0; top: 0; z-index: 100; pointer-events: none; opacity: 0; transition: opacity .3s ease; --c: var(--red); --t: var(--cream); }\n.cursor.is-on { opacity: 1; }\n.cursor[data-accent=\"cream\"] { --c: var(--cream); --t: var(--red); }\n.cursor__dot { position: fixed; left: 0; top: 0; width: 6px; height: 6px; margin: -3px 0 0 -3px; border-radius: 50%; background: var(--c); }\n.cursor__ring { position: fixed; left: 0; top: 0; }\n.cursor__circle {\n position: absolute; left: 0; top: 0; width: 40px; height: 40px; transform: translate(-50%, -50%);\n border: 1.5px solid var(--c); border-radius: 50%;\n display: grid; place-items: center;\n transition: width .5s var(--ease-out), height .5s var(--ease-out), background-color .3s ease, border-color .3s ease, opacity .3s ease;\n}\n.cursor__label { font-size: 11px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; color: var(--t); opacity: 0; transition: opacity .25s ease; white-space: nowrap; }\n.cursor.is-link .cursor__circle { width: 66px; height: 66px; background: color-mix(in srgb, var(--c) 14%, transparent); }\n.cursor.is-label .cursor__circle { width: 108px; height: 108px; background: var(--c); }\n.cursor.is-label .cursor__label { opacity: 1; }\n.cursor.is-label .cursor__dot { opacity: 0; }\n.cursor.is-down .cursor__circle { width: 30px; height: 30px; }\n.cursor.is-text .cursor__circle { opacity: 0; }\nhtml.has-cursor, html.has-cursor * { cursor: none !important; }\nhtml.has-cursor input, html.has-cursor textarea { cursor: text !important; }\n\n.grain {\n position: fixed; inset: -50%; width: 200%; height: 200%; z-index: 95; pointer-events: none; opacity: .065;\n background-image: url(\"data:image/svg+xml;utf8,\");\n animation: grain .9s steps(5) infinite;\n}\n@keyframes grain {\n 0% { transform: translate(0, 0); } 20% { transform: translate(-4%, 3%); } 40% { transform: translate(3%, -5%); }\n 60% { transform: translate(-6%, -2%); } 80% { transform: translate(5%, 4%); } 100% { transform: translate(0, 0); }\n}\n\n.curtain { position: fixed; inset: 0; z-index: 92; display: none; place-content: center; justify-items: center; gap: 22px; background: var(--red); color: var(--cream); clip-path: inset(100% 0 0 0); }\n.curtain__logo { width: clamp(56px, 6vw, 96px); height: auto; }\n.curtain__label { overflow: hidden; padding-top: .3em; margin-top: -.3em; font-family: var(--f-display); font-size: clamp(48px, 9vw, 170px); line-height: 1; text-transform: uppercase; }\n.curtain__label span { display: block; }\n\n.burst { position: fixed; left: 0; top: 0; z-index: 96; pointer-events: none; }\n.burst svg { width: 100%; height: auto; }\n\n/* ==========================================================================\n HERO\n ========================================================================== */\n.hero { position: relative; z-index: 1; height: 100vh; height: 100svh; overflow: hidden; background: #7cc4ee; --hb: max(40.2vw, 60svh); }\n.hero__stage { position: absolute; inset: 0; transform-origin: 50% 50%; }\n.hero__layer { position: absolute; inset: 0; will-change: transform; }\n.hero__m { position: absolute; inset: 0; }\n.hero__sky img { position: absolute; left: -4%; top: -6%; width: 108%; height: max(62%, calc(100% - var(--hb) * .92 + 24%)); object-fit: cover; object-position: 50% 30%; animation: skyDrift 46s ease-in-out infinite alternate; }\n@keyframes skyDrift { from { transform: translateX(-1.4%); } to { transform: translateX(1.4%); } }\n\n.hero__build img { position: absolute; bottom: -2px; left: 50%; height: var(--hb); width: auto; transform: translateX(-50%); }\n\n/* Zeitraffer (hero-sky.js): Himmel und H\u00e4user als WebGL, Wetter als 2D-Canvas */\n.hero__sky canvas { position: absolute; left: -4%; top: -6%; width: 108%; height: 112%; }\n.hero__build .build__gl { position: absolute; bottom: -2px; left: 50%; height: var(--hb); width: calc(var(--hb) * 2.5911); transform: translateX(-50%); }\n.hero.has-sky .hero__sky img, .hero.has-sky .hero__build img { opacity: 0; }\n/* WebGL verloren (hero-sky.js): nur noch die Bilder, keine eingefrorenen Canvas-Reste dar\u00fcber */\n.hero.sky-lost :is(.sky__gl, .sky__birds, .build__gl, .wx__cv) { visibility: hidden; }\n.hero__wx { pointer-events: none; }\n.hero__wx canvas { position: absolute; inset: 0; width: 100%; height: 100%; }\n.hero.has-sky .hero__petals { filter: brightness(calc(1 - var(--night, 0) * .4)); }\n.hero__dim { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(to top, rgba(0, 36, 58, .78) 0%, rgba(0, 36, 58, .1) 38%, transparent 55%); }\n/* Ganz leichte Vignette: R\u00e4nder und Ecken dunkeln ab, die H\u00e4user in der Mitte treten plastischer hervor */\n.hero__dim::before { content: \"\"; position: absolute; inset: 0; background: radial-gradient(ellipse 78% 72% at 50% 56%, transparent 52%, rgba(0, 0, 0, .1) 76%, rgba(0, 0, 0, .26) 100%); }\n.hero__dim::after { content: \"\"; position: absolute; inset: 0; background: var(--navy); opacity: var(--dim, 0); }\n.petal { position: absolute; left: var(--x); top: var(--y); width: var(--s); height: auto; overflow: visible; }\n\n.hero__flip { position: absolute; left: 50%; top: 50%; width: 300px; height: 390px; z-index: 12; overflow: hidden; border-radius: 20px; background: var(--navy); transform: translate(-50%, -50%); }\n.hero__flip img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; }\n.hero__flip img:first-child { opacity: 1; }\n\n.hero__ui {\n position: absolute; left: 0; right: 0; bottom: 0; z-index: 10;\n display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 24px;\n padding: 0 var(--gutter) calc(var(--gutter) * .9 + var(--wf-badge, 0px));\n color: var(--cream);\n}\n.hero__claim { font-family: var(--f-display); font-size: clamp(26px, 2.9vw, 52px); line-height: .92; text-transform: uppercase; }\n.hero__claim .line { display: block; overflow: hidden; padding-top: .3em; margin-top: -.3em; }\n.hero__claim .line > span { display: block; }\n.hero__badge { position: relative; display: grid; place-items: center; width: clamp(96px, 8.4vw, 136px); aspect-ratio: 1; justify-self: center; }\n.hero__badge-ring { position: absolute; inset: 0; width: 100%; height: 100%; animation: spin 16s linear infinite; }\n.hero__badge-ring text { font-family: var(--f-body); font-size: 15px; font-weight: 700; letter-spacing: .06em; fill: var(--cream); text-transform: uppercase; }\n.hero__badge::before { content: \"\"; position: absolute; inset: 22%; border-radius: 50%; background: var(--red); transition: transform .5s var(--ease-out); }\n@media (hover: hover) { .hero__badge:hover::before { transform: scale(1.15); } }\n.hero__badge-arrow { position: relative; width: 30%; height: auto; color: var(--cream); animation: bob 1.8s var(--ease-io) infinite; }\n.hero__meta { justify-self: end; display: grid; gap: 4px; text-align: right; font-size: 12px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; overflow: hidden; }\n.hero__meta span { display: block; }\n/* Karlshorst live: echte Uhrzeit und Temperatur, davor ein pulsierender roter Punkt (nur solange der Hero live ist) */\n.hero__meta span.is-live::before { content: \"\"; display: inline-block; width: 7px; height: 7px; margin: 0 .7em .1em 0; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 0 rgba(193, 17, 31, .6); vertical-align: middle; animation: livePulse 1.8s ease-out infinite; }\n@keyframes livePulse { 0% { box-shadow: 0 0 0 0 rgba(193, 17, 31, .6); } 70%, 100% { box-shadow: 0 0 0 7px rgba(193, 17, 31, 0); } }\n@keyframes spin { to { transform: rotate(360deg); } }\n@keyframes bob { 0%, 100% { transform: translateY(-18%); } 50% { transform: translateY(18%); } }\n\n/* ==========================================================================\n INTRO\n ========================================================================== */\n.intro {\n position: relative; z-index: 2; overflow: hidden; overflow: clip; /* hidden: R\u00fcckfall f\u00fcr iOS 15 (kein clip) */\n background: var(--cream); color: var(--navy);\n border-radius: var(--radius) var(--radius) 0 0;\n padding: clamp(96px, 15vh, 190px) var(--gutter) 0;\n box-shadow: 0 -30px 90px rgba(0, 20, 35, .28);\n}\n.intro__title { display: flex; flex-direction: column; font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(50px, 10.6vw, 210px); line-height: .86; margin: 30px 0 clamp(56px, 9vw, 140px); } /* flex: die negativen R\u00e4nder der Zeilen addieren sich (kein Zusammenfallen), der Abstand bleibt wie vorher */\n.intro__title .ln { display: block; overflow: hidden; padding: .32em 0 .2em; margin: -.32em 0 -.2em; } /* unten Platz f\u00fcr den Wurzel-Strich, sonst schnitt die Maske ihn halb ab */\n/* Die \u00c4-Punkte in \u201eschl\u00e4gt\u201c ragen weit \u00fcber die Gro\u00dfbuchstaben: Die letzte Zeile bekommt Luft,\n sonst sto\u00dfen sie an \u201eWurzeln\u201c und dessen Wurzel-Strich */\n.intro__title .ln:last-child { margin-top: .06em; }\n.intro__title .ln__i { display: inline-flex; align-items: center; gap: .2em; }\n.intro__title em { position: relative; font-style: normal; color: var(--red); }\n.intro__root { position: absolute; left: -2%; bottom: -.1em; width: 104%; height: .2em; overflow: visible; }\n.intro__root path { fill: none; stroke: var(--red); stroke-width: 7; stroke-linecap: round; vector-effect: non-scaling-stroke; }\n.pill { position: relative; display: inline-block; flex: none; width: 1.95em; height: .72em; border-radius: 999px; overflow: hidden; transform: translateY(-.03em); }\n.pill img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }\n.pill--b img { object-position: 50% 60%; } /* Innenhof (Rendering 03) statt des Bl\u00e4tter-Fotos: Fassade und Rasen im Streifen */\n.intro__cols { display: grid; grid-template-columns: 1.7fr 1fr; gap: 5vw; align-items: end; }\n.intro__big { font-size: clamp(24px, 3.1vw, 54px); line-height: 1.12; font-weight: 500; letter-spacing: -.02em; }\n.intro__small { font-size: clamp(16px, 1.25vw, 20px); max-width: 30ch; padding-bottom: .4em; border-top: 1.5px solid rgba(0, 48, 73, .2); padding-top: 18px; }\n.garden { position: relative; height: clamp(260px, 50vh, 560px); margin: clamp(40px, 8vh, 100px) calc(var(--gutter) * -1) 0; }\n.plant { position: absolute; bottom: -3px; left: var(--x); height: var(--h); transform-origin: 50% 100%; }\n.plant svg { height: 100%; width: auto; transform-origin: 50% 100%; overflow: visible; }\n\n/* ==========================================================================\n STATS\n ========================================================================== */\n.stats { position: relative; z-index: 2; overflow: hidden; overflow: clip; background: var(--navy); color: var(--cream); padding: clamp(100px, 18vh, 220px) var(--gutter) clamp(90px, 14vh, 170px); }\n.stats__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(38px, 8.6vw, 170px); line-height: .86; margin-top: 26px; }\n.stats__title .char { display: inline-block; }\n.stats__grid { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: clamp(64px, 11vh, 150px); }\n.stat { position: relative; padding: 30px 24px 0 0; }\n.stat__line { position: absolute; top: 0; left: 0; right: 18px; height: 1.5px; background: rgba(250, 245, 237, .35); transform-origin: 0 50%; }\n.stat__num { display: flex; align-items: flex-start; gap: .08em; font-family: var(--f-display); font-size: clamp(64px, 8.2vw, 156px); line-height: 1.1; }\n.stat__pre, .stat__unit { font-family: var(--f-body); font-weight: 700; font-size: .2em; line-height: 1; margin-top: .55em; color: var(--blue); letter-spacing: .02em; }\n/* gr\u00f6\u00dfer auf Wunsch des Kumpels (vorher fest 12px) */\n.stat__label { margin-top: 16px; font-size: clamp(13.5px, 1.15vw, 18px); font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: rgba(250, 245, 237, .78); }\n.stats__text { max-width: 30ch; margin: clamp(70px, 12vh, 150px) 0 0 auto; font-size: clamp(22px, 2.4vw, 42px); line-height: 1.16; font-weight: 500; letter-spacing: -.015em; }\n\n/* ==========================================================================\n BANDS\n ========================================================================== */\n.bands { position: relative; z-index: 2; height: clamp(230px, 36vh, 400px); overflow: hidden; background: linear-gradient(var(--navy) 50%, var(--cream) 50%); }\n.band { position: absolute; left: -8%; right: -8%; top: 50%; padding: clamp(12px, 1.4vw, 22px) 0; overflow: hidden; box-shadow: 0 20px 50px rgba(0, 20, 35, .18); }\n.band--a { background: var(--red); color: var(--cream); transform: translateY(-50%) rotate(-4deg); z-index: 2; }\n.band--b { background: var(--blue); color: var(--navy); transform: translateY(-50%) rotate(3.2deg); }\n.band__track { display: flex; width: max-content; will-change: transform; }\n.band__group { display: flex; align-items: center; gap: clamp(22px, 2.6vw, 44px); padding-right: clamp(22px, 2.6vw, 44px); font-family: var(--f-display); font-size: clamp(32px, 4.6vw, 84px); line-height: 1; text-transform: uppercase; white-space: nowrap; }\n.band__group svg { width: .78em; height: auto; flex: none; }\n\n/* ==========================================================================\n R\u00dcCKZUGSORT\n ========================================================================== */\n.retreat { position: relative; z-index: 2; height: 100vh; height: 100svh; overflow: hidden; background: var(--cream); }\n.retreat__media { position: absolute; inset: 0; overflow: hidden; clip-path: inset(29% 33% 29% 33% round 32px); --shade: 0; }\n.retreat__media img { width: 100%; height: 100%; object-fit: cover; transform: scale(1.45); }\n.retreat__media::after { content: \"\"; position: absolute; inset: 0; z-index: 2; background: linear-gradient(to top, rgba(0, 36, 58, .85) 0%, rgba(0, 36, 58, .25) 45%, rgba(0, 36, 58, 0) 70%); opacity: var(--shade); }\n.retreat__title {\n position: absolute; inset: 0; pointer-events: none;\n display: flex; flex-direction: column; justify-content: space-between; align-items: center;\n padding: clamp(96px, 11vh, 130px) var(--gutter) 9vh; text-align: center;\n font-family: var(--f-display); font-weight: 400; text-transform: uppercase;\n font-size: clamp(42px, 8.4vw, 170px); line-height: .86; color: var(--navy);\n}\n.retreat__title span { display: block; }\n/* h\u00f6chstens 55 % breit: zwischen 761 und ~1000 px (iPad hochkant, Handy quer) lagen die Schlagwort-Pillen rechts auf dem Text */\n.retreat__copy { position: absolute; left: var(--gutter); bottom: calc(var(--gutter) + 8px); max-width: min(600px, 55%); color: var(--cream); font-size: clamp(17px, 1.55vw, 26px); line-height: 1.38; font-weight: 500; }\n.retreat__tags { position: absolute; right: var(--gutter); bottom: calc(var(--gutter) + 8px); display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; max-width: 40%; }\n.retreat__play { width: 11px; height: 13px; background: currentColor; clip-path: polygon(0 0, 100% 50%, 0 100%); }\n\n/* ==========================================================================\n RAUMKONZEPT (horizontal)\n ========================================================================== */\n.raum { position: relative; z-index: 2; height: 100vh; height: 100svh; overflow: hidden; background: var(--cream); color: var(--navy); }\n.raum__track { display: flex; align-items: center; gap: clamp(48px, 7vw, 150px); width: max-content; height: 100%; padding: 0 12vw 0 var(--gutter); will-change: transform; }\n.rp { position: relative; flex: none; }\n.rp--intro { width: min(88vw, 980px); }\n.raum__big { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(70px, 14vw, 270px); line-height: .82; margin: 26px 0 34px; }\n.raum__big span { display: block; }\n.raum__big .outline {\n padding-top: .2em; margin-top: -.2em; /* die Buchstaben ragen \u00fcber die Zeile: Die F\u00fcllung (Hintergrund) muss bis ganz oben reichen */\n color: transparent; -webkit-text-stroke: 2px var(--navy);\n background: linear-gradient(90deg, var(--navy) 50%, transparent 50%) 100% 0 / 200% 100% no-repeat;\n -webkit-background-clip: text; background-clip: text;\n}\n.raum__big .solid { color: var(--red); }\n/* Umlaute in eng gesetzten \u00dcberschriften (main.js markiert die W\u00f6rter): Die Punkte von \u00c4, \u00d6, \u00dc stehen in der Markenschrift\n weit \u00fcber den Gro\u00dfbuchstaben und rutschten sonst in die Zeile dar\u00fcber (\u201eF\u00fcr alle\u201c verschmolz mit \u201eEin Quartier\u201c) */\n.uml { display: inline-block; padding-top: .24em; }\n.raum__big .solid .char { display: inline-block; }\n.raum__lead { max-width: 24ch; font-size: clamp(19px, 1.8vw, 30px); line-height: 1.22; font-weight: 500; letter-spacing: -.01em; }\n.raum__hint { display: inline-flex; align-items: center; gap: 14px; margin-top: 40px; font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }\n.raum__hint svg { width: 34px; height: auto; color: var(--red); transform: rotate(-90deg); animation: nudge 1.6s var(--ease-io) infinite; }\n/* waagerecht in Pfeilrichtung (Wischen/Weiterfahren geht nach rechts); vorher wippte der nach rechts zeigende Pfeil hoch und runter */\n@keyframes nudge { 0%, 100% { translate: 0 0; } 50% { translate: 8px 0; } }\n\n.rp--img { width: clamp(300px, 44vw, 780px); }\n.rp--tall { width: clamp(270px, 30vw, 540px); }\n.rp--wide { width: clamp(320px, 58vw, 1040px); }\n.rp__img { position: relative; height: 60vh; height: 60svh; border-radius: var(--radius); overflow: hidden; background: var(--cream-2); }\n.rp--tall .rp__img { height: 70vh; height: 70svh; }\n.rp__img img { position: absolute; top: 0; left: -10%; width: 120%; height: 100%; object-fit: cover; }\n.rp figcaption { display: grid; grid-template-columns: auto 1fr; gap: 8px 18px; margin-top: 22px; max-width: 540px; }\n.rp__n { grid-row: span 2; font-family: var(--f-display); font-size: 22px; line-height: 1.1; color: var(--red); }\n.rp figcaption h3 { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(24px, 2.3vw, 40px); line-height: .95; }\n.rp figcaption p { font-size: 15px; color: rgba(0, 48, 73, .78); }\n.rp--type { width: clamp(300px, 38vw, 660px); text-align: center; }\n.rp__rooms { display: flex; justify-content: center; align-items: center; gap: .12em; font-family: var(--f-display); font-size: clamp(130px, 22vw, 420px); line-height: 1.1; }\n.rp__rooms .dash { width: .34em; height: .09em; border-radius: .05em; background: var(--red); transform: translateY(-.06em); }\n.rp__rooms-label { font-family: var(--f-display); font-size: clamp(38px, 4.2vw, 78px); line-height: 1; text-transform: uppercase; margin-top: -.1em; }\n.rp__rooms-sub { max-width: 28ch; margin: 18px auto 0; font-size: 15px; color: rgba(0, 48, 73, .75); }\n.rp--features { width: clamp(330px, 62vw, 1060px); }\n.rp__ftitle { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(40px, 4.6vw, 86px); line-height: .88; }\n.feats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 30px; }\n.feat { display: flex; flex-direction: column; justify-content: space-between; gap: 22px; min-height: clamp(170px, 24vh, 250px); padding: clamp(20px, 2vw, 32px); border-radius: calc(var(--radius) * .8); background: var(--navy); color: var(--cream); }\n.feat:nth-child(2) { background: var(--blue); color: var(--navy); }\n.feat:nth-child(3) { background: var(--red); }\n.feat:nth-child(4) { background: var(--cream-2); color: var(--navy); }\n.feat svg { width: clamp(42px, 3.4vw, 54px); height: auto; }\n.feat h4 { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(24px, 2.3vw, 40px); line-height: .95; }\n.feat p { margin-top: 10px; font-size: 14px; line-height: 1.45; opacity: .86; }\n.raum__progress { position: absolute; left: var(--gutter); right: var(--gutter); bottom: 26px; height: 2px; background: rgba(0, 48, 73, .12); }\n.raum__progress i { position: absolute; inset: 0; background: var(--red); transform-origin: 0 50%; transform: scaleX(0); }\n\n/* ==========================================================================\n NACHHALTIGKEIT\n ========================================================================== */\n.green { position: relative; z-index: 2; background: var(--navy); color: var(--cream); }\n.green__pin { position: relative; height: 100vh; height: 100svh; overflow: hidden; display: grid; place-items: center; }\n.green__eye { position: absolute; inset: 0; clip-path: circle(16% at 50% 50%); }\n.green__eye img { width: 100%; height: 100%; object-fit: cover; transform: scale(1.5); }\n.green__eye::after { content: \"\"; position: absolute; inset: 0; background: radial-gradient(circle at 50% 50%, rgba(0, 48, 73, .25), rgba(0, 48, 73, .78)); }\n.green__head { position: relative; z-index: 2; padding: 0 var(--gutter); text-align: center; }\n/* gr\u00f6\u00dfer auf Wunsch des Kumpels (27.09.; Handy s. u.): vorher 8,4vw bzw. 16\u201322px */\n.green__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(44px, 9.4vw, 190px); line-height: .86; max-width: 14ch; margin: 22px auto 26px; text-shadow: 0 .04em .2em rgba(0, 24, 38, .35); }\n.green__title .char { display: inline-block; }\n.green__sub { max-width: 40ch; margin: 0 auto; font-size: clamp(19px, 1.75vw, 30px); font-weight: 500; text-shadow: 0 .05em .4em rgba(0, 24, 38, .4); }\n.green__text { padding: clamp(110px, 18vh, 220px) var(--gutter) clamp(70px, 12vh, 140px); }\n.green__text p { max-width: 30ch; font-size: clamp(26px, 3.3vw, 58px); line-height: 1.12; font-weight: 500; letter-spacing: -.02em; }\n\n.stack { position: relative; padding: 0 var(--gutter) 14vh; }\n.card { position: relative; height: min(78vh, 820px); margin-bottom: 7vh; }\n.card:last-child { margin-bottom: 0; }\n.card__inner {\n position: relative; height: 100%; overflow: hidden; border-radius: var(--radius);\n display: grid; grid-template-columns: 1.08fr 1fr; gap: clamp(20px, 3vw, 56px);\n padding: clamp(22px, 3vw, 52px); transform-origin: 50% 0%;\n box-shadow: 0 -20px 60px rgba(0, 16, 28, .28);\n}\n.card--cream .card__inner { background: var(--cream); color: var(--navy); }\n.card--blue .card__inner { background: var(--blue); color: var(--navy); }\n.card--red .card__inner { background: var(--red); color: var(--cream); }\n.card--navy .card__inner { background: #0b3d58; color: var(--cream); }\n.card__text { display: flex; flex-direction: column; justify-content: space-between; gap: 24px; min-height: 0; }\n.card__top { display: flex; align-items: center; justify-content: space-between; font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; padding-bottom: 16px; border-bottom: 1.5px solid currentColor; }\n.card__n { font-family: var(--f-display); font-size: clamp(38px, 4vw, 70px); line-height: .9; letter-spacing: 0; }\n.card__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(34px, 4.1vw, 80px); line-height: .88; margin-bottom: 20px; }\n.card__body > p { max-width: 46ch; font-size: clamp(15px, 1.1vw, 18px); opacity: .9; }\n.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; }\n.chips li { padding: 8px 14px; border-radius: 99px; border: 1.5px solid currentColor; font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }\n.card__visual { position: relative; border-radius: calc(var(--radius) * .7); overflow: hidden; min-height: 0; }\n.card__visual--img img { width: 100%; height: 115%; object-fit: cover; }\n.card__shade { position: absolute; inset: 0; z-index: 3; border-radius: inherit; background: #00121e; opacity: 0; pointer-events: none; }\n\n.card__visual--sun { display: grid; place-items: center; background: rgba(0, 48, 73, .12); }\n.sun { position: relative; width: min(64%, 46vh); aspect-ratio: 1; display: grid; place-items: center; }\n.sun__flower { width: 100%; height: 100%; color: var(--cream); animation: spin 22s linear infinite; }\n.sun__core { position: absolute; width: 22%; aspect-ratio: 1; border-radius: 50%; background: var(--red); box-shadow: 0 0 0 0 rgba(193, 17, 31, .5); animation: pulse 2.2s ease-out infinite; }\n.orbit { position: absolute; inset: 8%; animation: spin 14s linear infinite reverse; }\n.orbit svg { position: absolute; width: 13%; height: auto; color: var(--navy); }\n.orbit svg:nth-child(1) { left: 0; top: 44%; }\n.orbit svg:nth-child(2) { right: 4%; top: 6%; color: var(--cream-2); }\n.orbit svg:nth-child(3) { right: 12%; bottom: 0; color: var(--red); }\n@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(193, 17, 31, .55); } 100% { box-shadow: 0 0 0 36px rgba(193, 17, 31, 0); } }\n@keyframes pulseSm { 0% { box-shadow: 0 0 0 0 rgba(193, 17, 31, .55); } 100% { box-shadow: 0 0 0 9px rgba(193, 17, 31, 0); } }\n\n.card__visual--charge { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: clamp(12px, 2vh, 22px); padding: clamp(20px, 3vw, 48px); background: rgba(0, 0, 0, .12); }\n.charge__scene { display: block; width: 100%; max-width: 640px; height: auto; }\n.charge__road { stroke: rgba(250, 245, 237, .4); }\n.charge__stat { display: flex; align-items: center; gap: clamp(14px, 1.6vw, 24px); width: 100%; max-width: 640px; }\n.charge__num { display: flex; align-items: flex-start; justify-content: flex-end; flex: none; min-width: 2.3em; font-family: var(--f-display); font-size: clamp(44px, 4.4vw, 84px); line-height: 1; }\n.charge__pct { font-size: .5em; margin-left: .06em; line-height: 1.05; }\n.charge__label { font-size: 13px; font-weight: 700; letter-spacing: .14em; line-height: 1.4; text-transform: uppercase; opacity: .85; }\n.battery { position: relative; display: grid; grid-template-columns: repeat(10, 1fr); gap: 6px; width: calc(100% - 10px); max-width: 630px; height: clamp(44px, 6vh, 64px); margin-right: 10px; padding: 7px; border: 2px solid currentColor; border-radius: 14px; }\n.battery::after { content: \"\"; position: absolute; right: -10px; top: 30%; width: 6px; height: 40%; border-radius: 0 4px 4px 0; background: currentColor; }\n.battery i { border-radius: 5px; background: var(--cream); transform-origin: 0 50%; }\n\n.card__visual--badges { display: grid; grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); gap: 14px; place-items: center; background: rgba(255, 255, 255, .05); padding: clamp(16px, 2vw, 32px); }\n.stamp { display: grid; place-content: center; text-align: center; width: min(100%, 24vh); aspect-ratio: 1; border-radius: 50%; border: 2px dashed currentColor; font-family: var(--f-display); text-transform: uppercase; line-height: .9; }\n.stamp span { font-family: var(--f-body); font-size: 11px; font-weight: 700; letter-spacing: .14em; }\n.stamp b { font-weight: 400; font-size: clamp(30px, 3vw, 54px); }\n.stamp--a { background: var(--red); border-color: var(--cream); border-style: solid; transform: rotate(-8deg); }\n.stamp--b { background: var(--cream); color: var(--navy); border-style: solid; transform: rotate(6deg); grid-column: 2; grid-row: 1 / 3; }\n.stamp--c { background: #fff; border: 0; overflow: hidden; transform: rotate(-4deg); }\n.stamp--c img { width: 78%; height: auto; margin: auto; }\n\n/* ==========================================================================\n DAS PROJEKT\n ========================================================================== */\n.projekt { position: relative; z-index: 2; height: 100vh; height: 100svh; overflow: hidden; background: var(--cream); color: var(--navy); }\n.projekt__inner { height: 100%; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: clamp(16px, 3vh, 34px); padding: calc(var(--nav-h) + 6px) var(--gutter) var(--gutter); }\n.projekt__head { display: flex; justify-content: space-between; align-items: flex-end; gap: 24px; }\n.projekt__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(38px, 5.4vw, 104px); line-height: .86; margin-top: 14px; }\n.plan__steps { display: flex; gap: 8px; font-size: 12px; font-weight: 700; letter-spacing: .1em; }\n.plan__steps li { padding: 9px 14px; border-radius: 99px; border: 1.5px solid rgba(0, 48, 73, .25); transition: background .4s ease, color .4s ease, border-color .4s ease; white-space: nowrap; }\n.plan__steps li.is-active { background: var(--navy); border-color: var(--navy); color: var(--cream); }\n.plan { display: grid; grid-template-columns: 1.5fr 1fr; gap: clamp(20px, 3.2vw, 60px); min-height: 0; }\n.plan__visual { position: relative; min-height: 0; border-radius: var(--radius); overflow: hidden; background: var(--plan); }\n.plan__img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }\n.plan__img:not([data-plan=\"base\"]) { clip-path: inset(0 100% 0 0); }\n.plan__scan { position: absolute; top: 0; bottom: 0; left: 0; width: 3px; background: var(--red); box-shadow: 0 0 24px 4px rgba(193, 17, 31, .5); opacity: 0; }\n.plan__tag { position: absolute; left: 18px; bottom: 18px; max-width: 280px; padding: 12px 16px; border-radius: 16px; background: rgba(250, 245, 237, .88); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); font-size: 13px; font-weight: 600; line-height: 1.35; }\n.plan__info { position: relative; display: grid; align-content: center; min-height: 0; }\n.phase { grid-area: 1 / 1; visibility: hidden; }\n.phase__status { display: inline-flex; align-items: center; gap: 10px; font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }\n.phase__status i { width: 10px; height: 10px; border-radius: 50%; background: rgba(0, 48, 73, .35); }\n.phase__status--live i { background: var(--red); animation: pulse 1.8s ease-out infinite; }\n.phase__name { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(40px, 4.9vw, 96px); line-height: .86; margin: 16px 0 10px; }\n/* Farben der Bauabschnitte = Farben ihrer H\u00e4user im Lageplan (I & II graublau, III & IV rot, V & VI blau); das Graublau\n etwas dunkler als im Bild (#869AAC), sonst zu blass als Schrift auf Creme */\n.projekt { --ph0: #6886A1; --ph1: var(--red); --ph2: var(--teal); }\n.phase--0 .phase__name { color: var(--ph0); }\n.phase--1 .phase__name { color: var(--ph1); }\n.phase--2 .phase__name { color: var(--ph2); }\n.phase__wa { font-size: 13px; font-weight: 600; letter-spacing: .08em; opacity: .7; }\n.phase__facts { display: grid; grid-template-columns: repeat(3, 1fr); margin: 26px 0 22px; border-top: 1.5px solid rgba(0, 48, 73, .2); }\n.phase__facts div { padding: 16px 12px 0 0; }\n.phase__facts dt { font-size: 11px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; opacity: .6; }\n.phase__facts dd { font-family: var(--f-display); font-size: clamp(40px, 4.4vw, 84px); line-height: 1; margin-top: 8px; }\n.phase__text { max-width: 44ch; font-size: clamp(14px, 1.05vw, 17px); opacity: .85; }\n.sum { display: flex; flex-wrap: wrap; align-items: baseline; gap: .05em .22em; margin: 20px 0 18px; font-family: var(--f-display); font-size: clamp(56px, 6.6vw, 132px); line-height: .92; text-transform: uppercase; }\n.sum span { display: inline-block; }\n/* Summe: jede Zahl in der Farbe ihrer H\u00e4user (Spans aus initProjekt), das Ergebnis dunkel (rot h\u00e4tte nach III & IV ausgesehen) */\n.sum__n--0 { color: var(--ph0); }\n.sum__n--1 { color: var(--ph1); }\n.sum__n--2 { color: var(--ph2); }\n.sum__eq { width: 100%; color: var(--navy); font-size: 1.35em; }\n/* Hochformat ab Tablet (iPad hochkant): Lageplan \u00fcber die ganze Breite, Angaben darunter. Zweispaltig war der Plan so\n schmal und hoch beschnitten, dass die H\u00e4user von I & II und V & VI au\u00dferhalb des Bildes lagen. */\n@media (min-width: 761px) and (orientation: portrait) {\n .plan { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) auto; }\n .plan__info { align-content: start; }\n}\n\n/* ==========================================================================\n LAGE\n ========================================================================== */\n.lage { position: relative; z-index: 2; height: 100vh; height: 100svh; overflow: hidden; background: var(--map); color: var(--navy); }\n.lage__map { position: absolute; inset: 0; }\n.lage__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n.lage__veil { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(100deg, rgba(246, 248, 235, .96) 0%, rgba(246, 248, 235, .7) 30%, rgba(246, 248, 235, 0) 55%); }\n.lage__copy { position: absolute; left: var(--gutter); top: calc(var(--nav-h) + 12px); }\n.lage__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(58px, 10vw, 200px); line-height: .84; margin-top: 16px; }\n.lage__title .char { display: inline-block; }\n/* Marker folgt dem Quartier (Position per JS), die Pop-Animation sitzt am inneren Element */\n.lage__marker { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2; will-change: transform; }\n.lage__marker-in { position: absolute; left: 0; top: 0; }\n.lage__ring { position: absolute; left: -46px; top: -46px; width: 92px; height: 92px; border-radius: 50%; border: 2px solid var(--red); animation: ring 2.4s var(--ease-out) infinite; }\n.lage__ring--2 { animation-delay: 1.2s; }\n@keyframes ring { 0% { transform: scale(.3); opacity: 1; } 100% { transform: scale(2.2); opacity: 0; } }\n.lage__pin { position: absolute; left: 0; bottom: 22px; transform: translateX(-50%); display: flex; align-items: center; gap: 10px; padding: 12px 18px 12px 14px; border-radius: 99px; background: var(--navy); color: var(--cream); white-space: nowrap; font-family: var(--f-display); font-size: clamp(18px, 1.6vw, 26px); line-height: 1; text-transform: uppercase; box-shadow: 0 16px 40px rgba(0, 30, 50, .35); }\n.lage__pin::after { content: \"\"; position: absolute; left: 50%; bottom: -7px; width: 14px; height: 14px; background: var(--navy); transform: translateX(-50%) rotate(45deg); border-radius: 2px; }\n.lage__pin svg { width: 22px; height: auto; color: var(--red); }\n.lage__labels { position: absolute; right: var(--gutter); bottom: var(--gutter); height: 1em; overflow: hidden; font-family: var(--f-display); font-size: clamp(38px, 6vw, 118px); line-height: 1; text-transform: uppercase; text-align: right; }\n.lage__ticker span { display: block; height: 1em; }\n.lage__ticker span:last-child { color: var(--red); }\n/* Schilder an den Zielen der Wege (Name in der Markenschrift, Minuten in Inter Tight) */\n.lage__dests { position: absolute; inset: 0; pointer-events: none; z-index: 2; }\n.lage__dest { position: absolute; left: 0; top: 0; will-change: transform; }\n.lage__dest-in { display: grid; gap: 5px; width: max-content; padding: 11px 15px 12px; border-radius: 14px; background: rgba(250, 245, 237, .95); color: var(--navy); white-space: nowrap; box-shadow: 0 12px 34px rgba(0, 30, 50, .16); visibility: hidden; opacity: 0; }\n.lage__dest b { font-family: var(--f-display); font-weight: 400; font-size: clamp(16px, 1.4vw, 23px); line-height: 1; text-transform: uppercase; padding-top: .14em; }\n.lage__dest span { font-size: 12.5px; font-weight: 600; letter-spacing: .01em; color: rgba(0, 48, 73, .78); }\n.lage__dest span i { font-style: normal; color: var(--red); }\n.lage__dest--minor .lage__dest-in { padding: 8px 12px 9px; background: rgba(250, 245, 237, .9); }\n.lage__dest--minor b { font-size: clamp(13px, 1.05vw, 16px); }\n.lage__dest--minor span { font-size: 11.5px; }\n/* Quellenangabe der Karte (Pflicht: \u00a9 OpenStreetMap-Mitwirkende) */\n.lage__credit { position: absolute; left: var(--gutter); bottom: 12px; z-index: 3; font-size: 10.5px; line-height: 1.3; color: rgba(0, 48, 73, .62); }\n.lage__credit a { text-decoration: underline; text-underline-offset: 2px; }\n/* Minuten-Liste: am Desktop nur f\u00fcr Screenreader (Karte und Schilder sind aria-hidden), am Handy sichtbar */\n.lage__list { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n/* ==========================================================================\n KIEZ\n ========================================================================== */\n.kiez { position: relative; z-index: 2; background: var(--cream); color: var(--navy); padding: clamp(110px, 17vh, 210px) var(--gutter) clamp(110px, 17vh, 210px); }\n.kiez__head { display: grid; grid-template-columns: 1.25fr 1fr; gap: 5vw; align-items: end; margin-bottom: clamp(60px, 11vh, 130px); }\n.kiez__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(50px, 8.2vw, 160px); line-height: .84; }\n.kiez__title .char { display: inline-block; }\n.kiez__lead { font-size: clamp(20px, 1.9vw, 32px); line-height: 1.18; font-weight: 600; letter-spacing: -.01em; margin-bottom: 18px; }\n.kiez__text { max-width: 52ch; color: rgba(0, 48, 73, .8); }\n.places { border-top: 1.5px solid rgba(0, 48, 73, .18); }\n.place { position: relative; display: grid; grid-template-columns: 70px 1fr auto; align-items: center; gap: 20px; padding: clamp(18px, 2.4vw, 34px) 0; border-bottom: 1.5px solid rgba(0, 48, 73, .18); overflow: hidden; }\n.place::before { content: \"\"; position: absolute; inset: 0; z-index: 0; background: var(--navy); transform: scaleY(0); transform-origin: 50% 100%; transition: transform .65s var(--ease-io); }\n.place > * { position: relative; z-index: 1; transition: color .55s var(--ease-io), transform .65s var(--ease-io); }\n.place__n { padding-left: 8px; font-size: 12px; font-weight: 700; letter-spacing: .12em; color: var(--red); }\n.place__name { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(32px, 5.4vw, 104px); line-height: .92; }\n.place__tag { padding-right: 8px; font-size: 12px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; opacity: .7; text-align: right; }\n.place__thumb { display: none; }\n@media (hover: hover) {\n .place:hover::before { transform: scaleY(1); }\n .place:hover > * { color: var(--cream); }\n .place:hover .place__name { transform: translateX(28px); }\n}\n/* Karte mit dem Foto des Ortes, l\u00e4uft am Desktop mit der Maus \u00fcber die Liste (Fotos quer, 4:3) */\n.places__float { position: fixed; left: 0; top: 0; z-index: 60; width: clamp(240px, 24vw, 400px); aspect-ratio: 4 / 3; border-radius: 22px; overflow: hidden; pointer-events: none; transform: scale(0); box-shadow: 0 30px 70px rgba(0, 30, 50, .35); background: var(--navy); }\n.pslide { position: absolute; inset: 0; clip-path: inset(100% 0 0 0); }\n.pslide img { width: 100%; height: 100%; object-fit: cover; }\n.places__credit { max-width: 110ch; margin-top: 26px; font-size: 11px; line-height: 1.6; color: rgba(0, 48, 73, .62); }\n.places__credit a { text-decoration: underline; text-decoration-color: rgba(0, 48, 73, .28); text-underline-offset: 2px; transition: color .3s ease, text-decoration-color .3s ease; }\n.places__credit a:hover { color: var(--navy); text-decoration-color: var(--red); }\n/* Touch-Ger\u00e4te ab Tablet-Breite haben keine Maus-Karte: dort steht das Foto klein in der Zeile */\n@media (hover: none) and (min-width: 761px) {\n .place { grid-template-columns: 70px 1fr auto auto; }\n .place__thumb { display: block; width: 132px; aspect-ratio: 4 / 3; border-radius: 16px; overflow: hidden; background: var(--navy); }\n .place__thumb img { width: 100%; height: 100%; object-fit: cover; }\n}\n\n/* ==========================================================================\n GESCHICHTE\n ========================================================================== */\n.history { position: relative; z-index: 2; height: 100vh; height: 100svh; overflow: hidden; background: var(--navy); color: var(--cream); }\n.history__shape { position: absolute; right: -8vw; bottom: -18vh; width: clamp(300px, 46vw, 820px); height: auto; color: rgba(102, 155, 188, .16); transform-origin: 50% 70%; }\n.history__inner { position: relative; height: 100%; display: grid; grid-template-rows: auto 1fr auto; padding: calc(var(--nav-h) + 16px) var(--gutter) calc(var(--gutter) + 8px); }\n.history__big { align-self: center; font-family: var(--f-display); text-transform: uppercase; font-size: clamp(64px, 14.5vw, 290px); line-height: .9; white-space: nowrap; }\n.history__foot { display: flex; justify-content: space-between; align-items: flex-end; gap: 30px; }\n.history__steps { display: grid; max-width: 560px; }\n.hstep { grid-area: 1 / 1; font-size: clamp(18px, 1.6vw, 26px); line-height: 1.3; font-weight: 500; opacity: 0; transform: translateY(24px); transition: opacity .6s ease, transform .8s var(--ease-out); }\n.hstep.is-active { opacity: 1; transform: none; }\n.hstep b { display: block; margin-bottom: 10px; font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: var(--blue); }\n.history__dots { display: flex; gap: 10px; }\n.history__dots i { width: 38px; height: 4px; border-radius: 4px; background: rgba(250, 245, 237, .2); transition: background .5s ease; }\n.history__dots i.is-active { background: var(--red); }\n.history__rail { position: absolute; right: calc(var(--gutter) * .5); top: 22vh; bottom: 22vh; width: 2px; background: rgba(250, 245, 237, .14); }\n.history__rail i { position: absolute; inset: 0; background: var(--red); transform-origin: 50% 0; transform: scaleY(0); }\n/* Fotos zu den Stationen (main.js baut sie aus data-img\u2026 der Stationen) */\n.hpic { margin: 0; }\n.hpic__img { position: relative; display: block; aspect-ratio: 4 / 3; overflow: hidden; background: rgba(250, 245, 237, .07); }\n.hpic__img img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }\n.hpic__cap { display: flex; flex-direction: column; gap: 2px; margin-top: 9px; font-size: 11px; line-height: 1.4; color: rgba(250, 245, 237, .58); }\n.hpic__what { font-weight: 600; color: rgba(250, 245, 237, .86); }\n.hpic__by:empty { display: none; }\n.hpic a, .hline__credit a { text-decoration: underline; text-decoration-color: rgba(250, 245, 237, .3); text-underline-offset: 2px; transition: color .3s ease, text-decoration-color .3s ease; }\n.hpic a:hover, .hline__credit a:hover { color: var(--cream); text-decoration-color: var(--red); }\n/* Jahres-Wort am Desktop: Abz\u00fcge mit Rand, die sich rechts stapeln */\n.hpics { position: absolute; right: calc(var(--gutter) + 4.5vw); top: 50%; width: clamp(280px, 30vw, 520px); translate: 0 -56%; pointer-events: none; }\n.hpics::before { content: \"\"; display: block; padding-top: calc(75% + 70px); }\n.hpics__stack { position: absolute; inset: 0; transition: opacity .6s ease, transform .8s var(--ease-out); }\n.hpics.is-empty .hpics__stack { opacity: 0; transform: translateY(40px); }\n.hpic--print { position: absolute; left: 0; top: 0; width: 100%; padding: 10px 10px 0; background: var(--cream); box-shadow: 0 30px 60px rgba(0, 12, 22, .42), 0 2px 6px rgba(0, 12, 22, .2); pointer-events: auto; will-change: transform; }\n.hpic--print::after { content: \"\"; position: absolute; inset: 0; background: var(--navy); opacity: 0; transition: opacity .6s ease; pointer-events: none; }\n.hpic--print.is-under::after { opacity: .3; }\n.hpic--print .hpic__img { background: #e9e1d0; }\n.hpic--print .hpic__cap { min-height: 60px; margin: 0; padding: 10px 2px 12px; color: rgba(0, 48, 73, .62); }\n.hpic--print .hpic__what { color: var(--navy); }\n.hpic--print a { text-decoration-color: rgba(0, 48, 73, .3); }\n.hpic--print a:hover { color: var(--navy); }\n\n/* ==========================================================================\n IMAGE TRAIL\n ========================================================================== */\n.trail { position: relative; z-index: 2; height: 100vh; height: 100svh; overflow: hidden; display: grid; place-items: center; text-align: center; background: var(--blue); color: var(--navy); }\n.trail__imgs { position: absolute; inset: 0; z-index: 1; pointer-events: none; }\n.trail__img { position: absolute; left: 0; top: 0; width: clamp(160px, 19vw, 340px); aspect-ratio: 4 / 3; border-radius: 18px; overflow: hidden; opacity: 0; box-shadow: 0 28px 60px rgba(0, 30, 50, .3); will-change: transform; }\n.trail__img img { width: 100%; height: 100%; object-fit: cover; }\n.trail__content { position: relative; z-index: 3; pointer-events: none; padding: 0 var(--gutter); }\n.trail__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(46px, 8.2vw, 160px); line-height: .86; color: var(--cream); margin: 22px 0 30px; text-shadow: 0 .03em .18em rgba(0, 48, 73, .35); }\n.trail__title .char { display: inline-block; }\n.trail__hint { display: inline-flex; align-items: center; gap: 10px; padding: 10px 18px; border-radius: 99px; background: var(--navy); color: var(--cream); font-size: 12px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }\n.trail__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--red); animation: pulse 1.6s ease-out infinite; }\n.trail__hint-touch, .footer__hint-touch { display: none; }\n@media (hover: none), (pointer: coarse) {\n .trail__hint-mouse, .footer__hint-mouse { display: none; }\n .trail__hint-touch, .footer__hint-touch { display: inline; }\n}\n\n/* ==========================================================================\n FILM (Baustelle aus der Luft): stummer Loop, Desktop mit Schrift-Maske (html[data-film])\n ========================================================================== */\n.film { position: relative; z-index: 2; height: 100vh; height: 100svh; overflow: hidden; background: var(--cream); color: var(--cream); }\n.film__media { position: absolute; inset: 0; overflow: hidden; background: var(--navy); }\n.film__media picture { display: contents; }\n.film__poster, .film__video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }\n.film__video { opacity: 0; transition: opacity .8s ease; }\n.film__video.is-playing { opacity: 1; }\n/* Spielt iOS nicht automatisch ab (Stromsparmodus), bliebe sonst ein Play-Symbol \u00fcber dem Film: dann steht das Poster */\n.film__video::-webkit-media-controls, .film__video::-webkit-media-controls-start-playback-button { display: none !important; -webkit-appearance: none; }\n.film__shade { position: absolute; inset: 0; pointer-events: none; background: linear-gradient(to top, rgba(0, 36, 58, .8) 0%, rgba(0, 36, 58, .24) 38%, rgba(0, 36, 58, 0) 60%); opacity: 0; }\nhtml[data-film=\"karte\"] .film__shade { opacity: 1; }\n.film__mask { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; pointer-events: none; display: none; }\nhtml[data-film=\"schrift\"] .film__mask { display: block; }\n.film__copy { position: absolute; z-index: 4; left: var(--gutter); right: calc(var(--gutter) + 90px); bottom: calc(var(--gutter) + 8px); display: grid; justify-items: start; gap: 16px; }\n.film__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(48px, 7.4vw, 140px); line-height: .86; padding-top: .12em; text-shadow: 0 .04em .3em rgba(0, 20, 35, .35); }\n/* Schrift-Fassung: Der Titel steht gro\u00df in der Maske, hier nur f\u00fcr Screenreader */\nhtml[data-film=\"schrift\"] .film__title { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n.film__btn { margin-top: 6px; padding: 16px 26px 16px 20px; background: rgba(0, 36, 58, .35); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); }\n.film__pause { position: absolute; z-index: 5; right: calc(var(--gutter) + 26px); bottom: calc(var(--gutter) + 12px); display: grid; place-items: center; width: 50px; height: 50px; border-radius: 50%; color: var(--cream); background: rgba(0, 36, 58, .35); border: 1.5px solid rgba(250, 245, 237, .55); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); transition: background .3s ease, color .3s ease, border-color .3s ease; }\n.film__pause:focus-visible { background: var(--cream); color: var(--navy); border-color: var(--cream); }\n@media (hover: hover) { .film__pause:hover { background: var(--cream); color: var(--navy); border-color: var(--cream); } }\n.film__pause i { width: 12px; height: 14px; border-left: 4px solid currentColor; border-right: 4px solid currentColor; }\n.film__pause.is-paused i { width: 12px; margin-left: 3px; border: 0; background: currentColor; clip-path: polygon(0 0, 100% 50%, 0 100%); }\n\n/* Lightbox f\u00fcr den ganzen Film (Vimeo), main.js initLightbox. \u00dcber allem au\u00dfer dem eigenen Cursor (der ist dann aus) */\n.lightbox { position: fixed; inset: 0; z-index: 99; display: grid; place-items: center; padding: max(64px, 7vh) var(--gutter); background: rgba(0, 20, 32, .92); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); opacity: 0; transition: opacity .4s ease; }\n.lightbox.is-open { opacity: 1; }\n.lightbox:not(.is-open) { pointer-events: none; }\nhtml.lightbox-open { background: rgb(0, 20, 32); } /* Streifen der freigehaltenen Scrollleiste wie der Hintergrund der Lightbox */ /* beim Aus- und Einblenden keine Klicks abfangen */\n.lightbox__frame { position: relative; width: min(100%, calc((100vh - 2 * max(64px, 7vh)) * 16 / 9)); aspect-ratio: 16 / 9; border-radius: 14px; overflow: hidden; background: #000; box-shadow: 0 30px 80px rgba(0, 0, 0, .5); transform: translateY(18px) scale(.97); transition: transform .6s var(--ease-out); }\n@supports (height: 100svh) { .lightbox__frame { width: min(100%, calc((100svh - 2 * max(64px, 7svh)) * 16 / 9)); } }\n.lightbox.is-open .lightbox__frame { transform: none; }\n.lightbox__frame iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }\n.lightbox__close { position: absolute; top: calc(max(12px, env(safe-area-inset-top, 0px)) + 4px); right: max(12px, var(--gutter)); display: grid; place-items: center; width: 48px; height: 48px; border-radius: 50%; background: var(--cream); color: var(--navy); transition: background .3s ease, color .3s ease; }\n.lightbox__close i { position: absolute; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transform: rotate(45deg); }\n.lightbox__close i + i { transform: rotate(-45deg); }\n.lightbox__close:focus-visible { outline: 2px solid var(--red); outline-offset: 3px; }\n@media (hover: hover) { .lightbox__close:hover { background: var(--red); color: var(--cream); } }\n/* solange der Film l\u00e4uft: normaler Mauszeiger (\u00fcber dem Vimeo-Player kommt der eigene nicht an), keine Leiste, keine Pille */\nhtml.has-cursor.lightbox-open .lightbox, html.has-cursor.lightbox-open .lightbox * { cursor: auto !important; }\nhtml.has-cursor.lightbox-open .lightbox__close { cursor: pointer !important; }\nhtml.lightbox-open .cursor, html.lightbox-open .secnav, html.lightbox-open .skip { opacity: 0 !important; pointer-events: none !important; }\n\n/* ==========================================================================\n NEUIGKEITEN\n ========================================================================== */\n.news { position: relative; z-index: 2; background: var(--cream); color: var(--navy); padding: clamp(110px, 17vh, 210px) var(--gutter) clamp(120px, 18vh, 230px); }\n.news__head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: clamp(36px, 6vh, 64px); }\n.news__title { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(50px, 8.2vw, 160px); line-height: .84; margin-top: 16px; }\n.news__title .char { display: inline-block; }\n.news__card { display: grid; grid-template-columns: 1.25fr 1fr; overflow: hidden; border-radius: var(--radius); background: var(--navy); color: var(--cream); will-change: transform; }\n.news__img { position: relative; min-height: clamp(320px, 60vh, 640px); overflow: hidden; }\n.news__img img { position: absolute; left: -10%; top: -10%; width: 120%; height: 120%; object-fit: cover; }\n.news__body { display: flex; flex-direction: column; justify-content: space-between; align-items: flex-start; gap: 36px; padding: clamp(26px, 4vw, 64px); }\n.news__body h3 { font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(46px, 5.8vw, 112px); line-height: .86; }\n\n/* ==========================================================================\n KONTAKT\n ========================================================================== */\n.kontakt { position: relative; z-index: 3; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); grid-template-rows: auto auto auto 1fr; column-gap: 6vw; align-items: start; background: var(--red); color: var(--cream); border-radius: 0 0 var(--radius) var(--radius); padding: clamp(96px, 14vh, 160px) var(--gutter) clamp(70px, 10vh, 120px); box-shadow: 0 40px 90px rgba(0, 16, 28, .35); }\n.kontakt > .kicker { grid-column: 1; grid-row: 1; justify-self: start; }\n.kontakt__title { grid-column: 1; grid-row: 2; font-family: var(--f-display); font-weight: 400; text-transform: uppercase; font-size: clamp(44px, 5.6vw, 104px); line-height: .86; margin: 18px 0 22px; }\n.kontakt__title .char { display: inline-block; }\n.kontakt__lead { grid-column: 1; grid-row: 3; max-width: 44ch; font-size: clamp(16px, 1.15vw, 19px); }\n.kontakt__grid { display: contents; }\n.kontakt__direct { grid-column: 1; grid-row: 4; display: flex; flex-direction: column; align-items: flex-start; gap: 6px; margin-top: clamp(26px, 4.5vh, 46px); }\n.biglink { display: inline-block; font-family: var(--f-display); font-size: clamp(24px, 2.3vw, 42px); line-height: 1.15; text-transform: uppercase; }\n.biglink .roll { line-height: 1.12; }\n.kontakt address { margin-top: 16px; font-style: normal; opacity: .88; }\n.form { grid-column: 2; grid-row: 1 / span 4; align-self: center; display: grid; grid-template-columns: 1fr 1fr; gap: 22px 24px; }\n.field { position: relative; }\n.field--full { grid-column: 1 / -1; }\n.field input, .field textarea {\n display: block; width: 100%; padding: 24px 0 9px; border: 0; border-bottom: 1.5px solid rgba(250, 245, 237, .5); border-radius: 0;\n background: transparent; color: var(--cream); font: inherit; font-size: 17px; outline: none; resize: none;\n}\n.field label { position: absolute; left: 0; top: 24px; font-size: 17px; color: rgba(250, 245, 237, .78); pointer-events: none; transform-origin: 0 0; transition: transform .5s var(--ease-out), color .4s ease; }\n.field input:focus + label, .field input:not(:placeholder-shown) + label,\n.field textarea:focus + label, .field textarea:not(:placeholder-shown) + label { transform: translateY(-24px) scale(.7); color: var(--cream); }\n.field i { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: var(--cream); transform: scaleX(0); transform-origin: 0 50%; transition: transform .7s var(--ease-io); }\n.field input:focus ~ i, .field textarea:focus ~ i { transform: scaleX(1); }\n.field.is-invalid input, .field.is-invalid textarea { border-bottom-color: var(--cream-2); }\n.field.is-invalid label { color: var(--cream-2); }\n.field.is-invalid::after { content: \"Pflichtfeld\"; position: absolute; right: 0; top: 30px; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; color: var(--cream-2); }\n.check { grid-column: 1 / -1; display: flex; align-items: center; gap: 14px; font-size: 14px; cursor: pointer; }\n.check input { position: absolute; opacity: 0; width: 1px; height: 1px; }\n.check__box { position: relative; flex: none; width: 24px; height: 24px; border: 1.5px solid var(--cream); border-radius: 7px; transition: background .3s ease; }\n.check input:checked + .check__box { background: var(--cream); }\n.check input:checked + .check__box::after { content: \"\"; position: absolute; left: 7.5px; top: 3.5px; width: 6px; height: 11px; border: solid var(--red); border-width: 0 2.5px 2.5px 0; transform: rotate(45deg); }\n.check input:focus-visible + .check__box { outline: 2px solid var(--cream); outline-offset: 3px; }\n.check.is-invalid .check__box { border-color: var(--cream-2); box-shadow: 0 0 0 3px rgba(245, 237, 214, .35); }\n.check a { text-decoration: underline; text-underline-offset: 3px; }\n.form__foot { grid-column: 1 / -1; display: flex; align-items: center; flex-wrap: wrap; gap: 20px 28px; }\n.form__note { font-size: 14px; font-weight: 600; }\n\n/* ==========================================================================\n FOOTER\n ========================================================================== */\n.footer { position: relative; z-index: 1; height: 100vh; height: 100svh; min-height: 600px; margin-top: calc(var(--radius) * -1); overflow: hidden; background: var(--navy); color: var(--cream); }\n.footer__inner { position: absolute; inset: 0; display: flex; flex-direction: column; }\n.footer__stage { position: relative; flex: 1; overflow: hidden; }\n.footer__bg { position: absolute; inset: 0; background: radial-gradient(130% 95% at 50% 0%, #86b5d2 0%, var(--blue) 52%, #5b8fb0 100%); }\n.footer__render { display: none; position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 60%; }\nhtml[data-footer=\"rendering\"] .footer__render { display: block; }\nhtml[data-footer=\"rendering\"] .footer__bg::after { content: \"\"; position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(0, 36, 58, .62) 0%, rgba(0, 36, 58, .34) 34%, rgba(0, 36, 58, 0) 60%, rgba(0, 36, 58, .22) 100%); }\nhtml[data-footer=\"rendering\"] .footer__word { text-shadow: 0 .03em .28em rgba(0, 24, 40, .5); }\n.footer__word { position: absolute; left: 0; right: 0; top: calc(var(--radius) + 11vh); text-align: center; white-space: nowrap; font-family: var(--f-display); text-transform: uppercase; font-size: 15.4vw; line-height: .8; color: var(--cream); text-shadow: 0 .03em .12em rgba(0, 48, 73, .2); }\n.footer__word .char { display: inline-block; }\n.footer__world { position: absolute; inset: 0; z-index: 3; touch-action: pan-y; }\n.pbody { position: absolute; left: 0; top: 0; pointer-events: none; will-change: transform; }\n.pbody svg { width: 100%; height: 100%; overflow: visible; filter: drop-shadow(0 10px 14px rgba(0, 30, 50, .22)); }\n.footer__hint { position: absolute; z-index: 4; right: var(--gutter); top: calc(var(--radius) + 20px); padding: 9px 16px; border-radius: 99px; background: rgba(0, 48, 73, .82); font-size: 11px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; pointer-events: none; }\n.footer__bar { position: relative; z-index: 5; display: grid; grid-template-columns: 1.5fr 1fr 1fr auto; align-items: center; gap: 24px; padding: 26px var(--gutter) calc(26px + var(--wf-badge, 0px)); background: var(--navy); font-size: 13px; }\n.footer__col { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; }\n.footer__col--brand { flex-direction: row; align-items: center; gap: 16px; }\n.footer__col--brand svg { width: 34px; height: auto; flex: none; color: var(--cream); }\n.footer__col--brand p { opacity: .8; }\n.footer__col--brand .footer__credit { margin-top: 8px; font-size: 11px; line-height: 1.5; opacity: .55; }\n.footer__credit a { text-decoration: underline; text-decoration-color: rgba(250, 245, 237, .3); text-underline-offset: 2px; }\n.footer__top { display: grid; place-items: center; width: 64px; height: 64px; border-radius: 50%; background: var(--red); color: var(--cream); }\n.footer__top svg { width: 26px; height: auto; transform: rotate(180deg); }\n\n/* ==========================================================================\n RESPONSIVE\n ========================================================================== */\n@media (max-width: 1180px) {\n .nav__links { display: none; }\n}\n\n@media (max-width: 1000px) {\n .menu__panel { grid-template-columns: 1fr; align-content: end; }\n .kiez__head { grid-template-columns: 1fr; }\n .kontakt { display: block; }\n .kontakt__title { font-size: clamp(44px, 8vw, 80px); }\n .kontakt__grid { display: grid; grid-template-columns: 1fr; gap: 40px; margin-top: 34px; }\n .kontakt__direct { margin-top: 0; }\n .kontakt__direct, .form { grid-column: auto; grid-row: auto; }\n .footer__bar { grid-template-columns: 1fr 1fr; }\n .footer__col--brand { grid-column: 1 / -1; }\n}\n\n@media (max-width: 760px) {\n :root { --nav-h: 72px; }\n /* auch nach der H\u00f6he begrenzt: auf kleinen iPhones (548 px) rutschte der erste Punkt sonst unter die Kn\u00f6pfe */\n .menu__link { font-size: min(9.4vw, 5.6svh); gap: 12px; }\n .menu__panel { gap: 26px; }\n .nav__brand { display: none; }\n .hero { --hb: max(40.2vw, 58svh); }\n .hero__build img, .hero__build .build__gl { transform: translateX(-31%); }\n .hero__ui { grid-template-columns: 1fr auto; }\n .hero__meta { display: none; }\n .hero__badge { width: 88px; }\n\n .intro__title { font-size: 10vw; }\n .intro__title .ln__i { gap: .16em; }\n .pill { width: 1.4em; }\n .intro__cols { grid-template-columns: 1fr; gap: 28px; }\n .retreat__title { font-size: 9.4vw; }\n .raum__big { font-size: 17vw; }\n .history__big { font-size: 13vw; }\n .trail__title { font-size: 9.6vw; }\n .green__title { font-size: 12.6vw; } /* Nachhaltigkeit: vorher 36px (Kumpel: \u201emuss gr\u00f6\u00dfer\u201c) */\n .garden { height: 34vh; }\n\n .stats__grid { grid-template-columns: repeat(2, 1fr); row-gap: 44px; }\n\n /* \u00dcber Kreuz w\u00fcrden sich die B\u00e4nder auf schmalen Bildschirmen fast ganz verdecken: hier \u00fcbereinander */\n .bands { height: 290px; }\n .band--b { top: 30%; transform: translateY(-50%) rotate(2.6deg); }\n .band--a { top: 70%; transform: translateY(-50%) rotate(-3deg); }\n /* Der gro\u00dfe Pulsring l\u00e4ge wie ein Fleck \u00fcber Knopf und Text */\n .trail__dot, .phase__status--live i { animation-name: pulseSm; }\n /* Preloader: \u00dcberspringen unter die obere Zeile, sonst liegt der Knopf auf den Koordinaten */\n .loader__skip { top: calc(var(--gutter) * .8 + 58px); }\n /* Men\u00fc: Deko nur unten rechts neben der Adresse, nicht hinter den Men\u00fcpunkten */\n .menu__shape--2, .menu__shape--4 { display: none; }\n .menu__shape--1 { top: auto; bottom: 19%; right: 5%; width: 84px; }\n .menu__shape--3 { top: auto; bottom: 5%; right: 7%; width: 54px; }\n\n .retreat__copy { right: var(--gutter); max-width: none; font-size: 16px; }\n .retreat__tags { display: none; }\n\n .rp--intro { width: 86vw; }\n .rp--img, .rp--tall, .rp--wide { width: 80vw; }\n .rp__img, .rp--tall .rp__img { height: 52svh; }\n .rp--type { width: 80vw; }\n .rp--features { width: 88vw; }\n .feats { grid-template-columns: 1fr 1fr; gap: 10px; }\n .feat { min-height: 0; padding: 16px; gap: 12px; }\n .feat svg { width: 34px; }\n .feat p { font-size: 12px; }\n\n .card { height: 84svh; }\n .card__inner { display: flex; flex-direction: column; gap: 14px; padding: 18px; }\n .card__visual { order: -1; flex: 1 1 auto; min-height: 16svh; }\n .card__text { flex: none; min-height: auto; }\n .card__title { font-size: 28px; margin-bottom: 12px; }\n .card__n { font-size: 34px; }\n .card__text { gap: 12px; }\n .card__top { padding-bottom: 10px; }\n .card__body > p { font-size: 13.5px; line-height: 1.45; }\n .chips { margin-top: 12px; }\n .chips li { padding: 6px 10px; font-size: 10px; }\n .card__visual--charge { padding: 16px 18px; }\n .battery { height: 34px; padding: 5px; gap: 4px; border-radius: 11px; }\n .battery i { border-radius: 4px; }\n .card__visual--badges { grid-template-columns: repeat(3, 1fr); grid-template-rows: 1fr; }\n .stamp { width: 100%; }\n .stamp--b { grid-column: auto; grid-row: auto; }\n .stamp b { font-size: 24px; }\n .stamp span { font-size: 9px; }\n\n .projekt__inner { gap: 14px; }\n .plan__steps { display: none; }\n .projekt__title { font-size: 34px; }\n .plan { grid-template-columns: 1fr; grid-template-rows: 28svh minmax(0, 1fr); gap: 14px; }\n .plan__tag { display: none; }\n .plan__info { align-content: start; }\n .phase__name { font-size: 34px; margin: 8px 0 4px; }\n .phase__facts { margin: 14px 0 12px; }\n .phase__facts div { padding-top: 10px; }\n .phase__facts dd { font-size: 32px; }\n .phase__text { font-size: 13px; line-height: 1.45; }\n .sum { font-size: 54px; margin: 12px 0; }\n\n .lage__veil { background: linear-gradient(180deg, rgba(246, 248, 235, .96) 0%, rgba(246, 248, 235, .6) 34%, rgba(246, 248, 235, 0) 55%); }\n .place { grid-template-columns: 1fr auto; gap: 6px 14px; }\n .place__n { display: none; }\n .place__tag { grid-column: 1; grid-row: 2; text-align: left; padding: 0; font-size: 10px; }\n /* Foto des Ortes quer (4:3) neben dem Namen, gro\u00df genug zum Erkennen */\n .place__thumb { display: block; grid-column: 2; grid-row: 1 / 3; align-self: center; width: 96px; aspect-ratio: 4 / 3; border-radius: 14px; overflow: hidden; background: var(--navy); }\n .place__thumb img { width: 100%; height: 100%; object-fit: cover; }\n .places__credit { margin-top: 20px; font-size: 10.5px; }\n .places__float { display: none; }\n\n .history__foot { flex-direction: column; align-items: flex-start; }\n .history__rail { display: none; }\n\n .news__card { grid-template-columns: 1fr; }\n .news__img { min-height: 42svh; }\n\n .form { grid-template-columns: 1fr; }\n .footer__bar { grid-template-columns: 1fr auto; gap: 18px; padding: 20px var(--gutter) calc(20px + var(--wf-badge, 0px)); font-size: 12px; }\n .footer__col--brand { grid-column: 1 / -1; }\n .footer__col:nth-child(3) { grid-column: 1; }\n .footer__top { grid-column: 2; grid-row: 2 / 4; align-self: center; width: 54px; height: 54px; }\n .footer__word { top: calc(var(--radius) + 13vh); }\n .footer__hint { left: var(--gutter); right: auto; }\n}\n\n/* ==========================================================================\n SCHLANKE HANDY-FASSUNG (html.lite, setzt main.js beim Laden unter 761 px)\n Statt langer gepinnter Scroll-Szenen: kompakte Abschnitte und Wisch-Karussells.\n ========================================================================== */\nhtml.lite .raum__track, html.lite .stack, html.lite .plan__info {\n display: flex; gap: 14px; width: auto; height: auto; padding: 0 var(--gutter);\n overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding: 0 var(--gutter);\n scrollbar-width: none; will-change: auto;\n}\nhtml.lite .raum__track::-webkit-scrollbar, html.lite .stack::-webkit-scrollbar, html.lite .plan__info::-webkit-scrollbar { display: none; }\nhtml.lite .raum__track > *, html.lite .stack > .card, html.lite .plan__info > .phase { flex: 0 0 auto; scroll-snap-align: start; }\n\n/* Garten im Intro: am Handy 5 statt 9 Pflanzen, neu verteilt (Kumpel: \u201eweniger Pflanzen auf dem Handy\u201c, es war\n gedr\u00e4ngt voll). Raus: kleine Tulpe, Vase, Schnecke, zweite Tulpe */\nhtml.lite .garden .plant:nth-child(2), html.lite .garden .plant:nth-child(4), html.lite .garden .plant:nth-child(7), html.lite .garden .plant:nth-child(8) { display: none; }\n/* !important, weil --x/--h im Markup inline stehen */\nhtml.lite .garden .plant:nth-child(1) { --x: -3% !important; --h: 58% !important; }\nhtml.lite .garden .plant:nth-child(3) { --x: 25% !important; --h: 70% !important; }\nhtml.lite .garden .plant:nth-child(5) { --x: 43% !important; --h: 88% !important; }\nhtml.lite .garden .plant:nth-child(6) { --x: 70% !important; --h: 44% !important; }\nhtml.lite .garden .plant:nth-child(9) { --x: 91% !important; --h: 36% !important; }\n\n/* R\u00fcckzugsort ohne Drohnenflug */\nhtml.lite .retreat { display: flex; flex-direction: column; height: auto; overflow: visible; padding: calc(var(--nav-h) + 20px) var(--gutter) 70px; }\nhtml.lite .retreat__title { order: -1; position: static; display: block; padding: 0; text-align: left; font-size: 12.4vw; pointer-events: auto; }\nhtml.lite .retreat__r { color: var(--red); }\nhtml.lite .retreat__media { position: relative; inset: auto; height: 58svh; margin: 24px 0 22px; border-radius: 24px; clip-path: inset(0 round 24px); }\nhtml.lite .retreat__media img { transform: none; }\nhtml.lite .retreat__media::after, html.lite .dive, html.lite .dive__canvas, html.lite .dive__img { display: none; }\nhtml.lite .retreat__copy { position: static; max-width: none; color: var(--navy); font-size: 17px; line-height: 1.45; }\nhtml.lite .retreat__tags { position: static; display: flex; justify-content: flex-start; max-width: none; margin-top: 20px; }\nhtml.lite .retreat__tags .tag { color: var(--navy); background: transparent; border-color: rgba(0, 48, 73, .3); }\n\n/* Raumkonzept als Karussell */\nhtml.lite .raum { height: auto; overflow: visible; padding: calc(var(--nav-h) + 10px) 0 64px; }\nhtml.lite .raum > .rp--intro { width: auto; padding: 0 var(--gutter); margin-bottom: 28px; }\nhtml.lite .raum__big { margin: 18px 0 20px; }\nhtml.lite .raum__hint { margin-top: 22px; }\nhtml.lite .raum__track { align-items: stretch; }\nhtml.lite .rp--img, html.lite .rp--tall, html.lite .rp--wide, html.lite .rp--type, html.lite .rp--features { width: 82vw; }\nhtml.lite .rp__img, html.lite .rp--tall .rp__img { height: 46svh; }\nhtml.lite .rp--type { display: flex; flex-direction: column; justify-content: center; padding: 24px 16px; border-radius: 24px; background: var(--cream-2); }\nhtml.lite .raum__progress { position: relative; left: auto; right: auto; bottom: auto; margin: 26px var(--gutter) 0; }\n\n/* Nachhaltigkeit: Kreis ohne Pin, Karten als Karussell */\nhtml.lite .green__pin { height: 92svh; }\nhtml.lite .green__text { padding: 64px var(--gutter) 40px; }\nhtml.lite .green__text p { font-size: 25px; }\nhtml.lite .stack { padding-bottom: 64px; }\n/* Karten so hoch wie ihr Inhalt (feste 72svh waren in Safari mit Leisten zu knapp: Zahl und Batterie wurden\n abgeschnitten); im Karussell werden alle so hoch wie die h\u00f6chste, das Bild f\u00fcllt den Rest */\nhtml.lite .card { width: 84vw; height: auto; margin: 0; }\nhtml.lite .card__inner { box-shadow: none; }\nhtml.lite .card__visual { flex: 1 0 auto; min-height: 150px; }\nhtml.lite .card__visual--img { height: 26svh; }\nhtml.lite .card__visual--sun { min-height: 190px; }\nhtml.lite .card__visual--charge { gap: 8px; }\nhtml.lite .charge__num { font-size: 40px; }\nhtml.lite .charge__label { font-size: 11px; }\n\n/* Das Projekt: Plan oben, Bauabschnitte als Karussell */\nhtml.lite .projekt { height: auto; overflow: visible; }\nhtml.lite .projekt__inner { display: block; height: auto; padding: calc(var(--nav-h) + 10px) 0 60px; }\nhtml.lite .projekt__head { display: block; padding: 0 var(--gutter); }\nhtml.lite .plan__steps { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 16px; }\nhtml.lite .plan__steps li { padding: 7px 12px; font-size: 11px; cursor: pointer; }\nhtml.lite .plan { display: block; margin-top: 18px; }\nhtml.lite .plan__visual { height: 32svh; margin: 0 var(--gutter) 16px; }\nhtml.lite .phase { visibility: visible; grid-area: auto; width: 84vw; padding: 18px; border-radius: 20px; background: rgba(0, 48, 73, .06); }\n\n/* Lage: auf dem Handy reicht der Marker, der Stadtteil-Schriftzug w\u00fcrde die Karten-Beschriftung \u00fcberdecken */\nhtml.lite .lage__labels { display: none; }\n/* Lage am Handy: kein Pin, Titel \u00fcber der Karte, darunter die Minuten-Liste und die Quellenangabe */\nhtml.lite .lage { height: auto; overflow: visible; display: flex; flex-direction: column; }\nhtml.lite .lage__copy { position: static; order: -1; padding: calc(var(--nav-h) + 8px) var(--gutter) 18px; }\nhtml.lite .lage__map { position: relative; height: 84svh; overflow: hidden; }\nhtml.lite .lage__veil { display: none; }\nhtml.lite .lage__pin { font-size: 14px; gap: 7px; padding: 8px 12px 8px 10px; bottom: 16px; }\nhtml.lite .lage__pin svg { width: 16px; }\nhtml.lite .lage__ring { left: -30px; top: -30px; width: 60px; height: 60px; }\nhtml.lite .lage__dest-in { padding: 7px 10px 8px; border-radius: 11px; gap: 3px; }\nhtml.lite .lage__dest b { font-size: 14px; }\nhtml.lite .lage__dest span { font-size: 11px; }\nhtml.lite .lage__credit { position: static; padding: 14px var(--gutter) 34px; }\nhtml.lite .lage__list { position: static; width: auto; height: auto; overflow: visible; clip: auto; clip-path: none; white-space: normal; display: grid; gap: 0; padding: 22px var(--gutter) 0; }\nhtml.lite .lage__list li { display: grid; grid-template-columns: 1fr auto auto; align-items: baseline; gap: 14px; padding: 13px 0 12px; border-top: 1px solid rgba(0, 48, 73, .14); }\nhtml.lite .lage__list li:last-child { border-bottom: 1px solid rgba(0, 48, 73, .14); }\nhtml.lite .lage__list b { font-family: var(--f-display); font-weight: 400; font-size: 19px; line-height: 1; text-transform: uppercase; padding-top: .14em; }\nhtml.lite .lage__list span { font-size: 12.5px; font-weight: 600; color: rgba(0, 48, 73, .78); text-align: right; }\nhtml.lite .lage__list span small { display: block; font-size: 10px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: rgba(0, 48, 73, .5); }\nhtml.lite .lage__list-head { font-size: 11px; font-weight: 600; letter-spacing: .16em; text-transform: uppercase; color: rgba(0, 48, 73, .55); padding: 0 0 10px; }\n\n/* Geschichte als Zeitleiste */\nhtml.lite .history { height: auto; }\nhtml.lite .history__inner { display: block; height: auto; padding: calc(var(--nav-h) + 10px) var(--gutter) 70px; }\nhtml.lite .history__big, html.lite .history__dots, html.lite .history__rail { display: none; }\nhtml.lite .history__foot { display: block; margin-top: 26px; }\nhtml.lite .history__steps { display: grid; gap: 30px; max-width: none; }\nhtml.lite .hstep { grid-area: auto; opacity: 1; transform: none; transition: none; }\nhtml.lite .hstep::before { content: attr(data-word); display: block; margin-bottom: 8px; font-family: var(--f-display); font-size: 15vw; line-height: .9; text-transform: uppercase; color: var(--cream); }\nhtml.lite .hpic--inline { margin: 6px 0 18px; }\nhtml.lite .hpic--inline .hpic__img { border-radius: 14px; }\n.history__steps { list-style: none; margin: 0; padding: 0; }\n/* Geschichte als Zeitstrahl (?geschichte=zeitstrahl): Stationen auf einer Linie, die durchs Bild f\u00e4hrt */\n.hline { position: absolute; inset: 0; pointer-events: none; --gap: 50vw; --x0: calc(var(--gutter) + 5vw); }\n.hline__track { position: absolute; left: 0; top: 0; height: 100%; width: calc(var(--x0) + (var(--n) - 1) * var(--gap) + 100vw); will-change: transform; }\n.hline__base, .hline__fill { position: absolute; left: var(--x0); top: 60%; width: calc((var(--n) - 1) * var(--gap)); height: 2px; background: rgba(250, 245, 237, .16); }\n.hline__fill { background: var(--red); transform-origin: 0 50%; transform: scaleX(0); }\n.hline__st { position: absolute; left: calc(var(--x0) + var(--i) * var(--gap)); top: 60%; width: 0; }\n.hline__year { position: absolute; left: -.04em; bottom: 30px; font-family: var(--f-display); font-size: clamp(80px, 13vw, 250px); line-height: .9; white-space: nowrap; text-transform: uppercase; color: var(--cream); opacity: .14; transform: translateY(12px); transition: opacity .6s ease, transform .8s var(--ease-out), color .6s ease; }\n.hline__dot { position: absolute; left: -9px; top: -8px; width: 18px; height: 18px; border-radius: 50%; background: var(--navy); border: 2px solid rgba(250, 245, 237, .45); transition: background .4s ease, border-color .4s ease, box-shadow .6s ease; }\n.hline__year--long { font-size: clamp(64px, 8.4vw, 160px); }\n.hline__txt { position: absolute; left: 0; top: 40px; width: min(470px, 38vw); font-size: clamp(17px, 1.35vw, 23px); line-height: 1.4; font-weight: 500; color: var(--cream); opacity: .14; transform: translateY(12px); transition: opacity .6s ease, transform .8s var(--ease-out); }\n.hline__txt b { display: block; margin-bottom: 10px; font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: var(--blue); }\n.hline__st.is-past .hline__year { opacity: .3; transform: none; }\n.hline__st.is-past .hline__dot { background: var(--red); border-color: var(--red); }\n.hline__st.is-on .hline__year, .hline__st.is-on .hline__txt { opacity: 1; transform: none; }\n.hline__st.is-on .hline__dot { background: var(--red); border-color: var(--red); box-shadow: 0 0 0 9px rgba(193, 17, 31, .2); }\n/* Foto \u00fcber der Jahreszahl: so hoch, wie \u00fcber der Zahl Platz ist; die Zahl ragt zur H\u00e4lfte hinein */\n.hline { --yfs: clamp(80px, 13vw, 250px); --pich: clamp(140px, calc(60svh - 110px - .5 * var(--yfs) - var(--nav-h)), 44vh); }\n.hline__year { z-index: 2; }\n.hline__pic { position: absolute; left: 0; bottom: calc(30px + .5 * var(--yfs)); z-index: 1; height: var(--pich); aspect-ratio: 4 / 3; opacity: .12; transform: translateY(14px); transition: opacity .6s ease, transform .8s var(--ease-out); }\n.hline__st--long .hline__pic { bottom: calc(30px + .5 * clamp(64px, 8.4vw, 160px)); }\n.hline__pic .hpic__img { height: 100%; }\n.hline__pic .hpic__img::after { content: \"\"; position: absolute; inset: 0; background: linear-gradient(0deg, rgba(0, 36, 55, .72) 0%, rgba(0, 36, 55, 0) 42%); }\n.hline__st.is-past .hline__pic { opacity: .26; transform: none; }\n.hline__st.is-on .hline__pic { opacity: 1; transform: none; }\n.hline__credit { display: block; margin-top: 12px; font-size: 11px; line-height: 1.4; font-weight: 400; color: rgba(250, 245, 237, .55); }\n.hline__credit a { pointer-events: auto; }\nhtml[data-history=\"zeitstrahl\"] .history__big, html[data-history=\"zeitstrahl\"] .history__rail, html[data-history=\"zeitstrahl\"] .history__dots { display: none; }\nhtml[data-history=\"zeitstrahl\"] .history__steps { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }\n/* Handy: senkrechte Linie links, Stationen untereinander */\nhtml.lite[data-history=\"zeitstrahl\"] .hline { position: relative; inset: auto; margin-top: 30px; --x0: 0px; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__track { position: relative; width: auto; height: auto; }\nhtml.lite[data-history=\"zeitstrahl\"] :is(.hline__base, .hline__fill) { left: 8px; top: 12px; bottom: 40px; width: 2px; height: auto; transform-origin: 50% 0; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__fill { transform: scaleY(0); }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__st { position: relative; left: 0; top: 0; width: auto; padding: 0 0 40px 40px; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__year { position: static; display: block; font-size: 15vw; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__year--long { font-size: 12vw; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__dot { left: 0; top: 6px; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__txt { position: static; width: auto; margin-top: 12px; font-size: 16px; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__st.is-past .hline__txt { opacity: 1; transform: none; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__pic { position: relative; left: auto; bottom: auto; height: auto; width: 100%; margin-top: 14px; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__pic .hpic__img { height: auto; border-radius: 14px; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__pic .hpic__img::after { display: none; }\nhtml.lite[data-history=\"zeitstrahl\"] .hline__st.is-past .hline__pic { opacity: 1; }\n/* Geschichte auf der Karte (?geschichte=karte): links Jahr und Text, rechts die Vektorkarte im dunklen Thema */\n.hmap { position: absolute; inset: 0; }\n.hmap__view { position: absolute; right: 0; top: 0; width: 64%; height: 100%; }\n.hmap__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n.hmap__view::after { content: \"\"; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(90deg, var(--navy) 0%, rgba(0, 48, 73, 0) 30%), linear-gradient(0deg, var(--navy) 0%, rgba(0, 48, 73, 0) 16%), linear-gradient(180deg, var(--navy) 0%, rgba(0, 48, 73, 0) 18%); }\n.hmap__pin { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2; will-change: transform, opacity; }\n.hmap__pin .lage__ring { border-color: var(--red); }\n.hmap__tag { position: absolute; left: 18px; top: -46px; padding: 9px 14px 8px; border-radius: 99px; background: var(--cream); color: var(--navy); white-space: nowrap; font-family: var(--f-display); font-size: clamp(15px, 1.2vw, 20px); line-height: 1; text-transform: uppercase; box-shadow: 0 10px 30px rgba(0, 0, 0, .3); }\n.hmap__copy { position: absolute; left: var(--gutter); top: 30%; width: 38%; }\n.hmap__st { position: absolute; left: 0; top: 0; width: 100%; opacity: 0; transform: translateY(18px); transition: opacity .55s ease, transform .8s var(--ease-out); }\n.hmap__st.is-on { opacity: 1; transform: none; }\n.hmap__year { font-family: var(--f-display); font-size: clamp(72px, 9.4vw, 180px); line-height: .9; text-transform: uppercase; white-space: nowrap; }\n.hmap__year--long { font-size: clamp(56px, 6.4vw, 124px); }\n.hmap__txt { margin-top: 24px; max-width: 460px; font-size: clamp(17px, 1.35vw, 22px); line-height: 1.4; font-weight: 500; }\n.hmap__txt b { display: block; margin-bottom: 10px; font-size: 12px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; color: var(--blue); }\n.hmap__credit { position: absolute; right: var(--gutter); bottom: 12px; z-index: 3; font-size: 10.5px; color: rgba(250, 245, 237, .55); }\n/* OSM-Hinweis ist Pflicht: direkt \u00fcber das Webflow-Badge, b\u00fcndig mit ihm (links daneben stie\u00df er auf Tablets an die\n \u00dcberspringen-Pille; Handy: steht im Fluss, s. u.) */\nhtml:has(.w-webflow-badge) .hmap__credit { right: 12px; bottom: 46px; }\n.hmap__credit a { text-decoration: underline; text-underline-offset: 2px; }\n/* Luftbild-Fenster bei 1909 und 2030: Stempel \u201eLuftbild 1928\u201c sitzt unten auf dem Ring (Lage per transform, Deckkraft aus\n historyMap in main.js); solange das Fenster offen ist, pulsiert die Ortsmarke nicht (Ringe laufen per Animation \u00fcber\n die Deckkraft, deshalb ausblenden statt opacity) */\n.hmap__lb { position: absolute; left: 0; top: 0; z-index: 3; margin: 0; padding: 8px 13px 7px; border: 1px solid rgba(250, 245, 237, .32); border-radius: 99px; background: rgba(0, 48, 73, .62); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); color: rgba(250, 245, 237, .78); font-size: 10.5px; font-weight: 700; letter-spacing: .16em; line-height: 1; text-transform: uppercase; white-space: nowrap; opacity: 0; pointer-events: none; will-change: transform, opacity; }\n.hmap__lb:empty { display: none; }\n.hmap__pin.is-lens .lage__ring { visibility: hidden; }\n.hmap__lb b { margin-left: 5px; font-family: var(--f-display); font-size: 16px; font-weight: 400; letter-spacing: .02em; color: var(--cream); }\n/* Foto als Abzug unten rechts in der Karte */\n.hmap__pics { position: absolute; right: calc(var(--gutter) + 3.5vw); bottom: 76px; z-index: 3; width: clamp(220px, 21vw, 380px); }\n.hmap__pics::before { content: \"\"; display: block; padding-top: calc(75% + 62px); }\n.hmap__pic { position: absolute; left: 0; top: 0; width: 100%; padding: 8px 8px 0; background: var(--cream); box-shadow: 0 26px 54px rgba(0, 8, 16, .5); opacity: 0; transform: translateY(16px) rotate(2.5deg); transition: opacity .5s ease, transform .9s var(--ease-out); }\n.hmap__pic.is-on { opacity: 1; transform: rotate(-1.5deg); }\n.hmap__pic .hpic__img { background: #e9e1d0; }\n.hmap__pic .hpic__cap { min-height: 54px; margin: 0; padding: 8px 2px 10px; color: rgba(0, 48, 73, .62); }\n.hmap__pic .hpic__what { color: var(--navy); }\n.hmap__pic a { text-decoration-color: rgba(0, 48, 73, .3); }\n.hmap__pic a:hover { color: var(--navy); }\nhtml[data-history=\"karte\"] .history__big, html[data-history=\"karte\"] .history__rail, html[data-history=\"karte\"] .history__dots, html[data-history=\"karte\"] .history__shape { display: none; }\nhtml[data-history=\"karte\"] .history__steps { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); }\n/* \u00dcberschrift \u00fcber die Karte legen: unter ~1000 px Breite beginnt die Karte (64 %) links von ihrem Ende und schnitt sie ab (\u201e\u2026GESCHICH\u201c) */\nhtml:not(.lite)[data-history=\"karte\"] .history__inner > .kicker { position: relative; z-index: 4; }\n/* Handy: Karte bleibt oben stehen, die Stationen scrollen darunter durch */\nhtml.lite[data-history=\"karte\"] .history { overflow: clip; }\n@supports not (overflow: clip) { html.lite[data-history=\"karte\"] .history { overflow: visible; } } /* iOS 15: sonst klebte die Karte nicht */\nhtml.lite[data-history=\"karte\"] .hmap { position: relative; inset: auto; margin-top: 18px; }\nhtml.lite[data-history=\"karte\"] .hmap__view { position: sticky; top: calc(var(--nav-h) + 6px); z-index: 2; width: auto; height: 38svh; border-radius: 16px; overflow: hidden; box-shadow: 0 16px 40px rgba(0, 0, 0, .35); }\nhtml.lite[data-history=\"karte\"] .hmap__view::after { display: block; background: linear-gradient(0deg, rgba(0, 36, 55, .85) 0%, rgba(0, 36, 55, 0) 38%); }\n/* Vorhang \u00fcber der stehenden Karte: klebt oben, liegt unter der Karte und \u00fcber den Stationen. Ohne ihn tauchten Text und\n Fotos, die unter der Karte durchgelaufen waren, im Spalt dar\u00fcber (Platz f\u00fcr die Nav) wieder auf. Reicht bis hinter\n die runden Ecken der Karte (+16px), sonst blitzt dort Inhalt durch. */\nhtml.lite[data-history=\"karte\"] .hmap::before { content: \"\"; position: sticky; top: 0; z-index: 1; display: block; height: calc(var(--nav-h) + 22px); margin-bottom: calc(-1 * (var(--nav-h) + 22px)); background: var(--navy); pointer-events: none; }\n.hmap__now { display: none; }\n/* Jahreszahl in der Karte: Deckkraft = Anteil der gro\u00dfen Zahl der aktiven Station, der schon unter der Karte liegt\n (Inline-Stil aus historyMap, main.js); die kurze \u00dcberblendung gl\u00e4ttet Scroll-Schritte und den Wechsel der Station */\nhtml.lite[data-history=\"karte\"] .hmap__now { display: block; position: absolute; left: 14px; bottom: 8px; z-index: 3; font-family: var(--f-display); font-size: 12vw; line-height: 1; text-transform: uppercase; color: var(--cream); opacity: 0; transform: translateY(10px); transition: opacity .2s linear, transform .2s linear; }\nhtml.lite[data-history=\"karte\"] .hmap__tag { left: 12px; top: -38px; padding: 7px 11px 6px; font-size: 13px; }\nhtml.lite[data-history=\"karte\"] .hmap__lb { padding: 6px 10px 5px; font-size: 9px; }\nhtml.lite[data-history=\"karte\"] .hmap__lb b { font-size: 13px; }\nhtml.lite[data-history=\"karte\"] .hmap__copy { position: relative; left: 0; top: 0; width: auto; padding-top: 10px; }\nhtml.lite[data-history=\"karte\"] .hmap__st { position: relative; min-height: 46svh; padding: 26px 0 10px; opacity: .3; transform: none; }\nhtml.lite[data-history=\"karte\"] .hmap__st.is-on { opacity: 1; }\nhtml.lite[data-history=\"karte\"] .hmap__year { font-size: 15vw; }\nhtml.lite[data-history=\"karte\"] .hmap__year--long { font-size: 12vw; }\nhtml.lite[data-history=\"karte\"] .hmap__txt { margin-top: 12px; font-size: 16px; }\nhtml.lite[data-history=\"karte\"] .hmap__credit { position: static; padding: 6px 0 0; text-align: left; }\nhtml.lite[data-history=\"karte\"] .hmap__st .hpic--inline { margin: 16px 0 0; }\n\n/* Film: Titel \u00fcber der Hochformat-Karte (4:5), Knopf darunter */\nhtml.lite .film { display: flex; flex-direction: column; align-items: flex-start; height: auto; overflow: visible; padding: calc(var(--nav-h) + 10px) var(--gutter) 70px; color: var(--navy); }\nhtml.lite .film__copy { display: contents; }\nhtml.lite .film .kicker { order: 1; color: var(--navy); }\nhtml.lite .film__title { order: 2; margin: 16px 0 22px; font-size: 12.4vw; text-shadow: none; }\nhtml.lite .film__media { order: 3; position: relative; inset: auto; align-self: stretch; aspect-ratio: 4 / 5; border-radius: 24px; clip-path: inset(0 round 24px); }\nhtml.lite .film__btn { order: 4; margin-top: 22px; color: var(--navy); background: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; }\nhtml.lite .film__btn .btn__fill { background: var(--navy); }\n@media (hover: hover) { html.lite .film__btn:hover { color: var(--cream); } }\nhtml.lite .film__pause { right: 14px; bottom: 14px; width: 44px; height: 44px; }\n\n/* Ruhige Fassung f\u00fcr \u201eBewegung reduzieren\u201c. Derzeit abgeschaltet: Die Einstellung wird auf Wunsch ignoriert\n (REDUCED = false in main.js), html.reduced wird also nie gesetzt. */\nhtml.reduced :is(.grain, .hero__badge-ring, .hero__badge-arrow, .sun__flower, .orbit, .raum__hint svg, .lage__ring, .hero__sky img, .dive__rec b, .nav__sound.is-on .eq i),\nhtml.reduced .nav__sound.is-hint::after { animation: none !important; }\n/* Laufb\u00e4nder ohne Bewegung: alle W\u00f6rter zeigen statt eines stehenden Ausschnitts \u2013 untereinander, Text umbrochen */\nhtml.reduced .bands { height: auto; display: grid; gap: clamp(18px, 3vw, 40px); padding: clamp(48px, 7vw, 100px) 0; }\nhtml.reduced .band { position: relative; top: auto; left: auto; right: auto; margin: 0 -8%; }\nhtml.reduced .band--b { transform: rotate(2deg); }\nhtml.reduced .band--a { transform: rotate(-2.4deg); }\nhtml.reduced .band__track { width: auto; justify-content: center; }\nhtml.reduced .band__group + .band__group { display: none; }\nhtml.reduced .band__group { flex-wrap: wrap; justify-content: center; row-gap: .2em; padding: 0 12%; white-space: normal; text-align: center; }\nhtml.reduced .band__group svg:last-child { display: none; } /* Trenner am Ende gibt es nur f\u00fcr die Endlosschleife */\n@media (max-width: 760px) {\n html.reduced .band__group { font-size: 24px; column-gap: 14px; padding: 0 9%; }\n}\n\n\n/* ==========================================================================\n WEBGL, DROHNENFLUG, WURZELN, BL\u00dcTEN\n ========================================================================== */\n.gl-canvas { position: absolute; inset: 0; z-index: 2; width: 100%; height: 100%; opacity: 0; pointer-events: none; }\n\n.dive__canvas { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; opacity: 0; }\n.dive__img { position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; object-fit: cover; opacity: 0; }\n.dive { position: absolute; inset: 0; z-index: 5; pointer-events: none; color: var(--cream); visibility: hidden; opacity: 0; }\n.dive__frame i { position: absolute; width: clamp(26px, 3vw, 44px); height: clamp(26px, 3vw, 44px); border: 2px solid rgba(250, 245, 237, .9); }\n.dive__frame i:nth-child(1) { left: var(--gutter); top: calc(var(--nav-h) + 10px); border-right: 0; border-bottom: 0; }\n.dive__frame i:nth-child(2) { right: var(--gutter); top: calc(var(--nav-h) + 10px); border-left: 0; border-bottom: 0; }\n.dive__frame i:nth-child(3) { left: var(--gutter); bottom: var(--gutter); border-right: 0; border-top: 0; }\n.dive__frame i:nth-child(4) { right: var(--gutter); bottom: var(--gutter); border-left: 0; border-top: 0; }\n.dive__cross { position: absolute; left: 50%; top: 50%; width: 54px; height: 54px; transform: translate(-50%, -50%); opacity: .85; }\n.dive__cross::before, .dive__cross::after { content: \"\"; position: absolute; background: var(--cream); }\n.dive__cross::before { left: 50%; top: 0; bottom: 0; width: 1.5px; transform: translateX(-50%); }\n.dive__cross::after { top: 50%; left: 0; right: 0; height: 1.5px; transform: translateY(-50%); }\n.dive__top, .dive__bottom { position: absolute; display: flex; align-items: center; gap: 26px; font-size: 11px; font-weight: 700; letter-spacing: .2em; text-transform: uppercase; text-shadow: 0 1px 10px rgba(0, 20, 35, .55); }\n.dive__top { left: calc(var(--gutter) + clamp(40px, 4vw, 62px)); top: calc(var(--nav-h) + 20px); }\n.dive__bottom { right: calc(var(--gutter) + clamp(40px, 4vw, 62px)); bottom: calc(var(--gutter) + 10px); }\n.dive__rec b { display: inline-block; width: 9px; height: 9px; margin-right: 8px; border-radius: 50%; background: var(--red); animation: rec 1.1s step-end infinite; }\n@keyframes rec { 50% { opacity: 0; } }\n.dive__zoom b { font-family: var(--f-display); font-size: 22px; font-weight: 400; letter-spacing: 0; margin: 0 2px; }\n.dive__meter { position: relative; width: clamp(80px, 12vw, 180px); height: 2px; background: rgba(250, 245, 237, .3); }\n.dive__meter i { position: absolute; inset: 0; background: var(--cream); transform-origin: 0 50%; transform: scaleX(0); }\n.dive__caption { position: absolute; left: var(--gutter); right: var(--gutter); bottom: 16vh; height: 1em; font-family: var(--f-display); font-size: clamp(40px, 7.4vw, 140px); line-height: 1; text-transform: uppercase; text-shadow: 0 .04em .3em rgba(0, 20, 35, .5); }\n.dive__caption span { position: absolute; left: 0; bottom: 0; white-space: nowrap; opacity: 0; }\n\n.roots { position: absolute; left: 0; top: 0; z-index: 0; pointer-events: none; overflow: visible; }\n.roots path { fill: none; stroke: var(--root-c, #003049); stroke-linecap: round; stroke-linejoin: round; }\n.roots .r-main { stroke-width: var(--root-w, 2.4px); }\n.roots .r-branch { stroke-width: calc(var(--root-w, 2.4px) * .62); }\n.roots .r-node { fill: var(--root-c, #003049); }\n.intro, .retreat, .raum, .projekt, .lage, .kiez, .news, .trail, .film { --root-c: #003049; }\n.film > .roots { z-index: 3; } /* \u00fcber der Schrift-Maske (Desktop), unter Titel und Kn\u00f6pfen */\n.stats, .green, .history, .kontakt { --root-c: #FAF5ED; }\n.bands { --root-c: #669BBC; }\n\n.petals { position: fixed; inset: 0; z-index: 97; width: 100%; height: 100%; pointer-events: none; }\n\n/* \u00dcberg\u00e4nge als Welle (main.js initEdges, wie in v32): Die Form ragt aus einem Element mit H\u00f6he 0 nach oben \u00fcber\n den vorigen Abschnitt; der Schatten wird unten abgeschnitten. .edge__root = St\u00fcck Wurzel in der Welle */\n.edge { position: relative; z-index: 4; height: 0; pointer-events: none; --edge-h: clamp(44px, 6vw, 110px); }\n.edge svg { position: absolute; left: 0; bottom: -2px; width: 100%; height: calc(var(--edge-h) + 2px); overflow: visible; filter: drop-shadow(0 -8px 18px rgba(0, 20, 35, .13)); clip-path: inset(-160px 0 0 0); }\n.edge path { fill: var(--edge-c); }\n.edge .edge__root { fill: none; stroke-linecap: butt; }\n\n@media (max-width: 760px) {\n .nav__sound { padding: 12px 13px; }\n .nav__sound-txt { display: none; }\n .dive__caption { font-size: 10.5vw; bottom: 18vh; }\n .dive__caption span { white-space: normal; }\n .dive__top span:last-child, .dive__meter { display: none; }\n .roots { --root-w: 1.6px; }\n}\n\n/* ==========================================================================\n NIEDRIGE QUERFORMATE (Handy quer: ab 761 px breit, also Desktop-Fassung mit Pins, aber nur 360\u2013520 px hoch)\n \u00dcberschriften, Pillen und Karten stie\u00dfen dort aneinander oder lagen unter dem Bildrand (v37)\n ========================================================================== */\n@media (min-width: 761px) and (max-height: 520px) {\n /* Nav schmaler: 84 px w\u00e4ren bei 360 px H\u00f6he fast ein Viertel des Bildes */\n :root { --nav-h: 64px; }\n .nav__inner { padding-top: 10px; padding-bottom: 10px; }\n /* Men\u00fc: zweispaltig und nach der H\u00f6he begrenzt; einspaltig lagen vier der sechs Punkte oberhalb des Bildschirms */\n .menu__panel { grid-template-columns: 1.4fr 1fr; align-items: center; align-content: center; gap: 28px; padding-top: calc(var(--nav-h) + 6px); padding-bottom: 14px; }\n .menu__link { font-size: min(6vw, 8.6svh); }\n .menu__aside { gap: 12px; font-size: 13px; padding-bottom: 0; }\n /* \u00dcberspringen-Pille nur als Ring mit Pfeil: mit Text lag sie auf Inhalten */\n .skip { gap: 0; padding: 6px; }\n .skip__txt { display: none; }\n /* Raumkonzepte: Ausstattung in einer Reihe statt 2 \u00d7 2 (die untere Reihe war abgeschnitten) */\n .rp--features { width: min(96vw, 1060px); }\n .rp__ftitle { font-size: 30px; }\n .feats { grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 14px; }\n .feat { min-height: 0; gap: 10px; padding: 14px; }\n .feat svg { width: 30px; }\n .feat h4 { font-size: 19px; }\n .feat p { margin-top: 6px; font-size: 12px; line-height: 1.4; }\n /* Nachhaltigkeit: Titel nach der H\u00f6he begrenzen, die Unterzeile lag sonst unter dem Bildrand */\n .green__title { font-size: min(9.4vw, 15svh); margin: 12px auto 14px; }\n .green__sub { font-size: 16px; }\n /* Karten-Stapel: so hoch, dass auch die vierte (am tiefsten angeheftete) Karte ganz ins Bild passt */\n .card { height: calc(89svh - 58px); }\n .card__inner { gap: 20px; padding: 16px 20px; }\n .card__text { gap: 8px; }\n .card__top { padding-bottom: 8px; }\n .card__title { font-size: 24px; margin-bottom: 8px; }\n .card__body > p { font-size: 12.5px; line-height: 1.4; }\n .chips { display: none; }\n /* Projekt: Die Pillen der Bauabschnitte lagen \u00fcber dem Namen des Abschnitts (auf dem Desktop nur Anzeige),\n die Angaben rechts ragten nach oben in den Kopf */\n .plan__steps, .plan__tag { display: none; }\n .projekt__inner { gap: 10px; }\n .projekt__title { font-size: 34px; margin-top: 8px; }\n .plan { grid-template-columns: 1.25fr 1fr; gap: 22px; }\n .plan__info { align-content: start; }\n .phase__status { font-size: 11px; }\n .phase__name { font-size: 28px; margin: 6px 0 2px; }\n .phase__facts { margin: 8px 0 6px; }\n .phase__facts div { padding-top: 6px; }\n .phase__facts dd { font-size: 28px; margin-top: 2px; }\n .phase__text { font-size: 12.5px; line-height: 1.4; }\n .sum { font-size: 38px; margin: 8px 0 6px; }\n /* Geschichte (Karte): Die gro\u00dfe Jahreszahl lag \u00fcber der \u00dcberschrift, das Foto unter den Kn\u00f6pfen der Nav */\n .hmap__copy { top: calc(var(--nav-h) + 40px); }\n .hmap__year { font-size: min(9.4vw, 17svh); }\n .hmap__year--long { font-size: min(6.4vw, 13svh); }\n .hmap__txt { margin-top: 10px; font-size: 15px; }\n .hmap__pics { width: 170px; bottom: 56px; }\n /* Lage: kleinere Schilder, sie lagen auf der kleinen Karte \u00fcbereinander */\n .lage__dest-in { gap: 3px; padding: 6px 10px 7px; border-radius: 10px; }\n .lage__dest b { font-size: 14px; }\n .lage__dest span { font-size: 10.5px; }\n .lage__dest--minor b { font-size: 12px; }\n .lage__dest--minor span { font-size: 10px; }\n}\n\n/* Haarlinie an hellen Abschnittsgrenzen (Safari, iPhone): Die Grenzen liegen auf Bruchteilen von Pixeln (z. B. 2887,25 px\n zwischen Laufb\u00e4ndern und R\u00fcckzugsort), die letzte Ger\u00e4tezeile war nur teilweise bemalt und der Navy-Hintergrund der\n Seite schien als feine dunkle Linie durch. Helle Abschnitte, die an helle grenzen, decken die Zeile \u00fcber sich mit ihrer\n eigenen Farbe ab (Schatten nach oben, verschiebt nichts). */\n.retreat, .raum, .kiez, .news { box-shadow: 0 -2px 0 var(--cream); }\n.lage { box-shadow: 0 -2px 0 var(--map); }\n","js":[{"name":"gl.js","code":"/* ==========================================================================\n KARLS LIVING \u2014 WebGL\n 1) Bild-FX: Bilder biegen sich beim Scrollen, Wellen beim Hover, RGB-Split bei Tempo.\n Ein gemeinsamer WebGL-Kontext rendert offscreen und kopiert in 2D-Canvas-Overlays,\n damit Clip-Paths, Radien und DOM-Ebenen erhalten bleiben. In Ruhe zeigt das\n Original- (volle Qualit\u00e4t), das Canvas wird nur w\u00e4hrend der Bewegung eingeblendet.\n 2) Drohnenflug: scroll-gesteuerter Zoom durch mehrere Renderings mit Zoom-Blur.\n ========================================================================== */\n(() => {\n 'use strict';\n const KL = (window.KL = window.KL || {});\n\n const VERT = `\n attribute vec2 aPos;\n varying vec2 vUv;\n void main() { vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }\n `;\n\n const FX_FRAG = `\n precision highp float;\n varying vec2 vUv;\n uniform sampler2D uImage;\n uniform vec2 uRes; // Canvas in px\n uniform vec4 uRect; // Bild-Box im Canvas (x, y von oben links, w, h) in px\n uniform vec2 uTexSize;\n uniform float uVel; // -1..1 Scroll-Tempo\n uniform float uHover; // 0..1\n uniform vec2 uMouse; // 0..1, oben links\n uniform float uTime;\n uniform float uHoriz; // 1 = horizontale Bewegung\n\n vec2 coverUv(vec2 p) {\n vec2 local = (p - uRect.xy) / uRect.zw;\n float boxR = uRect.z / uRect.w;\n float texR = uTexSize.x / uTexSize.y;\n vec2 s = boxR > texR ? vec2(1.0, texR / boxR) : vec2(boxR / texR, 1.0);\n return (local - 0.5) * s + 0.5;\n }\n\n void main() {\n vec2 uv = vec2(vUv.x, 1.0 - vUv.y);\n vec2 p = uv * uRes;\n vec2 c = uv - 0.5;\n\n // Tuch-Biegung: die Mitte h\u00e4ngt der Bewegung hinterher\n p.y -= (1.0 - 4.0 * c.x * c.x) * uVel * (1.0 - uHoriz) * 0.07 * uRes.y;\n p.x -= (1.0 - 4.0 * c.y * c.y) * uVel * uHoriz * 0.05 * uRes.x;\n\n // Wellen um den Cursor + leichter Zoom\n vec2 m = uMouse * uRes;\n vec2 dm = p - m;\n float d = length(dm) / uRes.y;\n float wave = sin(d * 42.0 - uTime * 6.0) * exp(-d * 6.0) * uHover;\n p += normalize(dm + 0.0001) * wave * 0.012 * uRes.y;\n p = m + (p - m) * (1.0 - 0.06 * uHover);\n\n // RGB-Split\n vec2 dir = mix(vec2(0.0, 1.0), vec2(1.0, 0.0), uHoriz);\n float split = abs(uVel) * 0.009 * uRes.y + uHover * 0.0025 * uRes.y;\n vec4 g = texture2D(uImage, coverUv(p));\n float r = texture2D(uImage, coverUv(p + dir * split)).r;\n float b = texture2D(uImage, coverUv(p - dir * split)).b;\n gl_FragColor = vec4(r, g.g, b, 1.0);\n }\n `;\n\n const DIVE_FRAG = `\n precision highp float;\n varying vec2 vUv;\n uniform sampler2D uA;\n uniform sampler2D uB;\n uniform vec2 uSizeA;\n uniform vec2 uSizeB;\n uniform vec2 uRes;\n uniform float uZoomA;\n uniform float uZoomB;\n uniform vec2 uFocusA;\n uniform vec2 uFocusB;\n uniform float uAimA;\n uniform float uAimB;\n uniform float uMix;\n uniform float uBlur;\n\n vec2 cover(vec2 uv, vec2 size) {\n float sr = uRes.x / uRes.y, tr = size.x / size.y;\n vec2 s = sr > tr ? vec2(1.0, tr / sr) : vec2(sr / tr, 1.0);\n return (uv - 0.5) * s + 0.5;\n }\n // Zoom auf einen Fokuspunkt, der dabei zur Bildmitte wandert\n vec2 dive(vec2 uv, vec2 size, float zoom, vec2 focus, float aim) {\n vec2 c = cover(uv, size);\n vec2 f = mix(focus, vec2(0.5), aim);\n return focus + (c - f) / zoom;\n }\n vec3 blurred(sampler2D t, vec2 size, vec2 uv, float zoom, vec2 focus, float aim) {\n vec3 acc = vec3(0.0);\n float ws = 0.0;\n vec2 dir = uv - 0.5;\n // Dithering gegen sichtbare Stufen im Zoom-Blur\n float jitter = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);\n for (int i = 0; i < TAPS; i++) {\n float k = (float(i) + jitter) / float(TAPS);\n float w = 1.0 - 0.7 * k;\n vec2 u = uv - dir * k * uBlur * 0.22;\n acc += texture2D(t, dive(u, size, zoom, focus, aim)).rgb * w;\n ws += w;\n }\n return acc / ws;\n }\n void main() {\n vec2 uv = vec2(vUv.x, 1.0 - vUv.y);\n vec3 col = blurred(uA, uSizeA, uv, uZoomA, uFocusA, uAimA);\n if (uMix > 0.001) {\n vec3 b = blurred(uB, uSizeB, uv, uZoomB, uFocusB, uAimB);\n col = mix(col, b, uMix);\n }\n float vig = smoothstep(1.05, 0.25, length((uv - 0.5) * vec2(uRes.x / uRes.y, 1.0)));\n col *= mix(1.0, mix(0.72, 1.0, vig), clamp(uZoomA - 1.0, 0.0, 1.0));\n col += vec3(0.92, 0.96, 1.0) * uBlur * 0.07;\n gl_FragColor = vec4(col, 1.0);\n }\n `;\n\n const clamp01 = v => Math.min(1, Math.max(0, v));\n const smooth = (a, b, v) => { const t = clamp01((v - a) / (b - a)); return t * t * (3 - 2 * t); };\n\n // ---------- Grundger\u00fcst ----------\n function createContext(canvas, opts = {}) {\n const gl = canvas.getContext('webgl', { alpha: false, antialias: false, premultipliedAlpha: false, preserveDrawingBuffer: !!opts.preserve, powerPreference: 'high-performance' });\n if (!gl) return null;\n const buf = gl.createBuffer();\n gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);\n const compile = (type, src) => {\n const s = gl.createShader(type);\n gl.shaderSource(s, src);\n gl.compileShader(s);\n if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));\n return s;\n };\n const program = frag => {\n const p = gl.createProgram();\n gl.attachShader(p, compile(gl.VERTEX_SHADER, VERT));\n gl.attachShader(p, compile(gl.FRAGMENT_SHADER, frag));\n gl.linkProgram(p);\n if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));\n const loc = {};\n return {\n use() {\n gl.useProgram(p);\n const a = gl.getAttribLocation(p, 'aPos');\n gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n gl.enableVertexAttribArray(a);\n gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);\n },\n u(name) { if (!(name in loc)) loc[name] = gl.getUniformLocation(p, name); return loc[name]; }\n };\n };\n const maxTex = gl.getParameter(gl.MAX_TEXTURE_SIZE);\n // Bild -> Textur (auf maxSize herunterskaliert, CORS-Bild n\u00f6tig)\n const texture = (img, maxSize) => {\n const limit = Math.min(maxSize, maxTex);\n const s = Math.min(1, limit / Math.max(img.naturalWidth, img.naturalHeight));\n let source = img, w = img.naturalWidth, h = img.naturalHeight;\n if (s < 1) {\n w = Math.round(w * s); h = Math.round(h * s);\n const c = document.createElement('canvas');\n c.width = w; c.height = h;\n const cx = c.getContext('2d');\n cx.imageSmoothingQuality = 'high';\n cx.drawImage(img, 0, 0, w, h);\n source = c;\n }\n const t = gl.createTexture();\n gl.bindTexture(gl.TEXTURE_2D, t);\n gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);\n gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);\n if (source !== img) source.width = source.height = 0; // Zwischen-Canvas freigeben (Safari z\u00e4hlt es sonst weiter mit)\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n return { t, w, h };\n };\n return { gl, program, texture, maxTex };\n }\n\n const whenLoaded = img => new Promise(res => {\n if (img.complete && img.naturalWidth) { res(img); return; }\n img.addEventListener('load', () => res(img), { once: true });\n img.addEventListener('error', () => res(null), { once: true });\n });\n\n // ==========================================================================\n // 1) Bild-FX\n // ==========================================================================\n function createFX({ mobile = false } = {}) {\n const glc = document.createElement('canvas');\n let ctx;\n try { ctx = createContext(glc, { preserve: true }); } catch (e) { ctx = null; }\n if (!ctx) return null;\n const { gl } = ctx;\n let prog;\n try { prog = ctx.program(FX_FRAG); } catch (e) { console.warn('[KL gl] fx shader', e); return null; }\n let lost = false;\n glc.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; items.forEach(hide); });\n\n const items = [];\n const io = new IntersectionObserver(entries => entries.forEach(en => {\n const it = items.find(i => i.el === en.target);\n if (it) { it.visible = en.isIntersecting; if (!it.visible) hide(it); }\n }), { rootMargin: '10% 0px' });\n\n const hide = it => { if (it.shown) { it.cv.style.opacity = '0'; it.shown = false; } };\n\n function add(el, { horizontal = false } = {}) {\n const img = el.querySelector('img');\n if (!img) return;\n const cv = document.createElement('canvas');\n cv.className = 'gl-canvas';\n cv.setAttribute('aria-hidden', 'true');\n el.appendChild(cv);\n const it = { el, img, cv, c2d: cv.getContext('2d'), tex: null, hover: 0, mx: 0.5, my: 0.5, visible: false, shown: false, horizontal };\n const upload = () => { if (!it.tex && !lost && img.naturalWidth) { try { it.tex = ctx.texture(img, mobile ? 1280 : 2048); } catch (e) { console.warn('[KL gl] texture', e); } } };\n whenLoaded(img).then(ok => { if (ok) upload(); });\n img.addEventListener('load', upload); // auch ein sp\u00e4ter getauschtes Bild (AVIF-R\u00fcckfall auf WebP, main.js)\n el.addEventListener('pointerenter', () => gsap.to(it, { hover: 1, duration: 0.9, ease: 'power2.out', overwrite: 'auto' }));\n el.addEventListener('pointerleave', () => gsap.to(it, { hover: 0, duration: 1.1, ease: 'power2.out', overwrite: 'auto' }));\n el.addEventListener('pointermove', e => {\n const r = el.getBoundingClientRect();\n it.mx = (e.clientX - r.left) / r.width;\n it.my = (e.clientY - r.top) / r.height;\n });\n io.observe(el);\n items.push(it);\n }\n\n function draw(it, vel, time) {\n const r = it.el.getBoundingClientRect();\n const ir = it.img.getBoundingClientRect();\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n const W = Math.max(2, Math.round(r.width * dpr)), H = Math.max(2, Math.round(r.height * dpr));\n if (it.cv.width !== W || it.cv.height !== H) { it.cv.width = W; it.cv.height = H; }\n if (glc.width < W || glc.height < H) { glc.width = Math.max(glc.width, W); glc.height = Math.max(glc.height, H); }\n gl.viewport(0, 0, W, H);\n prog.use();\n gl.activeTexture(gl.TEXTURE0);\n gl.bindTexture(gl.TEXTURE_2D, it.tex.t);\n gl.uniform1i(prog.u('uImage'), 0);\n gl.uniform2f(prog.u('uRes'), W, H);\n gl.uniform4f(prog.u('uRect'), (ir.left - r.left) * dpr, (ir.top - r.top) * dpr, ir.width * dpr, ir.height * dpr);\n gl.uniform2f(prog.u('uTexSize'), it.tex.w, it.tex.h);\n gl.uniform1f(prog.u('uVel'), vel);\n gl.uniform1f(prog.u('uHover'), it.hover);\n gl.uniform2f(prog.u('uMouse'), it.mx, it.my);\n gl.uniform1f(prog.u('uTime'), time);\n gl.uniform1f(prog.u('uHoriz'), it.horizontal ? 1 : 0);\n gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n it.c2d.clearRect(0, 0, W, H);\n it.c2d.drawImage(glc, 0, glc.height - H, W, H, 0, 0, W, H);\n }\n\n // pro Frame von main.js aufgerufen\n function update(vel, time) {\n if (lost) return;\n for (const it of items) {\n if (!it.visible || !it.tex) continue;\n const amount = Math.abs(vel) + it.hover;\n if (amount < 0.006) { hide(it); continue; }\n draw(it, vel, time);\n if (!it.shown) { it.cv.style.opacity = '1'; it.shown = true; }\n }\n }\n\n return { add, update };\n }\n\n // ==========================================================================\n // 2) Drohnenflug\n // ==========================================================================\n function createDive(canvas, stages, { mobile = false } = {}) {\n let ctx;\n try { ctx = createContext(canvas); } catch (e) { ctx = null; }\n if (!ctx) return null;\n const { gl } = ctx;\n let prog;\n try { prog = ctx.program(DIVE_FRAG.replace(/TAPS/g, mobile ? '8' : '14')); } catch (e) { console.warn('[KL gl] dive shader', e); return null; }\n const tex = stages.map(() => null);\n let lost = false;\n canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; });\n\n const n = stages.length;\n // Zeitfenster je Bild: Bild k ist aktiv f\u00fcr pos \u2208 [k - 0.28, k + 1]\n const windowU = (k, pos) => {\n const start = k === 0 ? 0 : k - 0.28;\n const len = k === 0 ? 1 : 1.28;\n return clamp01((pos - start) / len);\n };\n const zoomOf = (k, pos) => { const s = stages[k]; return s.z0 + (s.z1 - s.z0) * Math.pow(windowU(k, pos), 1.6); };\n const aimOf = (k, pos) => stages[k].aim * smooth(0.05, 0.9, windowU(k, pos));\n\n // Texturen erst laden, wenn der Flug in die N\u00e4he kommt\n let loading = false;\n function prepare() {\n if (loading) return;\n loading = true;\n stages.forEach((s, i) => {\n const img = new Image();\n img.crossOrigin = 'anonymous';\n img.decoding = 'async';\n img.src = s.src;\n whenLoaded(img).then(ok => { if (ok && !lost) { try { tex[i] = ctx.texture(img, mobile ? 2560 : 3072); } catch (e) { console.warn('[KL gl] dive texture', e); } } });\n });\n }\n\n function state(pos) {\n const k = Math.min(n - 1, Math.max(0, Math.floor(pos)));\n const t = pos - k;\n const mix = k < n - 1 ? smooth(0.72, 1.0, t) : 0;\n return { a: k, b: Math.min(n - 1, k + 1), mix };\n }\n\n function render(pos, velBlur = 0) {\n if (lost) return false;\n const st = state(pos);\n const A = tex[st.a], B = tex[st.b];\n if (!A || (st.mix > 0 && !B)) return false;\n const dpr = Math.min(window.devicePixelRatio || 1, 1.5); // auf dem Handy fr\u00fcher 1 \u2013 beim Heranzoomen zu weich\n const W = Math.max(2, Math.round(canvas.clientWidth * dpr)), H = Math.max(2, Math.round(canvas.clientHeight * dpr));\n if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; }\n gl.viewport(0, 0, W, H);\n prog.use();\n gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, A.t); gl.uniform1i(prog.u('uA'), 0);\n gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, (B || A).t); gl.uniform1i(prog.u('uB'), 1);\n gl.uniform2f(prog.u('uSizeA'), A.w, A.h);\n gl.uniform2f(prog.u('uSizeB'), (B || A).w, (B || A).h);\n gl.uniform2f(prog.u('uRes'), W, H);\n gl.uniform1f(prog.u('uZoomA'), zoomOf(st.a, pos));\n gl.uniform1f(prog.u('uZoomB'), zoomOf(st.b, pos));\n gl.uniform2f(prog.u('uFocusA'), stages[st.a].focus[0], stages[st.a].focus[1]);\n gl.uniform2f(prog.u('uFocusB'), stages[st.b].focus[0], stages[st.b].focus[1]);\n gl.uniform1f(prog.u('uAimA'), aimOf(st.a, pos));\n gl.uniform1f(prog.u('uAimB'), aimOf(st.b, pos));\n gl.uniform1f(prog.u('uMix'), st.mix);\n gl.uniform1f(prog.u('uBlur'), Math.min(1.2, Math.sin(st.mix * Math.PI) * 0.95 + velBlur));\n gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n return true;\n }\n\n return { prepare, render, state, zoomOf, get ready() { return !!tex[0]; }, get lost() { return lost; } };\n }\n\n KL.gl = { createFX, createDive };\n})();\n"},{"name":"hero-sky.js","code":"/* ==========================================================================\n KARLS LIVING \u2014 Hero-Zeitraffer\n Himmel (WebGL): Verlauf, Sonne, Mond, Sterne, ziehende Wolken, Regenbogen, Blitze\n H\u00e4user (WebGL): Tages- und Nachtlicht, Fenster, Wandleuchten und Laternen gehen an,\n Jahreszeiten f\u00e4rben B\u00e4ume und Rasen (Bl\u00fcten, Laub, Schnee)\n Wetter (2D): V\u00f6gel hinter dem Schriftzug, Regen, Schnee, Laub und Bl\u00fcten vor den H\u00e4usern\n Ein Tag dauert rund eine Minute (TEMPO), in jeder Nacht wechselt die Jahreszeit:\n Sommer \u2192 Herbst \u2192 Winter \u2192 Fr\u00fchling \u2192 Sommer \u2026\n ========================================================================== */\n(() => {\n 'use strict';\n const KL = (window.KL = window.KL || {});\n\n /* ---------- Vermessung des Geb\u00e4ude-Renderings (relativ 0..1, oben links) ---------- */\n // Frontfenster: [x0, y0, x1, Unterkante links, Unterkante rechts, [[Sprosse von, bis], \u2026], Pflanzen davor]\n // Nur die Glasfl\u00e4che innerhalb des Rahmens; die Unterkante folgt der Br\u00fcstung (im Dachgeschoss schr\u00e4g).\n const FRONT = [[0.9406,0.0776,0.9836,0.1129,0.1136,[[0.9521,0.9531],[0.9674,0.9685]],0],[0.9474,0.2227,0.9919,0.2824,0.2818,[[0.9594,0.9604],[0.975,0.976]],0],[0.9474,0.3866,0.9932,0.454,0.4511,[[0.9594,0.9604],[0.975,0.976]],1],[0.9474,0.552,0.9919,0.6271,0.624,[[0.9594,0.9604],[0.975,0.976]],0],[0.1018,0.3104,0.1453,0.3522,0.3464,[[0.1148,0.1172],[0.1284,0.1307]],0],[0.1706,0.2908,0.1945,0.3429,0.3399,[[0.1807,0.1818]],0],[0.2276,0.2908,0.2797,0.3354,0.3284,[[0.2411,0.2435],[0.2552,0.2576],[0.269,0.2714]],0],[0.3307,0.2692,0.3703,0.3217,0.3163,[[0.3432,0.3443],[0.357,0.3594]],0],[0.4091,0.2692,0.4362,0.3111,0.3076,[[0.4224,0.4247]],0],[0.1018,0.4393,0.1453,0.486,0.483,[[0.1148,0.1172],[0.1284,0.1307]],0],[0.1706,0.4271,0.1945,0.4812,0.4796,[[0.1807,0.1818]],0],[0.2276,0.4278,0.2794,0.4773,0.4735,[[0.2411,0.2435],[0.2552,0.2576],[0.269,0.2714]],0],[0.3307,0.4143,0.3703,0.47,0.4675,[[0.3432,0.3443],[0.357,0.3594]],0],[0.4091,0.4143,0.4359,0.4643,0.4629,[[0.4224,0.4247]],0],[0.1018,0.5702,0.1453,0.6235,0.6233,[[0.1148,0.1172],[0.1284,0.1307]],0],[0.1706,0.5661,0.1932,0.6234,0.6235,[[0.1807,0.1818]],0],[0.2276,0.5661,0.2797,0.6228,0.6228,[[0.2411,0.2435],[0.2552,0.2576],[0.269,0.2714]],0],[0.3307,0.5614,0.3703,0.6229,0.6213,[[0.3432,0.3443],[0.357,0.3594]],0],[0.4091,0.5621,0.4359,0.6222,0.6222,[[0.4224,0.4247]],0],[0.1729,0.1532,0.1974,0.1936,0.1884,[[0.1833,0.1844]],0],[0.2484,0.1538,0.2737,0.1781,0.1732,[[0.2602,0.2612]],0],[0.3164,0.1451,0.3414,0.1646,0.1592,[[0.3289,0.3299]],0],[0.3648,0.1457,0.3896,0.1545,0.1498,[[0.3789,0.3799]],0]];\n // \u00d6ffnungen an schr\u00e4gen Fassaden (Loggien rechts, hinteres Haus): [x0, x1, oben links, oben rechts, unten links,\n // unten rechts, Pflanzen davor] \u2013 Parallelogramme, deren Ober- und Unterkante der Perspektive folgen\n const SIDE = [[0.8591,0.8867,0.0963,0.0972,0.1148,0.1155,0],[0.8966,0.9031,0.0934,0.0845,0.1154,0.1154,0],[0.5047,0.5117,0.3592,0.3632,0.3843,0.3875,1],[0.5714,0.5768,0.3973,0.4003,0.4185,0.4215,1],[0.5857,0.5904,0.4681,0.4701,0.4947,0.4966,0],[0.5823,0.5898,0.5335,0.5348,0.5622,0.5637,1],[0.8263,0.8346,0.2413,0.231,0.3241,0.3154,1],[0.8263,0.8346,0.3957,0.3895,0.4781,0.4747,0],[0.8263,0.8346,0.5527,0.5506,0.6359,0.6355,0],[0.801,0.8089,0.2721,0.2626,0.3484,0.3416,1],[0.801,0.8089,0.4144,0.4087,0.4917,0.4865,0],[0.801,0.8089,0.5591,0.5572,0.636,0.6354,0],[0.7828,0.7893,0.2944,0.2865,0.4013,0.3961,1],[0.7828,0.7893,0.428,0.4233,0.5352,0.5327,0],[0.7828,0.7893,0.5636,0.5624,0.6712,0.6718,0],[0.7659,0.7773,0.315,0.3011,0.3703,0.3585,1],[0.7659,0.7773,0.4405,0.4323,0.4965,0.4965,0],[0.7659,0.7773,0.568,0.5654,0.6302,0.6302,1],[0.751,0.7609,0.3328,0.3211,0.3699,0.3599,0],[0.751,0.7609,0.4512,0.4444,0.4971,0.4962,1],[0.751,0.7609,0.5717,0.5695,0.6302,0.6302,1],[0.7359,0.7411,0.2794,0.2744,0.3352,0.3426,0],[0.7359,0.7411,0.3944,0.3894,0.4545,0.4508,0],[0.7359,0.7411,0.5072,0.5048,0.5677,0.5665,0],[0.7167,0.7219,0.3751,0.3688,0.4216,0.4162,1],[0.7094,0.7151,0.3827,0.3817,0.4191,0.4197,0],[0.7167,0.7219,0.4615,0.4615,0.5243,0.5241,1],[0.7039,0.7099,0.4863,0.4823,0.5241,0.5243,0],[0.7167,0.7219,0.5812,0.5793,0.6303,0.6288,1],[0.7039,0.7099,0.584,0.5826,0.6303,0.6314,1],[0.6943,0.7003,0.402,0.3952,0.4281,0.4225,0],[0.6943,0.7003,0.4934,0.4894,0.529,0.5258,0],[0.6943,0.7003,0.5861,0.5852,0.6316,0.6313,0],[0.7667,0.774,0.214,0.2026,0.2361,0.2245,0],[0.8154,0.8201,0.136,0.1289,0.1611,0.155,0],[0.7169,0.7211,0.2928,0.2874,0.3115,0.3047,0]];\n const WINDOW_COUNT = FRONT.length + SIDE.length;\n // Stra\u00dfenlaternen [x, y, Radius des Leuchtk\u00f6rpers]\n const LAMPS = [[0.1958,0.5884,0.0135],[0.5898,0.61,0.0054],[0.6003,0.6086,0.0061],[0.6148,0.6046,0.0067],[0.6302,0.6005,0.0081],[0.6479,0.5958,0.0094],[0.681,0.587,0.0121]];\n // Runde Wandleuchten neben den Fenstern [x, y]\n const WALL = [[0.0932,0.3144],[0.2039,0.2942],[0.3201,0.2733],[0.4461,0.274],[0.0932,0.4426],[0.2039,0.4312],[0.3201,0.4177],[0.4461,0.4184],[0.0932,0.5756],[0.2039,0.5688],[0.3201,0.5655],[0.4461,0.5668],[0.2062,0.1606],[0.9299,0.0823],[0.9365,0.2281],[0.9365,0.392],[0.9365,0.5574],[0.85,0.1053]];\n // Schnee-Kanten: Oberkanten von Br\u00fcstungen, Mauern, Balkonen und Laternenk\u00f6pfen, auf denen im Winter\n // ein Schneepolster liegt: [x0, y0, x1, y1, Polsterh\u00f6he] (relativ). Im Rendering vermessen: Kante je Spalte\n // gesucht, robuste Gerade angepasst. Dachkanten gegen den Himmel werden zur Laufzeit aus dem Umriss erkannt.\n const SNOW = [[0.0307,0.365,0.4589,0.3079,0.0047],[0.0307,0.4944,0.4589,0.4645,0.0047],[0.0307,0.6273,0.4589,0.6253,0.0047],[0.0253,0.2298,0.468,0.133,0.0061],[0.9445,0.2856,1.0,0.2853,0.0047],[0.9445,0.4572,1.0,0.4545,0.0047],[0.9445,0.6291,1.0,0.6279,0.0047],[0.6945,0.419,0.7227,0.419,0.0034],[0.6945,0.5261,0.7227,0.5238,0.0034],[0.6945,0.6318,0.7227,0.6296,0.0034],[0.7427,0.3652,0.7773,0.365,0.0034],[0.7427,0.497,0.7773,0.4964,0.0034],[0.7427,0.6307,0.7773,0.63,0.0034],[0.8453,0.1147,1.0,0.1174,0.0054],[0.4674,0.6297,0.5951,0.6287,0.004],[0.4674,0.6905,0.6062,0.6935,0.004],[0.1877,0.5844,0.2039,0.5844,0.0061],[0.5866,0.6084,0.593,0.6084,0.0024],[0.5967,0.6068,0.6039,0.6068,0.0027],[0.6108,0.6026,0.6188,0.6026,0.003],[0.6254,0.5981,0.635,0.5981,0.0036],[0.6423,0.593,0.6535,0.593,0.0042],[0.6738,0.5834,0.6882,0.5834,0.0054]];\n const IMG_ASPECT = 3840 / 1482;\n\n /* ---------- Tempo ---------- */\n // TEMPO: Uhrzeit, Jahreszeitenwechsel und Wolken (1 = urspr\u00fcnglich; zuletzt 0,625, auf Wunsch halbiert:\n // ein Tag dauert jetzt 46\u201366 s je nach Jahreszeit, ein ganzes Jahr rund 3\u00be Minuten).\n // CALM: Flug der V\u00f6gel, fallender Schnee, Laub und Bl\u00fcten. Fl\u00fcgelschlag und Regen bleiben nat\u00fcrlich,\n // sonst wirken sie wie Zeitlupe.\n const TEMPO = 0.3125, CALM = 0.625;\n\n /* ---------- Hilfen ---------- */\n const clamp01 = v => Math.min(1, Math.max(0, v));\n const sstep = (a, b, v) => { const t = clamp01((v - a) / (b - a)); return t * t * (3 - 2 * t); };\n const lerp = (a, b, t) => a + (b - a) * t;\n const mix3 = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];\n const bump = (h, a, b, c, d) => sstep(a, b, h) * (1 - sstep(c, d, h));\n const hash = n => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };\n const rnd = (a, b) => a + Math.random() * (b - a);\n\n /* ==========================================================================\n ZEIT, SONNE, WETTER\n ========================================================================== */\n const SEASONS = ['Fr\u00fchling', 'Sommer', 'Herbst', 'Winter'];\n // Fr\u00fchling, Sommer, Herbst, Winter\n const RISE = [6.0, 5.0, 7.2, 8.2];\n const SET = [20.0, 21.4, 18.0, 16.2];\n const PEAK = [0.7, 0.88, 0.52, 0.36];\n // Wetter \u00fcber den Tag (h = Stunde)\n const WX = [\n // Fr\u00fchling: Quellwolken, mittags ein kurzer Schauer, danach ein Regenbogen, Bl\u00fcten fliegen\n h => ({ cover: 0.34 + 0.36 * bump(h, 9.5, 11.5, 13.2, 14.6), dark: 0.4 * bump(h, 10.5, 11.8, 13, 14), rain: 0.8 * bump(h, 11.2, 12, 12.9, 13.7), snow: 0, fog: 0.2 * bump(h, 3.5, 5.2, 7, 9), wind: 0.45, bow: bump(h, 13.5, 14.2, 16.4, 17.6), petals: 1, leaves: 0, storm: 0 }),\n // Sommer: klar, nachmittags Sch\u00f6nwetterwolken\n h => ({ cover: 0.26 + 0.22 * bump(h, 10, 13, 17, 20), dark: 0, rain: 0, snow: 0, fog: 0.1 * bump(h, 3.5, 4.8, 6, 7.5), wind: 0.25, bow: 0, petals: 0, leaves: 0, storm: 0 }),\n // Herbst: Morgennebel, sp\u00e4ter Regen mit Gewitter, Laub weht\n h => ({ cover: 0.55 + 0.43 * bump(h, 8.5, 11, 17, 19.5), dark: 0.6 * bump(h, 10, 12, 16.5, 18.5), rain: bump(h, 11.5, 12.8, 16, 17.5), snow: 0, fog: 0.6 * bump(h, 2.5, 4.5, 8.5, 11), wind: 0.9, bow: 0, petals: 0, leaves: 1, storm: bump(h, 13, 13.8, 15.5, 16.3) }),\n // Winter: grau mit Schneefall am Tag, nachts klar und frostig\n h => { const d = bump(h, 6.5, 8.5, 16.5, 19); return { cover: 0.26 + 0.64 * d, dark: 0.22 * d, rain: 0, snow: bump(h, 7, 8.5, 15, 17), fog: 0.28 * d, wind: 0.35, bow: 0, petals: 0, leaves: 0, storm: 0 }; }\n ];\n const WX_KEYS = ['cover', 'dark', 'rain', 'snow', 'fog', 'wind', 'bow', 'petals', 'leaves', 'storm'];\n\n function createClock({ season = 1, hour = 18.2 } = {}) {\n // live: echte Werte aus Karlshorst (Jahreszeit, Sonne, Mond, Wetter); live.mix = ihr Anteil (1 ganz live, 0 Zeitraffer)\n const c = { day: 0, hour, s0: season, live: null, moon0: 0.47, moonStep: 1 / 8 };\n const sOf = k => ((c.s0 + k) % 4 + 4) % 4;\n // Jahreszeiten-Anteile; der Wechsel passiert zwischen 22 und 7 Uhr\n c.weights = () => {\n const h = c.hour, w = [0, 0, 0, 0];\n let a, b, t;\n if (h >= 22) { a = sOf(c.day); b = sOf(c.day + 1); t = sstep(22, 31, h); }\n else if (h < 7) { a = sOf(c.day - 1); b = sOf(c.day); t = sstep(22, 31, h + 24); }\n else { a = b = sOf(c.day); t = 0; }\n w[a] += 1 - t; w[b] += t;\n const L = c.live;\n return L && L.mix > 0 ? w.map((v, i) => lerp(v, L.w[i], L.mix)) : w;\n };\n c.season = () => { const w = c.weights(); return w.indexOf(Math.max(...w)); };\n // Mondphase 0 \u2026 1 (0,5 = Vollmond). Im Zeitraffer schneller als echt (ein Umlauf in acht Tagen), damit auch mal\n // Vollmond ist; die erste Nacht ist fast voll. Live die echte Phase.\n c.moon = () => {\n const L = c.live, t = ((c.moon0 + (c.day + c.hour / 24 - 0.75) * c.moonStep) % 1 + 1) % 1;\n if (!L || L.mix <= 0) return t;\n let d = L.phase - t;\n d -= Math.round(d);\n return ((t + d * L.mix) % 1 + 1) % 1;\n };\n return c;\n }\n\n /* ---------- Karlshorst live: echte Uhrzeit, Sonne, Mond, Jahreszeit und Wetter ---------- */\n const HOME = { lat: 52.483, lon: 13.527 };\n // Uhrzeit und Datum in Berlin, egal in welcher Zeitzone jemand die Seite \u00f6ffnet (off = Abstand zu UTC in Stunden)\n function berlinNow(ms = Date.now()) {\n const p = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Berlin', year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hourCycle: 'h23' }).formatToParts(new Date(ms));\n const v = k => +(p.find(x => x.type === k) || { value: 0 }).value;\n const t = new Date(ms), utc = t.getUTCHours() + t.getUTCMinutes() / 60;\n const h = v('hour') + v('minute') / 60 + v('second') / 3600;\n return { y: v('year'), m: v('month'), d: v('day'), h, off: Math.round(((h - utc) % 24 + 24 + 12) % 24 - 12) };\n }\n // Sonnenaufgang und -untergang (Ortszeit) und h\u00f6chster Sonnenstand (als Sinus) f\u00fcr einen Tag (NOAA, vereinfacht)\n function sunDay(y, m, d, off) {\n const n = (Date.UTC(y, m - 1, d) - Date.UTC(y, 0, 0)) / 864e5;\n const g = 2 * Math.PI / 365 * (n - 0.5);\n const eqt = 229.18 * (0.000075 + 0.001868 * Math.cos(g) - 0.032077 * Math.sin(g) - 0.014615 * Math.cos(2 * g) - 0.040849 * Math.sin(2 * g));\n const dec = 0.006918 - 0.399912 * Math.cos(g) + 0.070257 * Math.sin(g) - 0.006758 * Math.cos(2 * g) + 0.000907 * Math.sin(2 * g) - 0.002697 * Math.cos(3 * g) + 0.00148 * Math.sin(3 * g);\n const phi = HOME.lat * Math.PI / 180;\n const cosH = Math.cos(90.833 * Math.PI / 180) / (Math.cos(phi) * Math.cos(dec)) - Math.tan(phi) * Math.tan(dec);\n const ha = Math.acos(Math.min(1, Math.max(-1, cosH))) * 180 / Math.PI;\n const noon = (720 - 4 * HOME.lon - eqt) / 60 + off;\n return { rise: noon - ha / 15, set: noon + ha / 15, peak: Math.sin((90 - Math.abs(HOME.lat - dec * 180 / Math.PI)) * Math.PI / 180) };\n }\n // Mondphase 0 \u2026 1 (0 Neumond, 0,5 Vollmond), gez\u00e4hlt ab einem bekannten Neumond\n const moonPhaseAt = ms => (((ms - Date.UTC(2000, 0, 6, 18, 14)) / 864e5 / 29.530588853) % 1 + 1) % 1;\n // So sehen B\u00e4ume und Rasen im jeweiligen Monat aus (Fr\u00fchling, Sommer, Herbst, Winter; je Monatsmitte), dazwischen\n // gemischt. Schnee liegt live nur, w\u00e4hrend es wirklich schneit (Schneeregen nur um den Gefrierpunkt; snowCover).\n const MONTHS = [[0, 0, 0, 1], [0, 0, 0, 1], [0.45, 0, 0, 0.55], [1, 0, 0, 0], [0.6, 0.4, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0], [0, 0.85, 0.15, 0], [0, 0.25, 0.75, 0], [0, 0, 0.7, 0.3], [0, 0, 0, 1]];\n function seasonOf(y, m, d) {\n const pos = (m - 1) + (d - 0.5) / new Date(Date.UTC(y, m, 0)).getUTCDate() - 0.5;\n const i = Math.floor(pos), t = sstep(0, 1, pos - i), a = MONTHS[(i + 12) % 12], b = MONTHS[(i + 13) % 12];\n return a.map((v, k) => v + (b[k] - v) * t);\n }\n // Aktuelles Wetter vom Deutschen Wetterdienst (\u00fcber Bright Sky: offene DWD-Daten, auch f\u00fcr gewerbliche Seiten frei,\n // mit Quellenangabe) -> Werte des Zeitraffers\n function wxFromDWD(x, w) {\n const cond = x.condition || '', icon = x.icon || '', has = re => re.test(cond) || re.test(icon);\n const cover = x.cloud_cover != null ? x.cloud_cover / 100 : icon === 'cloudy' ? 0.9 : /partly/.test(icon) ? 0.5 : 0.15;\n const mm = Math.max(x.precipitation_60 || 0, (x.precipitation_30 || 0) * 2, (x.precipitation_10 || 0) * 6); // mm pro Stunde\n const sleet = has(/sleet/) ? 0.5 : 1;\n const rain = has(/rain|sleet|hail|thunder/) ? clamp01(0.3 + mm / 3) * sleet : 0;\n const snow = has(/snow|sleet/) ? clamp01(0.35 + mm / 1.5) * sleet : 0;\n const storm = has(/thunder/) ? 1 : 0;\n const vis = x.visibility != null ? x.visibility : 20000;\n const fog = has(/fog/) ? 0.35 + 0.5 * sstep(4000, 300, vis) : 0.35 * sstep(9000, 2000, vis);\n const wind = clamp01((x.wind_speed_10 != null ? x.wind_speed_10 : x.wind_speed_30 || 8) / 38);\n return {\n cover: Math.max(cover, rain * 0.9, snow * 0.85, storm), dark: clamp01(rain * 0.5 + storm * 0.45 + sstep(0.85, 1, cover) * 0.15),\n rain, snow, fog, wind, bow: 0, petals: w[0] * (1 - rain), leaves: w[2] * sstep(0.1, 0.5, wind), storm\n };\n }\n\n // Live-Wetter, das gerade zu sehen ist: bis die Antwort des Wetterdienstes da ist (oder wenn sie ausbleibt) neutral\n // und trocken (wxNone) statt des erfundenen Zeitraffer-Wetters f\u00fcr diese Stunde; neue Werte blenden in 1,6 s \u00fcber\n // (wxFrom, wxAt), statt von einem Bild aufs n\u00e4chste umzuspringen\n function liveWx(L) {\n const tgt = L.wx || L.wxNone;\n if (!L.wxFrom) return tgt;\n const t = sstep(0, 1, (performance.now() - L.wxAt) / 1600);\n if (t >= 1) { L.wxFrom = null; return tgt; }\n const o = { snowCover: lerp(L.wxFrom.snowCover, tgt.snowCover, t) };\n WX_KEYS.forEach(k => { o[k] = lerp(L.wxFrom[k], tgt[k], t); });\n return o;\n }\n\n // Sonnenstand, Mond, Wetter und alle Farben f\u00fcr einen Zeitpunkt\n function environment(clock) {\n const w = clock.weights();\n const dot = a => a[0] * w[0] + a[1] * w[1] + a[2] * w[2] + a[3] * w[3];\n const L = clock.live, lf = L ? L.mix : 0; // Anteil der echten Werte aus Karlshorst\n const rise = lerp(dot(RISE), L ? L.rise : 0, lf), set = lerp(dot(SET), L ? L.set : 0, lf), peak = lerp(dot(PEAK), L ? L.peak : 0, lf), h = clock.hour;\n const nightLen = 24 - (set - rise);\n let e, sunT, moonT;\n if (h >= rise && h <= set) { sunT = (h - rise) / (set - rise); e = Math.sin(Math.PI * sunT) * peak; moonT = -1; }\n else { moonT = ((h - set + 24) % 24) / nightLen; e = -Math.sin(Math.PI * moonT) * 0.55; sunT = moonT < 0.5 ? 1 : 0; }\n\n const wx = {};\n WX_KEYS.forEach(k => { wx[k] = 0; });\n WX.forEach((f, i) => { if (w[i] < 0.001) return; const v = f(h); WX_KEYS.forEach(k => { wx[k] += v[k] * w[i]; }); });\n const LW = lf > 0 ? liveWx(L) : null;\n if (LW) WX_KEYS.forEach(k => { wx[k] = lerp(wx[k], LW[k], lf); });\n // Schnee am Boden und auf den D\u00e4chern: im Zeitraffer im Winter, live nur, w\u00e4hrend es wirklich schneit\n const snowCover = lerp(w[3], LW ? LW.snowCover : 0, lf);\n const moon = clock.moon(), moonLit = (1 - Math.cos(moon * Math.PI * 2)) / 2;\n\n const day = sstep(-0.04, 0.25, e);\n const night = 1 - sstep(-0.25, -0.03, e);\n const gold = Math.exp(-(((e - 0.04) / 0.11) ** 2));\n const over = clamp01(sstep(0.48, 0.95, wx.cover) * 0.9 + wx.dark * 0.25);\n\n // Himmel\n let zen = [0.012, 0.024, 0.07], hor = [0.04, 0.068, 0.15];\n zen = mix3(zen, [0.1, 0.15, 0.33], sstep(-0.32, -0.08, e)); hor = mix3(hor, [0.36, 0.32, 0.5], sstep(-0.32, -0.08, e));\n zen = mix3(zen, [0.22, 0.33, 0.6], sstep(-0.08, 0.03, e)); hor = mix3(hor, [1.0, 0.6, 0.36], sstep(-0.08, 0.02, e));\n zen = mix3(zen, [0.24, 0.52, 0.86], sstep(0.05, 0.35, e)); hor = mix3(hor, [0.74, 0.88, 0.97], sstep(0.06, 0.36, e));\n const grey = lerp(0.075, 0.66, day) * (1 - wx.dark * 0.55);\n zen = mix3(zen, [grey * 0.84, grey * 0.89, grey * 0.97], over);\n hor = mix3(hor, [grey * 1.1, grey * 1.12, grey * 1.16], over);\n zen = mix3(zen, [0.46, 0.6, 0.78], w[3] * day * 0.35); // Winterhimmel blasser\n\n const sunCol = mix3([1.0, 0.42, 0.16], [1.0, 0.95, 0.86], sstep(0.0, 0.3, e));\n let lit = [0.15, 0.17, 0.25], shade = [0.035, 0.045, 0.085];\n lit = mix3(lit, [0.62, 0.5, 0.64], sstep(-0.28, -0.05, e)); shade = mix3(shade, [0.23, 0.22, 0.35], sstep(-0.28, -0.05, e));\n lit = mix3(lit, [1.0, 0.72, 0.52], sstep(-0.06, 0.03, e)); shade = mix3(shade, [0.52, 0.42, 0.5], sstep(-0.06, 0.03, e));\n lit = mix3(lit, [1.0, 0.99, 0.97], sstep(0.07, 0.32, e)); shade = mix3(shade, [0.63, 0.69, 0.8], sstep(0.07, 0.32, e));\n lit = mix3(lit, [grey * 1.18, grey * 1.2, grey * 1.24], over * 0.75);\n shade = mix3(shade, [grey * 0.72, grey * 0.75, grey * 0.8], over);\n shade = shade.map(v => v * (1 - wx.dark * 0.35));\n // Stadtlicht: Nachts leuchten tiefe Wolken \u00fcber Berlin warm von unten, statt schwarz zu sein\n const city = night * sstep(0.35, 0.9, over);\n zen = mix3(zen, [0.1, 0.085, 0.08], city * 0.55);\n hor = mix3(hor, [0.26, 0.19, 0.15], city * 0.6);\n lit = mix3(lit, [0.3, 0.23, 0.19], city * 0.5);\n shade = mix3(shade, [0.16, 0.125, 0.11], city * 0.5);\n\n // Licht auf den H\u00e4usern\n let amb = [0.12, 0.14, 0.26];\n amb = mix3(amb, [0.34, 0.37, 0.55], sstep(-0.32, -0.06, e));\n amb = mix3(amb, [1.0, 0.88, 0.76], sstep(-0.06, 0.02, e));\n amb = mix3(amb, [1.08, 1.06, 1.03], sstep(0.08, 0.36, e));\n amb = mix3(amb, [lerp(0.12, 0.84, day), lerp(0.13, 0.87, day), lerp(0.2, 0.92, day)], over * 0.75);\n const sat = clamp01(1.04 - over * 0.32 - night * 0.38);\n\n const HZ = 0.72; // Horizont im Himmels-Canvas\n const sunX = lerp(0.1, 0.9, sunT), sunY = HZ - Math.max(e, -0.2) * 0.64;\n const mT = moonT < 0 ? 0 : moonT;\n const moonX = lerp(0.16, 0.84, mT), moonY = HZ - Math.sin(Math.PI * mT) * 0.62;\n\n amb = mix3(amb, [0.15, 0.135, 0.14], city * 0.4);\n // Vollmond hellt die Nacht etwas auf\n amb = mix3(amb, amb.map(v => v * 1.3), night * moonLit * (1 - over) * sstep(-0.02, -0.14, e));\n\n return {\n w, h, e, day, night, gold, over, wx, rise, set, nightLen, snowCover, moon, moonLit,\n zen, hor, sunCol, lit, shade, amb, sat, HZ, sunX, sunY, moonX, moonY,\n sunVis: sstep(-0.05, 0.02, e) * (1 - over * 0.85),\n moonVis: sstep(-0.02, -0.14, e) * (1 - over * 0.8),\n stars: sstep(-0.1, -0.28, e) * (1 - over),\n glow: gold * (1 - over * 0.8),\n glowCol: mix3([0.95, 0.45, 0.42], [1.0, 0.58, 0.26], sstep(-0.06, 0.04, e)),\n fogCol: mix3(mix3([0.07, 0.08, 0.12], [0.78, 0.8, 0.83], day), hor, 0.3)\n };\n }\n\n // Zeitraffer-Tempo (Stunden pro Sekunde): D\u00e4mmerung langsam, Nacht schnell\n const rate = env => {\n const deep = Math.max(2.2, env.nightLen / 3.4);\n const base = lerp(1.08, deep, sstep(0.0, -0.3, env.e));\n return lerp(base, 0.52, Math.exp(-(((env.e - 0.01) / 0.1) ** 2)));\n };\n\n function weatherWord(env) {\n const x = env.wx;\n if (x.snow > 0.3) return 'Schnee';\n if (x.storm > 0.4 && x.rain > 0.5) return 'Gewitter';\n if (x.rain > 0.3) return 'Regen';\n if (x.bow > 0.4 && env.day > 0.5) return 'Regenbogen';\n if (x.fog > 0.35) return 'Nebel';\n if (x.cover > 0.78) return 'Bedeckt';\n if (x.cover > 0.55) return 'Wolkig';\n if (env.night > 0.6) return 'Sternenklar';\n if (env.gold > 0.45) return env.h > 12 ? 'Abendrot' : 'Morgenrot';\n if (env.e < 0) return 'D\u00e4mmerung';\n return x.cover < 0.3 ? 'Sonnig' : 'Heiter';\n }\n\n /* ==========================================================================\n WEBGL-GRUNDGER\u00dcST\n ========================================================================== */\n const VERT = 'attribute vec2 aPos; varying vec2 vUv; void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }';\n const NOISE = `\n float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }\n float vnoise(vec2 p) {\n vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);\n }`;\n\n function glSetup(canvas, frag, alpha) {\n const gl = canvas.getContext('webgl', { alpha, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, preserveDrawingBuffer: false, powerPreference: 'high-performance' });\n if (!gl) return null;\n const sh = (type, src) => {\n const s = gl.createShader(type);\n gl.shaderSource(s, src);\n gl.compileShader(s);\n if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));\n return s;\n };\n const p = gl.createProgram();\n gl.attachShader(p, sh(gl.VERTEX_SHADER, VERT));\n gl.attachShader(p, sh(gl.FRAGMENT_SHADER, frag));\n gl.linkProgram(p);\n if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));\n gl.useProgram(p);\n const buf = gl.createBuffer();\n gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);\n const a = gl.getAttribLocation(p, 'aPos');\n gl.enableVertexAttribArray(a);\n gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);\n const loc = {};\n const u = name => (name in loc ? loc[name] : (loc[name] = gl.getUniformLocation(p, name)));\n return { gl, u };\n }\n const texture = (gl, unit, filter) => {\n const t = gl.createTexture();\n gl.activeTexture(gl.TEXTURE0 + unit);\n gl.bindTexture(gl.TEXTURE_2D, t);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n return t;\n };\n\n /* ==========================================================================\n HIMMEL\n ========================================================================== */\n const SKY_FRAG = (oct) => `\n precision highp float;\n varying vec2 vUv;\n uniform vec2 uRes, uSun, uMoon, uDrift;\n uniform float uTime, uHz, uCover, uDark, uFog, uStars, uBow, uFlash, uSunVis, uMoonVis, uEvolve, uNight, uGlow, uMoonK, uMoonWax, uMoonLit;\n uniform vec3 uZen, uHor, uSunCol, uLit, uShade, uFogCol, uGlowCol;\n uniform vec4 uShip; // Luftschiff: Mitte (uv), halbe L\u00e4nge (in Bildh\u00f6hen), Richtung (1 = Bug rechts); z = 0: keins\n uniform vec2 uShipB; // Neigung (rad), Lichter (0 \u2026 1, nachts)\n ${NOISE}\n // Luftschiff (Anspielung auf die drehbare Luftschiffhalle von 1909 und die Adresse \u201eAm Alten Flugplatz\u201c): Rumpf als\n // Zigarre mit schlankerem Heck und Ringspanten, Leitwerk oben und unten, F\u00fchrergondel vorn, Motorgondel hinten.\n // Licht wie die Wolken (uLit/uShade), im Dunst, nachts Silhouette mit warmen Gondelfenstern und rotem Blinklicht.\n float shipR(float x) {\n float prof = sqrt(max(0.0, 1.0 - pow(abs(x), 2.6)));\n return 0.17 * prof * (x < 0.0 ? 0.62 + 0.38 * smoothstep(-1.0, -0.25, x) : 1.0);\n }\n vec4 airship(vec2 uv, float asp, vec3 skyC) {\n vec2 p = (uv - uShip.xy) * vec2(asp, 1.0);\n p.y = -p.y;\n float c = cos(uShipB.x), s = sin(uShipB.x);\n p = vec2(c * p.x - s * p.y, s * p.x + c * p.y);\n p.x *= uShip.w;\n p /= uShip.z;\n if (abs(p.x) > 1.06 || abs(p.y) > 0.34) return vec4(0.0);\n float aa = 1.2 / (uRes.y * uShip.z);\n float R = shipR(p.x);\n float hull = 1.0 - smoothstep(-aa, aa, abs(p.y) - R);\n float finH = 0.11 * (1.0 - smoothstep(-0.86, -0.6, p.x));\n float fin = (1.0 - smoothstep(-aa, aa, abs(p.y) - R - finH)) * step(p.x, -0.58) * step(-0.985, p.x);\n vec2 g1 = p - vec2(0.36, -0.209);\n float gond = 1.0 - smoothstep(-aa, aa, max(abs(g1.x) - 0.12, abs(g1.y) - 0.026) - 0.012);\n vec2 g2 = p - vec2(-0.42, -0.188);\n float mot = 1.0 - smoothstep(-aa, aa, max(abs(g2.x) - 0.05, abs(g2.y) - 0.016) - 0.01);\n float a = max(max(hull, fin), max(gond, mot));\n if (a <= 0.0) return vec4(0.0);\n float ny = clamp(p.y / max(R, 0.002), -1.0, 1.0);\n vec3 n = vec3(0.0, ny, sqrt(max(0.0, 1.0 - ny * ny)));\n vec2 toL = mix(uSun, uMoon, uNight) - uShip.xy;\n vec3 L = normalize(vec3(toL.x * asp, -toL.y, 0.55));\n float dif = clamp(dot(n, L), 0.0, 1.0);\n vec3 hullC = mix(uShade * 0.82, uLit * 1.02, dif) * vec3(0.98, 0.96, 0.92);\n hullC *= 1.0 - 0.07 * smoothstep(0.44, 0.5, abs(fract(p.x * 6.0) - 0.5)); // Ringspanten\n hullC *= 1.0 - 0.18 * smoothstep(0.72, 1.0, abs(p.x)); // Bug und Heck runden sich weg\n hullC += uSunCol * pow(max(ny, 0.0), 10.0) * 0.18 * uSunVis; // Glanz oben\n vec3 col = mix(mix(uShade, uZen, 0.3) * 0.62, hullC, hull);\n col = mix(col, skyC * 0.5, uNight * 0.72); // nachts Silhouette, etwas Mondlicht bleibt auf dem Rumpf\n float win = gond * step(0.4, fract((g1.x + 0.12) * 22.0)) * (1.0 - smoothstep(0.004, 0.011, abs(g1.y - 0.003)));\n col += vec3(1.0, 0.78, 0.45) * win * uShipB.y * 1.4;\n float blink = step(0.82, fract(uTime * 0.9));\n col += vec3(1.0, 0.16, 0.1) * (1.0 - smoothstep(0.012, 0.03, length(p - vec2(0.36, -0.245)))) * blink * uShipB.y * 2.0;\n col = mix(col, skyC, 0.22 + 0.45 * uFog);\n return vec4(col, a);\n }\n // Grobes Rauschen f\u00fcr Verwirbelung und Schichtwolken\n float fbmW(vec2 p) {\n float s = 0.0, a = 0.5;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < 4; i++) { s += a * vnoise(p); p = m * p; a *= 0.5; }\n return s;\n }\n // Feines Rauschen f\u00fcr die Wolken selbst: mehr Oktaven f\u00fcr scharfe Details. fw = Fl\u00e4che eines Pixels in\n // Rausch-Einheiten; Oktaven, die feiner als ein Pixel w\u00e4ren, werden ausgeblendet (kein Flimmern am Horizont).\n float fbm(vec2 p, float fw) {\n float s = 0.0, a = 0.5, f = 1.0;\n mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n for (int i = 0; i < ${oct}; i++) { s += a * vnoise(p) * (1.0 - smoothstep(0.22, 0.45, f * fw)) + a * 0.5 * smoothstep(0.22, 0.45, f * fw); p = m * p; a *= 0.5; f *= 2.0; }\n return s;\n }\n vec3 hue(float h) { return clamp(abs(mod(h * 6.0 + vec3(0.0, 4.0, 2.0), 6.0) - 3.0) - 1.0, 0.0, 1.0); }\n\n void main() {\n vec2 uv = vec2(vUv.x, 1.0 - vUv.y);\n float asp = uRes.x / uRes.y;\n float t = clamp(uv.y / uHz, 0.0, 1.0);\n vec3 sky = mix(uZen, uHor, pow(t, 1.45));\n // Abend-/Morgenrot \u00fcber dem Horizont an der Stelle der Sonne\n sky += uGlowCol * uGlow * exp(-abs(uv.x - uSun.x) * 2.2) * pow(t, 3.0) * 0.55;\n vec3 skyBase = sky; // ohne Sterne (f\u00fcr Silhouette und Dunst des Luftschiffs)\n\n // Sterne\n if (uStars > 0.01) {\n vec2 g = gl_FragCoord.xy / 4.5;\n vec2 id = floor(g), f = fract(g) - 0.5;\n float h = hash(id);\n vec2 o = vec2(hash(id + 7.1), hash(id + 3.3)) - 0.5;\n float s = step(0.978, h) * smoothstep(0.36, 0.0, length(f - o * 0.5));\n s *= 0.55 + 0.45 * sin(uTime * (1.3 + h * 4.0) + h * 60.0);\n sky += vec3(0.88, 0.92, 1.0) * s * uStars * (1.0 - t * 0.85) * (0.6 + 0.8 * hash(id + 1.7));\n }\n\n // Sonne und Mond\n vec2 ds = (uv - uSun) * vec2(asp, 1.0);\n float dsun = length(ds);\n vec3 sunGlow = uSunCol * (exp(-dsun * 6.5) * 0.42 + exp(-dsun * 26.0) * 0.55) * uSunVis;\n float sunDisk = smoothstep(0.036, 0.03, dsun) * uSunVis;\n vec2 dm = (uv - uMoon) * vec2(asp, 1.0);\n float dmo = length(dm);\n float moonDisk = smoothstep(0.026, 0.022, dmo);\n // Mondphase: Die Schattengrenze ist eine Ellipse (k = cos 2\u03c0\u00b7Phase: 1 Neumond \u2026 -1 Vollmond); zunehmend ist\n // rechts beleuchtet, abnehmend links. Der dunkle Teil bleibt ganz schwach sichtbar (Erdlicht).\n vec2 mp = dm / 0.024;\n float edge = (uMoonWax > 0.5 ? mp.x : -mp.x) - uMoonK * sqrt(max(0.0, 1.0 - mp.y * mp.y));\n float moon = moonDisk * mix(0.05, 1.0, smoothstep(-0.07, 0.07, edge)) * uMoonVis;\n vec3 moonGlow = vec3(0.55, 0.66, 0.9) * exp(-dmo * 12.0) * (0.08 + 0.22 * uMoonLit) * uMoonVis;\n\n // Wolken als Ebene in Perspektive: oben gro\u00df, zum Horizont hin klein und dicht\n float depth = max(uHz - uv.y, 0.0);\n float pz = 1.0 / (depth + 0.16);\n vec2 cp = vec2((uv.x - 0.5) * asp * pz * 0.55, pz * 0.9) * 3.2 + uDrift;\n float fw = max(1.76 * pz, 2.88 * pz * pz) / uRes.y; // Pixelgr\u00f6\u00dfe in Rausch-Einheiten (quer bzw. in die Tiefe)\n vec2 q = vec2(fbmW(cp * 0.9 + vec2(1.7, 9.2) + uEvolve * 0.07), fbmW(cp * 0.9 + vec2(8.3, 2.8) - uEvolve * 0.05));\n vec2 cq = cp + q * 1.15;\n float n = fbm(cq, fw);\n float thr = mix(0.62, 0.3, uCover);\n float dens = smoothstep(thr, thr + mix(0.11, 0.26, uCover), n);\n vec2 toL = mix(uSun, uMoon, uNight) - uv;\n vec2 ld = toL / (length(toL) + 0.0001);\n float n2 = fbm(cq + ld * 0.12, fw);\n float litK = clamp(0.58 + (n - n2) * 4.2, 0.0, 1.0);\n vec3 ccol = mix(uShade, uLit, litK);\n ccol += uSunCol * exp(-dsun * 4.5) * (1.0 - dens) * 1.3 * uSunVis; // Silberrand\n float strat = smoothstep(0.56, 0.96, uCover) * (0.72 + 0.28 * fbmW(cp * 0.35 + uEvolve * 0.02));\n float cloudA = max(dens * 0.96, strat);\n vec3 cloudCol = mix(ccol, uShade * (0.9 + 0.14 * n), smoothstep(0.55, 1.0, uCover) * (1.0 - dens * 0.45));\n cloudA *= smoothstep(-0.02, 0.06, depth);\n\n vec3 col = sky + sunGlow * (1.0 - cloudA * 0.8) + uSunCol * sunDisk * 2.2 * (1.0 - cloudA) + (vec3(0.94, 0.95, 1.0) * moon + moonGlow) * (1.0 - cloudA * 0.9);\n // Luftschiff zwischen Himmel und Wolken: dicke Wolken verdecken es, durch d\u00fcnne schimmert es (danach ein Rest)\n vec4 ship = uShip.z > 0.0 ? airship(uv, asp, skyBase) : vec4(0.0);\n col = mix(col, ship.rgb, ship.a);\n col = mix(col, cloudCol, cloudA);\n col = mix(col, ship.rgb, ship.a * cloudA * 0.22);\n\n // Regenbogen gegen\u00fcber der Sonne\n if (uBow > 0.01) {\n vec2 bc = vec2(1.0 - uSun.x, uHz + 0.28);\n float r = length((uv - bc) * vec2(asp, 1.0));\n float x = (r - 0.66) / 0.055;\n float band = max(0.0, 1.0 - x * x);\n col += hue((1.0 - (x + 1.0) * 0.5) * 0.78) * band * uBow * 0.3 * (1.0 - cloudA * 0.7) * smoothstep(0.0, 0.12, depth);\n }\n\n col = mix(col, uFogCol, uFog * (0.25 + 0.75 * t) * 0.85);\n col += vec3(0.75, 0.8, 1.0) * uFlash * (0.3 + cloudA * 0.9);\n gl_FragColor = vec4(col, 1.0);\n }`;\n\n /* ==========================================================================\n H\u00c4USER\n ========================================================================== */\n const BUILD_FRAG = `\n precision highp float;\n varying vec2 vUv;\n uniform sampler2D uImg, uIds, uIds2, uWin, uMask, uSnow;\n uniform vec2 uTex;\n uniform float uDeep;\n uniform vec3 uAmb, uFogCol;\n uniform float uSat, uNight, uDay, uOver, uWet, uFog, uFlash, uLampDay, uSnowCover, uManual;\n uniform vec4 uSeason;\n uniform vec4 uLamps[${LAMPS.length}];\n uniform vec3 uWall[${WALL.length}];\n ${NOISE}\n vec3 rgb2hsv(vec3 c) {\n vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);\n vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));\n vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));\n float d = q.x - min(q.w, q.y);\n return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + 1e-10)), d / (q.x + 1e-10), q.x);\n }\n vec3 hsv2rgb(vec3 c) {\n vec3 p = abs(fract(c.xxx + vec3(1.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0);\n return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y);\n }\n // Schneedecke, die das Licht des Originals beh\u00e4lt: in der Sonne wei\u00df, im Schatten bl\u00e4ulich\n vec3 snowLit(float Lb, float L) {\n float sun = smoothstep(0.1, 0.42, Lb);\n return mix(vec3(0.64, 0.71, 0.85), vec3(0.97, 0.985, 1.0), sun) * clamp(0.95 + (L - Lb) * 0.35, 0.85, 1.05);\n }\n void main() {\n vec2 uv = vec2(vUv.x, 1.0 - vUv.y);\n vec4 tx = texture2D(uImg, uv);\n vec4 sn = texture2D(uSnow, uv); // Schneepolster: R Deckung, G Schatten, B H\u00f6he\n float sA = sn.r * uSnowCover;\n if (tx.a < 0.004 && sA < 0.004) { gl_FragColor = vec4(0.0); return; }\n vec3 c = tx.rgb;\n float L = dot(c, vec3(0.2126, 0.7152, 0.0722));\n vec3 hsv = rgb2hsv(c);\n vec2 ar = vec2(uTex.x / uTex.y, 1.0);\n\n // Gegl\u00e4ttete Masken (einmal beim Laden berechnet): R Pflanzen, G ganze Baumkronen, A weiche Helligkeit\n vec4 mk = texture2D(uMask, uv);\n float veg = max(mk.r, mk.g * (1.0 - smoothstep(0.12, 0.3, L))); // dunkle Schatten in den Kronen geh\u00f6ren dazu\n float Lb = mk.a;\n float lawnF = smoothstep(0.745, 0.785, uv.y); // unten: Rasen statt B\u00e4ume\n float hedge = smoothstep(0.722, 0.742, uv.y) * (1.0 - lawnF); // Hecken und Str\u00e4ucher am Stra\u00dfenrand\n float ground = (1.0 - veg) * smoothstep(0.76, 0.8, uv.y);\n // Streifen zwischen Hecke und Stra\u00dfe (Gehweg, Weg durch die Hecke) \u2013 ohne Ziegelsockel und ohne das Auto links\n float strip = smoothstep(0.735, 0.765, uv.y) * (1.0 - mk.r) * (1.0 - smoothstep(0.03, 0.08, c.r - c.b)) * step(0.07, uv.x);\n ground = max(ground, strip);\n\n // Sommer: sattes Gr\u00fcn\n vec3 summer = hsv2rgb(vec3(hsv.x, min(0.95, hsv.y * 1.1), hsv.z));\n // Fr\u00fchling: frisches Hellgr\u00fcn, einzelne Kronen bl\u00fchen rosa-wei\u00df\n vec3 spring = hsv2rgb(vec3(mix(hsv.x, 0.23, 0.35), min(0.9, hsv.y * 1.08), min(1.0, hsv.z * 1.06)));\n float bloom = smoothstep(0.52, 0.74, vnoise(uv * vec2(8.0, 3.6) + 7.3)) * (1.0 - lawnF) * smoothstep(-0.02, 0.08, L - Lb);\n spring = mix(spring, mix(vec3(1.0, 0.76, 0.86), vec3(1.0, 0.95, 0.97), smoothstep(0.25, 0.7, L)) * (0.62 + 0.55 * L), bloom * 0.8);\n // Herbst: nur der Farbton wechselt, Helligkeit und Blattstruktur bleiben; ganze Kronen f\u00e4rben sich \u00e4hnlich\n float tone = clamp(vnoise(uv * vec2(6.5, 3.0) + 3.1) * 1.3 - 0.2 + (L - Lb) * 2.0, 0.0, 1.0);\n vec3 fall = hsv2rgb(vec3(mix(0.012, 0.12, tone), clamp(hsv.y * 0.75 + 0.5, 0.0, 0.92), min(1.0, hsv.z * 1.16 + 0.015)));\n vec3 lawnFall = hsv2rgb(vec3(mix(hsv.x, 0.13, 0.5), hsv.y * 0.8, hsv.z * 1.02));\n vec3 autumn = mix(fall, lawnFall, lawnF);\n // Winter: Kronen k\u00fchl und ents\u00e4ttigt mit einem Hauch Raureif, der Rasen stumpf und br\u00e4unlich\n vec3 treeW = mix(vec3(L) * vec3(0.84, 0.88, 0.96), vec3(0.8, 0.85, 0.92), 0.16 * smoothstep(0.04, 0.25, L));\n vec3 lawnW = hsv2rgb(vec3(mix(hsv.x, 0.13, 0.45), hsv.y * 0.45, hsv.z * 0.92));\n vec3 winter = mix(treeW, lawnW, lawnF);\n\n vec3 seasonal = spring * uSeason.x + summer * uSeason.y + autumn * uSeason.z + winter * uSeason.w;\n vec3 col = mix(c, seasonal, veg);\n\n // Schnee (im Zeitraffer im Winter, live nur bei echtem Schnee): Hecken und Rasen tragen Schnee, Wege, Stra\u00dfe und\n // Gehsteige liegen unter einer geschlossenen Schneedecke (Bordsteine und Schatten bleiben sichtbar)\n col = mix(col, snowLit(Lb, L), uSnowCover * veg * max(lawnF, hedge * 0.82));\n col = mix(col, snowLit(Lb, L), uSnowCover * ground * 0.92);\n\n // Schneepolster auf D\u00e4chern, Br\u00fcstungen, Mauern und Laternen \u2013 mit leichter Schattierung f\u00fcr Volumen;\n // \u00fcber dem Dachumriss (im Himmel) besteht der Pixel nur aus Schnee\n col *= 1.0 - sn.g * 0.5 * uSnowCover;\n vec3 snowC = mix(vec3(0.74, 0.8, 0.9), vec3(0.97, 0.985, 1.0), smoothstep(0.05, 0.8, sn.b));\n col = mix(col, snowC, tx.a < 0.5 ? 1.0 : sA);\n\n // N\u00e4sse nach Regen: dunkler, satter Boden\n col = mix(col, col * 0.68, uWet * max(ground, veg * lawnF) * 0.8);\n\n // Tags\u00fcber: die im Rendering leuchtenden Laternen ausschalten\n float lampMask = 0.0;\n for (int i = 0; i < ${LAMPS.length}; i++) {\n vec4 lp = uLamps[i];\n float d = length((uv - lp.xy) * ar) / lp.z;\n lampMask = max(lampMask, smoothstep(1.9, 0.6, d));\n }\n col = mix(col, vec3(0.6, 0.62, 0.66) * (0.55 + 0.35 * L), lampMask * smoothstep(0.5, 0.85, L) * uLampDay);\n\n // Grundlicht\n vec3 lit = col * uAmb;\n float Lg = dot(lit, vec3(0.2126, 0.7152, 0.0722));\n lit = mix(vec3(Lg), lit, uSat);\n\n // Kunstlicht (bei D\u00e4mmerung und Nacht; am Tag nur Fenster, die jemand per Fingertipp eingeschaltet hat)\n if (uNight > 0.005 || uManual > 0.5) {\n // Fenster: weiches Zimmerlicht statt harter Fl\u00e4chen. Die F\u00fcllung hat weiche Kanten, rundherum liegt ein\n // warmer Schein auf Rahmen, Laibung und Wand; im Zimmer macht eine Lampe einen helleren Bereich\n vec4 w1 = texture2D(uIds, uv), w2 = texture2D(uIds2, uv);\n float id1 = floor(w1.r * 255.0 + 0.5), id2 = floor(w2.r * 255.0 + 0.5);\n float inWin = 0.0;\n if (id1 > 0.5) {\n vec4 d1 = texture2D(uWin, vec2((id1 + 0.5) / 64.0, 0.25));\n if (d1.a > 0.003) {\n vec4 s1 = texture2D(uWin, vec2((id1 + 0.5) / 64.0, 0.75));\n // Pflanzen vor dem Fenster bleiben als Silhouette stehen\n float occ = 1.0 - step(0.5, s1.r) * smoothstep(0.25, 0.55, mk.r);\n vec2 q = (vec2(w2.a, w1.a) - s1.gb) * vec2(1.9, 2.4);\n // Grundhelligkeit h\u00f6her als vorher (0,52): Die Ecken der Scheibe fallen zur Lampe hin weniger ab\n float b = 0.64 + 0.22 * (1.0 - w1.a) + 0.4 * exp(-dot(q, q));\n b *= 0.9 + 0.22 * smoothstep(0.5, 0.85, L); // helle Vorh\u00e4nge leuchten mit\n b *= mix(1.0, 0.72 + 0.4 * smoothstep(0.12, 0.45, L), s1.a); // Loggien: Scheiben heller als Laibung\n // In der D\u00e4mmerung spiegelt das Glas noch den Himmel, erst nachts wirkt das Zimmerlicht voll\n inWin = w1.g * d1.a * occ * mix(0.5, 0.95, uDeep) * mix(1.35, 1.0, uNight); // am Tag (nur per Fingertipp) kr\u00e4ftiger, sonst s\u00e4he man es kaum\n lit = mix(lit, d1.rgb * b * 1.12, inWin);\n lit += d1.rgb * (w1.b * d1.a * occ) * 0.4 * (1.0 - inWin);\n }\n }\n if (id2 > 0.5) {\n vec4 d2 = texture2D(uWin, vec2((id2 + 0.5) / 64.0, 0.25));\n lit += d2.rgb * (w2.b * d2.a) * 0.4 * (1.0 - inWin);\n }\n\n vec3 light = vec3(0.0), glow = vec3(0.0);\n for (int i = 0; i < ${LAMPS.length}; i++) {\n vec4 lp = uLamps[i];\n if (lp.w < 0.005) continue;\n vec2 d = (uv - lp.xy) * ar;\n float r = length(d) / lp.z;\n glow += vec3(1.0, 0.78, 0.5) * lp.w * (1.3 / (1.0 + r * r * 1.6) + 0.12 / (1.0 + r * 0.35));\n // Lichtkegel am Boden unter der Laterne\n vec2 pd = vec2(d.x / (lp.z * 9.0), (d.y - lp.z * 12.5) / (lp.z * 5.0));\n light += vec3(1.0, 0.74, 0.45) * lp.w * exp(-dot(pd, pd)) * 1.4;\n }\n for (int i = 0; i < ${WALL.length}; i++) {\n vec3 wl = uWall[i];\n if (wl.z < 0.005) continue;\n vec2 d = (uv - wl.xy) * ar;\n float r = length(d) * 260.0;\n glow += vec3(1.0, 0.8, 0.55) * wl.z * (0.9 / (1.0 + r * r * 1.4));\n // schmaler Lichtf\u00e4cher nach oben und unten an der Wand (q * q statt pow: pow mit negativer Basis ist in\n // GLSL undefiniert und kann auf manchen GPUs NaN = schwarze Pixel liefern)\n float q = d.x * 150.0;\n light += vec3(1.0, 0.76, 0.5) * wl.z * exp(-q * q) * exp(-abs(d.y) * 26.0) * 0.9;\n }\n lit += col * light + glow;\n }\n\n lit = mix(lit, uFogCol, uFog * 0.5);\n lit += col * uFlash * 0.55;\n float outA = max(tx.a, sA);\n gl_FragColor = vec4(lit * outA, outA);\n }`;\n\n /* ==========================================================================\n ERZEUGEN\n ========================================================================== */\n function create({ hero, img, reduced = false, mobile = false, mode = 'zeitraffer', onChange, onFlash } = {}) {\n if (!hero || !img) return null;\n const skyHost = hero.querySelector('.hero__sky .hero__m');\n const buildHost = hero.querySelector('.hero__build .hero__m');\n const wxHost = hero.querySelector('.hero__wx');\n if (!skyHost || !buildHost || !wxHost) return null;\n\n const skyCv = document.createElement('canvas');\n skyCv.className = 'sky__gl';\n const birdCv = document.createElement('canvas');\n birdCv.className = 'sky__birds';\n const buildCv = document.createElement('canvas');\n buildCv.className = 'build__gl';\n const wxCv = document.createElement('canvas');\n wxCv.className = 'wx__cv';\n [skyCv, birdCv, buildCv, wxCv].forEach(c => c.setAttribute('aria-hidden', 'true'));\n\n let S, B;\n try {\n S = glSetup(skyCv, SKY_FRAG(7), false);\n B = glSetup(buildCv, BUILD_FRAG, true);\n } catch (err) { console.warn('[KL sky]', err); return null; }\n if (!S || !B) return null;\n skyHost.appendChild(skyCv);\n skyHost.appendChild(birdCv);\n buildHost.appendChild(buildCv);\n wxHost.appendChild(wxCv);\n const b2 = birdCv.getContext('2d'), w2 = wxCv.getContext('2d');\n\n // ---------- Texturen der H\u00e4user ----------\n const g = B.gl;\n const texImg = texture(g, 0, g.LINEAR), texIds = texture(g, 1, g.NEAREST), texWin = texture(g, 2, g.NEAREST), texMask = texture(g, 3, g.LINEAR), texIds2 = texture(g, 4, g.NEAREST), texSnow = texture(g, 5, g.LINEAR);\n // Fenster-Tabelle (64 \u00d7 2): Zeile 1 wechselt jedes Bild (Farbe \u00d7 Helligkeit, Schaltzustand),\n // Zeile 2 ist fest (Pflanzen davor, Lage der Lampe im Zimmer, schr\u00e4ge Fassade)\n const winData = new Uint8Array(64 * 2 * 4);\n for (let i = 0; i < WINDOW_COUNT; i++) {\n const o = (64 + i + 1) * 4, side = i >= FRONT.length;\n winData[o] = (side ? SIDE[i - FRONT.length][6] : FRONT[i][6]) ? 255 : 0;\n winData[o + 1] = Math.round((0.18 + hash(i + 91) * 0.64) * 255);\n winData[o + 2] = Math.round((0.2 + hash(i + 101) * 0.45) * 255);\n winData[o + 3] = side ? 255 : 0;\n }\n g.activeTexture(g.TEXTURE2);\n g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, 64, 2, 0, g.RGBA, g.UNSIGNED_BYTE, winData);\n g.uniform1i(B.u('uImg'), 0); g.uniform1i(B.u('uIds'), 1); g.uniform1i(B.u('uWin'), 2); g.uniform1i(B.u('uMask'), 3); g.uniform1i(B.u('uIds2'), 4); g.uniform1i(B.u('uSnow'), 5);\n let texW = 0, texH = 0, texReady = false;\n\n // Bild in genau der Canvas-Aufl\u00f6sung hochladen (sauber verkleinert, kein Flimmern in den Lochblechen).\n // Die weichen Hilfsdaten (Fensterlicht, Masken, Schnee) entstehen in h\u00f6chstens DATA_DPR: Der Shader liest alle\n // Texturen \u00fcber dieselben relativen Koordinaten, und so bleibt die Vorberechnung beim Laden gleich schnell,\n // auch wenn das Bild selbst auf dem Handy in 2\u00d7 gerendert wird.\n let dataKey = '';\n const drawScaled = (W, H) => {\n const c = document.createElement('canvas');\n c.width = W; c.height = H;\n const cx = c.getContext('2d');\n cx.imageSmoothingEnabled = true;\n cx.imageSmoothingQuality = 'high';\n cx.drawImage(img, 0, 0, W, H);\n return c;\n };\n function uploadBuilding(W, H) {\n if (!img.complete || !img.naturalWidth) return false;\n const sc = drawScaled(W, H);\n // Die Hilfsdaten h\u00e4ngen nur an der CSS-Gr\u00f6\u00dfe, nicht an der Qualit\u00e4tsstufe: Ein Stufenwechsel l\u00e4dt\n // blo\u00df das Bild neu, statt Fensterlicht, Maske und Schnee (auf dem Handy sp\u00fcrbar langsam) neu zu rechnen.\n const dd = Math.min(DATA_DPR, window.devicePixelRatio || 1);\n const DW = Math.max(2, Math.round(buildCv.offsetWidth * dd)), DH = Math.max(2, Math.round(buildCv.offsetHeight * dd));\n // Hilfs-Canvases nach dem Hochladen freigeben: Safari z\u00e4hlt sie sonst weiter gegen seine Canvas-Speichergrenze\n let sd = null;\n const free = () => { sc.width = sc.height = 0; if (sd) sd.width = sd.height = 0; };\n try {\n g.activeTexture(g.TEXTURE0); g.bindTexture(g.TEXTURE_2D, texImg);\n g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, g.RGBA, g.UNSIGNED_BYTE, sc);\n if (dataKey !== DW + 'x' + DH) { // gleiche Datenaufl\u00f6sung: nichts neu rechnen\n sd = DW === W && DH === H ? sc : drawScaled(DW, DH);\n const ids = windowSoft(DW, DH);\n g.activeTexture(g.TEXTURE1); g.bindTexture(g.TEXTURE_2D, texIds);\n g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, DW, DH, 0, g.RGBA, g.UNSIGNED_BYTE, ids.t1);\n g.activeTexture(g.TEXTURE4); g.bindTexture(g.TEXTURE_2D, texIds2);\n g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, DW, DH, 0, g.RGBA, g.UNSIGNED_BYTE, ids.t2);\n const mask = buildMask(sd, DW, DH);\n flySpots = spotsFrom(mask);\n g.activeTexture(g.TEXTURE3); g.bindTexture(g.TEXTURE_2D, texMask);\n g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, mask.w, mask.h, 0, g.RGBA, g.UNSIGNED_BYTE, mask.data);\n g.activeTexture(g.TEXTURE5); g.bindTexture(g.TEXTURE_2D, texSnow);\n g.texImage2D(g.TEXTURE_2D, 0, g.RGBA, DW, DH, 0, g.RGBA, g.UNSIGNED_BYTE, snowData(sd, DW, DH));\n dataKey = DW + 'x' + DH;\n }\n } catch (err) { console.warn('[KL sky] texture', err); free(); return false; }\n free();\n texW = W; texH = H;\n return true;\n }\n\n // Weiches Fensterlicht, einmal beim Laden berechnet: Jede Scheibe wird gerastert und zweimal\n // weichgezeichnet \u2013 schwach f\u00fcr die F\u00fcllung (weiche Kanten, Sprossen nur angedeutet) und stark f\u00fcr den\n // Lichtschein rundherum. Je Pixel werden die zwei n\u00e4chstgelegenen Fenster gemerkt, damit sich die\n // Scheine benachbarter Fenster sauber \u00fcberlagern.\n // t1: R Fenster-Nr., G F\u00fcllung, B Schein, A H\u00f6he im Fenster \u00b7 t2: R zweites Fenster, B dessen Schein, A Breite im Fenster\n function windowSoft(W, H) {\n const t1 = new Uint8Array(W * H * 4), t2 = new Uint8Array(W * H * 4);\n const w1 = new Float32Array(W * H), w2 = new Float32Array(W * H);\n const base = W / 2250;\n const rFill = Math.max(1, Math.round(3 * base)), rHalo = Math.max(3, Math.round(11 * base));\n const shapes = [];\n FRONT.forEach((f, n) => {\n const x0 = f[0] * W, y0 = f[1] * H, x1 = f[2] * W, ybl = f[3] * H, ybr = f[4] * H;\n const mull = f[5].map(m => [m[0] * W, m[1] * W]);\n // Oberkante waagerecht (Fenstersturz), Unterkante folgt der davorstehenden Br\u00fcstung\n shapes.push({ id: n + 1, x0, x1, ymin: y0, ymax: Math.max(ybl, ybr), top: () => y0, bot: t => ybl + (ybr - ybl) * t, gap: cx => mull.some(m => cx >= m[0] && cx <= m[1]) });\n });\n SIDE.forEach((s, n) => {\n // Schr\u00e4ge Fassaden: Parallelogramm mit perspektivischer Ober- und Unterkante\n shapes.push({ id: FRONT.length + n + 1, x0: s[0] * W, x1: s[1] * W, ymin: Math.min(s[2], s[3]) * H, ymax: Math.max(s[4], s[5]) * H,\n top: t => (s[2] + (s[3] - s[2]) * t) * H, bot: t => (s[4] + (s[5] - s[4]) * t) * H, gap: () => false });\n });\n for (const sh of shapes) {\n const m = rHalo * 3 + 2;\n const X0 = Math.max(0, Math.floor(sh.x0) - m), X1 = Math.min(W, Math.ceil(sh.x1) + m);\n const Y0 = Math.max(0, Math.floor(sh.ymin) - m), Y1 = Math.min(H, Math.ceil(sh.ymax) + m);\n const w = X1 - X0, h = Y1 - Y0, span = Math.max(1, sh.x1 - sh.x0);\n if (w <= 0 || h <= 0) continue;\n // Abdeckung je Pixel genau (4 Unterspalten, senkrecht exakt), damit das Licht bis an den Rahmen und in die Ecken\n // reicht. Vorher ganze Pixel (je Rand bis zu 1 px weniger) und danach zweimal weichgezeichnet: In den Ecken kam\n // nur ein Viertel an, man sah unbeleuchtetes Glas (Kumpel, 27.09.). Jetzt das Maximum aus scharfer F\u00fcllung (innen\n // voll bis in die Ecken) und der weichen von vorher (au\u00dfen l\u00e4uft das Licht wie gehabt weich \u00fcber den Rahmen, das\n // ist der warme Schein; nur scharf fehlte er, User 27.09.)\n const cover = new Float32Array(w * h), SUB = 4;\n for (let x = Math.max(X0, Math.floor(sh.x0)); x < Math.min(X1, Math.ceil(sh.x1)); x++) {\n for (let k = 0; k < SUB; k++) {\n const cx = x + (k + 0.5) / SUB;\n if (cx < sh.x0 || cx > sh.x1 || sh.gap(cx)) continue;\n const t = (cx - sh.x0) / span, yt = sh.top(t), yb = sh.bot(t);\n for (let y = Math.max(Y0, Math.floor(yt)); y < Math.min(Y1, Math.ceil(yb)); y++) {\n const c = Math.min(y + 1, yb) - Math.max(y, yt); // senkrechter Anteil dieses Pixels innerhalb der Scheibe\n if (c > 0) cover[(y - Y0) * w + (x - X0)] += c / SUB;\n }\n }\n }\n const fill = boxBlur(cover, w, h, 1), soft = boxBlur(boxBlur(cover, w, h, rFill), w, h, rFill);\n for (let k = 0; k < fill.length; k++) if (soft[k] > fill[k]) fill[k] = soft[k];\n const halo = boxBlur(boxBlur(boxBlur(cover, w, h, rHalo), w, h, rHalo), w, h, rHalo);\n for (let yy = 0; yy < h; yy++) {\n for (let xx = 0; xx < w; xx++) {\n const li = yy * w + xx, fv = fill[li], hv = halo[li], wt = Math.max(fv, hv);\n if (wt < 0.004) continue;\n const x = X0 + xx, y = Y0 + yy, p = y * W + x, i = p * 4;\n const t = clamp01((x + 0.5 - sh.x0) / span), yt = sh.top(t), yb = sh.bot(t);\n const v = clamp01((y + 0.5 - yt) / Math.max(1, yb - yt));\n if (wt > w1[p]) {\n if (w1[p] > 0) { w2[p] = w1[p]; t2[i] = t1[i]; t2[i + 2] = t1[i + 2]; }\n w1[p] = wt;\n t1[i] = sh.id; t1[i + 1] = Math.round(fv * 255); t1[i + 2] = Math.round(hv * 255); t1[i + 3] = Math.round(v * 255);\n t2[i + 3] = Math.round(t * 255);\n } else if (wt > w2[p]) {\n w2[p] = wt; t2[i] = sh.id; t2[i + 2] = Math.round(hv * 255);\n }\n }\n }\n }\n return { t1, t2 };\n }\n\n // Masken f\u00fcr die Jahreszeiten, in halber Aufl\u00f6sung und gegl\u00e4ttet \u2013 damit Laub und Schnee\n // sauber \u00fcber ganze Fl\u00e4chen laufen statt pixelweise zu flackern\n // Gl\u00fchw\u00fcrmchen-Pl\u00e4tze: Pflanzen in der unteren Bildh\u00e4lfte (Hecken, Rasen, B\u00fcsche vor den H\u00e4usern), aus der Maske\n // einmal gesammelt (relative Bildkoordinaten), gemischt und begrenzt\n let flySpots = [];\n function spotsFrom(mask) {\n const { w, h, data } = mask, out = [];\n for (let y = Math.floor(h * 0.6); y < h - 2; y += 3) {\n for (let x = 2; x < w - 2; x += 3) if (data[(y * w + x) * 4] > 120) out.push([x / w, y / h]);\n }\n for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [out[i], out[j]] = [out[j], out[i]]; }\n return out.slice(0, 900);\n }\n function buildMask(src, W, H) {\n const w = Math.max(2, Math.round(W / 2)), h = Math.max(2, Math.round(H / 2)), N = w * h;\n const cv = document.createElement('canvas');\n cv.width = w; cv.height = h;\n const cx = cv.getContext('2d', { willReadFrequently: true });\n cx.imageSmoothingQuality = 'high';\n cx.drawImage(src, 0, 0, w, h);\n const px = cx.getImageData(0, 0, w, h).data;\n cv.width = cv.height = 0; // Hilfs-Canvas freigeben (Safari beh\u00e4lt es sonst im Canvas-Speicher)\n const veg = new Float32Array(N), lum = new Float32Array(N), alpha = new Float32Array(N);\n for (let i = 0; i < N; i++) {\n const r = px[i * 4] / 255, gg = px[i * 4 + 1] / 255, b = px[i * 4 + 2] / 255, a = px[i * 4 + 3] / 255;\n const mx = Math.max(r, gg, b), ch = mx - Math.min(r, gg, b);\n let hue = 0;\n if (ch > 1e-5) {\n hue = mx === r ? ((gg - b) / ch) % 6 : mx === gg ? (b - r) / ch + 2 : (r - gg) / ch + 4;\n hue /= 6;\n if (hue < 0) hue += 1;\n }\n alpha[i] = a;\n lum[i] = (0.2126 * r + 0.7152 * gg + 0.0722 * b) * a;\n // Gr\u00fcnt\u00f6ne; bei dunklem Laub z\u00e4hlt die relative S\u00e4ttigung, weil der absolute Farbanteil klein ist\n const colorful = Math.max(sstep(0.012, 0.05, ch), sstep(0.16, 0.32, ch / Math.max(mx, 1e-4)) * sstep(0.015, 0.05, mx));\n veg[i] = a > 0.5 ? sstep(0.14, 0.2, hue) * (1 - sstep(0.47, 0.53, hue)) * colorful : 0;\n }\n // weiche Helligkeit nur aus Bildpixeln (der transparente Himmel z\u00e4hlt nicht mit \u2013 sonst leuchten Kronenr\u00e4nder)\n const vegB = boxBlur(veg, w, h, 2), lumS = boxBlur(lum, w, h, 5), alphaS = boxBlur(alpha, w, h, 5);\n const lumB = lumS.map((v, i) => v / Math.max(alphaS[i], 0.05));\n // Pflanzenanteil nur unter Bildpixeln gemittelt \u2013 am Kronenrand z\u00e4hlt der Himmel nicht mit\n const vegS = boxBlur(veg, w, h, 3), alphaV = boxBlur(alpha, w, h, 3);\n // Baumkronen als Ganzes (auch dunkle Schattenpartien), damit Farbwechsel nicht fleckig wirken\n const vegR = boxBlur(veg, w, h, 6), alphaR = boxBlur(alpha, w, h, 6);\n const region = vegR.map((v, i) => sstep(0.2, 0.45, v / Math.max(alphaR[i], 0.05)) * (alpha[i] > 0.5 ? 1 : 0));\n const data = new Uint8Array(N * 4);\n for (let i = 0; i < N; i++) {\n data[i * 4] = Math.round(vegB[i] * 255);\n data[i * 4 + 1] = Math.round(region[i] * 255);\n data[i * 4 + 3] = Math.round(Math.min(1, lumB[i]) * 255);\n }\n return { w, h, data };\n }\n\n // Schneepolster in voller Aufl\u00f6sung: R Deckung, G Schatten darunter, B H\u00f6he im Polster (0 unten \u2026 1 oben).\n // Die Polster liegen auf den vermessenen Kanten (SNOW) und auf allen Dachkanten gegen den Himmel, die hier\n // aus dem Umriss erkannt werden. Bei D\u00e4chern ragt der Schnee \u00fcber den Umriss hinaus in den Himmel.\n function snowData(src, W, H) {\n const d = new Uint8Array(W * H * 4);\n const s = W / 2250;\n const wave = x => 0.84 + 0.1 * Math.sin(x * 0.09 / s) + 0.06 * Math.sin(x * 0.031 / s + 1.7);\n const pile = (xa, ya, xb, yb, hgt, below) => {\n if (xb < xa) [xa, ya, xb, yb] = [xb, yb, xa, ya];\n const len = Math.max(1, xb - xa), taper = Math.min(len * 0.5, Math.max(2, hgt * 1.6)), sh = Math.max(1.5, hgt * 0.45);\n for (let x = Math.max(0, Math.floor(xa)); x <= Math.min(W - 1, Math.ceil(xb)); x++) {\n const cx = x + 0.5;\n if (cx < xa || cx > xb) continue;\n const ye = ya + (yb - ya) * (cx - xa) / len;\n const e = Math.min(1, Math.min(cx - xa, xb - cx) / taper);\n const round = Math.sqrt(Math.max(0, 1 - (1 - e) * (1 - e))); // abgerundete Enden\n const top = ye - hgt * wave(x) * round, bot = ye + below;\n for (let y = Math.max(0, Math.floor(top)); y <= Math.min(H - 1, Math.floor(bot)); y++) {\n const cov = Math.max(0, Math.min(y + 1, bot) - Math.max(y, top));\n if (cov <= 0) continue;\n const i = (y * W + x) * 4, v = Math.round(cov * 255);\n if (v > d[i]) { d[i] = v; d[i + 2] = Math.round(clamp01((bot - (y + 0.5)) / Math.max(1, bot - top)) * 255); }\n }\n for (let k = 0; k < sh; k++) { // leichter Schatten unter dem Polster\n const y = Math.floor(bot) + 1 + k;\n if (y >= H) break;\n const i = (y * W + x) * 4 + 1;\n d[i] = Math.max(d[i], Math.round(110 * (1 - k / sh) * round));\n }\n }\n };\n // vermessene Kanten (DEPTH: Dicke der Polster, 1 = knapp 10 cm)\n const DEPTH = 1.7;\n for (const [x0, y0, x1, y1, h] of SNOW) pile(x0 * W, y0 * H, x1 * W, y1 * H, h * H * DEPTH, Math.max(1, 1.2 * s));\n\n // Dachkanten gegen den Himmel: obere Umrisskanten, die gerade verlaufen (keine Baumkronen)\n const cx = document.createElement('canvas');\n cx.width = W; cx.height = H;\n const c2 = cx.getContext('2d', { willReadFrequently: true });\n c2.drawImage(src, 0, 0);\n const px = c2.getImageData(0, 0, W, H).data;\n cx.width = cx.height = 0; // freigeben\n const green = i => {\n const r = px[i], g = px[i + 1], b = px[i + 2], mx = Math.max(r, g, b), ch = mx - Math.min(r, g, b);\n return g >= r && g >= b && ch > 12 && ch / Math.max(1, mx) > 0.12;\n };\n const edges = Array.from({ length: W }, () => []);\n for (let x = 0; x < W; x++) {\n for (let y = 1; y < H - 3; y++) {\n const i = (y * W + x) * 4;\n if (px[i + 3] >= 128 && px[i - W * 4 + 3] < 128) {\n let g = 0;\n for (let k = 1; k <= 3; k++) if (green(i + k * W * 4)) g++;\n if (g < 2) edges[x].push(y);\n }\n }\n }\n const runs = [], open = [];\n for (let x = 0; x < W; x++) {\n const next = [];\n for (const y of edges[x]) {\n let best = null;\n for (const r of open) if (r.lx === x - 1 && Math.abs(r.ly - y) <= 1 && (!best || Math.abs(r.ly - y) < Math.abs(best.ly - y))) best = r;\n if (best && !best.used) { best.used = true; best.pts.push([x, y]); best.lx = x; best.ly = y; next.push(best); }\n else next.push({ pts: [[x, y]], lx: x, ly: y });\n }\n for (const r of open) if (!next.includes(r)) runs.push(r);\n for (const r of next) r.used = false;\n open.length = 0; open.push(...next);\n }\n runs.push(...open);\n const minLen = Math.max(10, 14 * s), hRoof = 0.0061 * H * DEPTH;\n for (const r of runs) {\n const n = r.pts.length;\n if (n < minLen) continue;\n let sx = 0, sy = 0, sxx = 0, sxy = 0;\n for (const [x, y] of r.pts) { sx += x; sy += y; sxx += x * x; sxy += x * y; }\n const den = n * sxx - sx * sx, a = Math.abs(den) > 1e-9 ? (n * sxy - sx * sy) / den : 0, b = (sy - a * sx) / n;\n let res = 0;\n for (const [x, y] of r.pts) res += Math.abs(y - (a * x + b));\n if (res / n > 0.9 || Math.abs(a) > 0.8) continue; // ausgefranst oder zu steil: Baum, keine Dachkante\n const x0 = r.pts[0][0], x1 = r.pts[n - 1][0] + 1;\n pile(x0, a * x0 + b, x1, a * x1 + b, hRoof, Math.max(1, 1.5 * s));\n }\n return d;\n }\n function boxBlur(src, w, h, r) {\n const tmp = new Float32Array(w * h), out = new Float32Array(w * h), inv = 1 / (2 * r + 1);\n for (let y = 0; y < h; y++) {\n const row = y * w;\n let acc = 0;\n for (let x = -r; x <= r; x++) acc += src[row + Math.min(w - 1, Math.max(0, x))];\n for (let x = 0; x < w; x++) {\n tmp[row + x] = acc * inv;\n acc += src[row + Math.min(w - 1, x + r + 1)] - src[row + Math.max(0, x - r)];\n }\n }\n for (let x = 0; x < w; x++) {\n let acc = 0;\n for (let y = -r; y <= r; y++) acc += tmp[Math.min(h - 1, Math.max(0, y)) * w + x];\n for (let y = 0; y < h; y++) {\n out[y * w + x] = acc * inv;\n acc += tmp[Math.min(h - 1, y + r + 1) * w + x] - tmp[Math.max(0, y - r) * w + x];\n }\n }\n return out;\n }\n\n // ---------- Gr\u00f6\u00dfen ----------\n // Qualit\u00e4tsstufen: Obergrenze der Pixeldichte und Aufl\u00f6sung des Himmels (Anteil davon). Kommt das Ger\u00e4t nicht\n // fl\u00fcssig mit, geht es eine Stufe runter (2 \u2192 1,5 \u2192 1). Fr\u00fcher Desktop h\u00f6chstens 1,5 und Himmel 50 %: Auf\n // Retina-Bildschirmen wurden Wolken und Wetter dadurch mit gut einem Drittel der Aufl\u00f6sung gerechnet und wirkten weich.\n // Die weichen Hilfsdaten des Geb\u00e4udes bleiben bei h\u00f6chstens DATA_DPR.\n const TIERS = [{ dpr: 2, sky: 0.7 }, { dpr: 1.5, sky: 0.6 }, { dpr: 1, sky: 0.5 }];\n const DATA_DPR = mobile ? 1.25 : 1.5;\n let tier = 0, lowPower = false; // lowPower: unterste Stufe erreicht\n const DPR = () => Math.min(window.devicePixelRatio || 1, TIERS[tier].dpr);\n let skyScale = TIERS[0].sky;\n let SW = 0, SH = 0, BW = 0, BH = 0, WW = 0, WH = 0, cssW = 0, cssH = 0, wxW = 0, wxH = 0;\n function resize() {\n const d = DPR();\n cssW = skyCv.offsetWidth; cssH = skyCv.offsetHeight;\n SW = Math.max(2, Math.round(cssW * d * skyScale)); SH = Math.max(2, Math.round(cssH * d * skyScale));\n if (skyCv.width !== SW || skyCv.height !== SH) { skyCv.width = SW; skyCv.height = SH; }\n const bw = Math.round(cssW * d), bh = Math.round(cssH * d);\n if (birdCv.width !== bw || birdCv.height !== bh) { birdCv.width = bw; birdCv.height = bh; }\n BW = Math.max(2, Math.round(buildCv.offsetWidth * d)); BH = Math.max(2, Math.round(buildCv.offsetHeight * d));\n if (buildCv.width !== BW || buildCv.height !== BH) { buildCv.width = BW; buildCv.height = BH; }\n wxW = wxCv.offsetWidth; wxH = wxCv.offsetHeight;\n skyOff.x = skyCv.offsetLeft; skyOff.y = skyCv.offsetTop;\n WW = Math.max(2, Math.round(wxW * d)); WH = Math.max(2, Math.round(wxH * d));\n if (wxCv.width !== WW || wxCv.height !== WH) { wxCv.width = WW; wxCv.height = WH; }\n // L\u00e4dt Safari nach dem Drehen gerade ein anderes Bild aus dem srcset (img.complete ist dann false), schlug das\n // Hochladen fehl und das Geb\u00e4ude verschwand: Das bisherige Bild bleibt stehen, das neue kommt mit seinem load\n if (BW !== texW || BH !== texH) texReady = uploadBuilding(BW, BH) || texReady;\n }\n\n // ---------- Zustand ----------\n const clock = createClock({ season: 1, hour: 18.3 });\n\n // ---------- Karlshorst live ----------\n // mode 'start': Der Hero beginnt mit der echten Uhrzeit, Jahreszeit, Sonne, Mondphase und dem echten Wetter in\n // Karlshorst und geht nach release() in den Zeitraffer \u00fcber; das Echte blendet \u00fcber einige Zeitraffer-Stunden aus.\n // mode 'live': bleibt echt (Uhr in Echtzeit, Wetter alle zehn Minuten neu). mode 'zeitraffer': wie fr\u00fcher (Sommer, 18:18).\n // Wetter: Deutscher Wetterdienst \u00fcber Bright Sky (offene Daten, frei auch f\u00fcr gewerbliche Seiten, Quelle nennen).\n // Open-Meteo w\u00e4re f\u00fcr gewerbliche Seiten kostenpflichtig.\n let released = mode === 'zeitraffer', relT = 0, sinceRel = 0, liveTemp = null, nextSync = 0, liveDay = '';\n const WX_URL = 'https://api.brightsky.dev/current_weather?lat=' + HOME.lat + '&lon=' + HOME.lon + '&tz=Europe%2FBerlin';\n let wxAsked = 0;\n function fetchWeather() {\n if (!window.fetch) return;\n wxAsked = Date.now();\n // h\u00f6chstens 4 s warten, danach bleibt es beim neutralen Wetter (wxNone)\n const ac = window.AbortController ? new AbortController() : null, to = ac && setTimeout(() => ac.abort(), 4000);\n fetch(WX_URL, ac ? { signal: ac.signal } : undefined).then(r => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))).then(j => {\n const x = j && j.weather, L = clock.live;\n if (!x || !L) return;\n const txt = (x.condition || '') + (x.icon || '');\n const next = Object.assign(wxFromDWD(x, L.w), {\n // Schneeregen deckt nur um den Gefrierpunkt wei\u00df zu (bei +3 \u00b0C bleibt der Boden gr\u00fcn)\n snowCover: /snow/.test(txt) || (/sleet/.test(txt) && x.temperature != null && x.temperature <= 0.5) ? 1 : 0\n });\n L.wxFrom = liveWx(L); L.wxAt = performance.now(); // vom gerade Sichtbaren aus weich \u00fcberblenden\n L.wx = next;\n liveTemp = x.temperature;\n lastLabel = ''; dirty = true;\n }).catch(err => console.info('[KL sky] Live-Wetter nicht verf\u00fcgbar', err)).finally(() => clearTimeout(to));\n }\n // Echte Uhrzeit, Tag und Mond \u00fcbernehmen (einmal pro Sekunde reicht)\n function syncLive(force) {\n const L = clock.live;\n if (!L || (!force && performance.now() < nextSync)) return;\n nextSync = performance.now() + 1000;\n // mit Zeitzonen-Versatz: am Tag der Zeitumstellung \u00e4ndern sich Sonnenauf- und -untergang um 3 Uhr um eine Stunde\n const b = berlinNow(), key = b.y + '-' + b.m + '-' + b.d + '/' + b.off;\n if (key !== liveDay) {\n liveDay = key;\n const sun = sunDay(b.y, b.m, b.d, b.off);\n Object.assign(L, { w: seasonOf(b.y, b.m, b.d), rise: sun.rise, set: sun.set, peak: sun.peak });\n }\n L.phase = moonPhaseAt(Date.now());\n clock.hour = b.h;\n }\n if (mode !== 'zeitraffer') {\n clock.live = { mix: 1, w: [0, 1, 0, 0], rise: 6, set: 20, peak: 0.6, phase: 0.5, wx: null, wxNone: null, wxFrom: null, wxAt: 0 };\n syncLive(true);\n const w = clock.live.w;\n // neutral: leicht bew\u00f6lkt, trocken, kaum Wind (bis echte Daten da sind oder falls sie ausbleiben)\n clock.live.wxNone = Object.assign(wxFromDWD({ cloud_cover: 35, condition: 'dry', icon: 'partly-cloudy-day', visibility: 20000, wind_speed_10: 8 }, w), { snowCover: 0 });\n clock.s0 = w.indexOf(Math.max(...w)); // danach geht der Zeitraffer mit der echten Jahreszeit weiter\n clock.moon0 = clock.live.phase - (clock.hour / 24 - 0.75) * clock.moonStep; // und mit der echten Mondphase\n fetchWeather();\n if (mode === 'live') setInterval(() => { if (!document.hidden) fetchWeather(); }, 10 * 60 * 1000);\n // Seite lange im Hintergrund (Tab noch nicht angesehen): beim Zur\u00fcckkommen neues Wetter, solange es live ist\n document.addEventListener('visibilitychange', () => {\n if (!document.hidden && clock.live && (mode === 'live' || !released) && Date.now() - wxAsked > 10 * 60 * 1000) fetchWeather();\n });\n }\n\n let env = environment(clock);\n let playing = !reduced, active = true, time = 0, evolve = 0, flash = 0, nextFlash = 3, manualOn = false;\n const drift = [0, 0];\n const WIN = Array.from({ length: WINDOW_COUNT }, (_, i) => {\n const k = hash(i + 61);\n return {\n thr: 0.12 + hash(i + 1) * 0.78,\n off: 22.2 + hash(i + 11) * 3.8,\n morning: hash(i + 21) < 0.42 ? 5.0 + hash(i + 31) * 2.4 : 99,\n owl: hash(i + 41) < 0.08,\n tv: hash(i + 51) < 0.07,\n col: k < 0.62 ? [1.0, 0.76, 0.48] : k < 0.9 ? [1.0, 0.87, 0.7] : [0.84, 0.9, 1.0],\n dim: 0.74 + hash(i + 81) * 0.3, // jedes Zimmer etwas anders hell (Stehlampe, Deckenlicht \u2026)\n lv: 0,\n autoOn: false, man: null, manAuto: false // man: per Fingertipp geschaltet (1 an, 0 aus)\n };\n });\n const lampLv = LAMPS.map(() => 0), wallLv = WALL.map(() => 0);\n const lampArr = new Float32Array(LAMPS.length * 4), wallArr = new Float32Array(WALL.length * 3);\n\n function lights(dt, k) {\n const dark = clamp01(sstep(0.1, -0.08, env.e) + env.wx.dark * env.day * 0.35);\n const hour = clock.hour, H = hour < 12 ? hour + 24 : hour;\n manualOn = false;\n WIN.forEach((w, i) => {\n let on;\n if (w.owl || H < w.off) on = dark > w.thr;\n else on = hour < 12 && hour >= w.morning && dark > 0.35;\n w.autoOn = on;\n // Per Fingertipp geschaltet: gilt, bis die Automatik von selbst umschalten w\u00fcrde (dann \u00fcbernehmen die Bewohner)\n if (w.man != null) { if (on !== w.manAuto) w.man = null; else on = w.man === 1; }\n if (w.man === 1) manualOn = true;\n const target = on ? 1 : 0;\n w.lv += (target - w.lv) * Math.min(1, (target > w.lv ? 0.3 : 0.1) * k);\n // RGB = Farbe \u00d7 Helligkeit des Zimmers, A = Schaltzustand (0 aus \u2026 1 an)\n let v = w.lv, col = w.col, bright = w.dim;\n if (target === 1 && w.lv < 0.92 && w.man == null) v *= hash(Math.floor(time * 24) + i * 7) > 0.35 ? 1 : 0.25; // Flackern beim Einschalten\n if (w.tv) { const f = 0.5 + 0.5 * Math.sin(time * 7 + i) * Math.sin(time * 13.3 + i * 2); col = mix3([0.55, 0.7, 1.0], [0.9, 0.9, 1.0], f); bright *= 0.6 + 0.4 * f; }\n winData[(i + 1) * 4] = Math.round(clamp01(col[0] * bright) * 255);\n winData[(i + 1) * 4 + 1] = Math.round(clamp01(col[1] * bright) * 255);\n winData[(i + 1) * 4 + 2] = Math.round(clamp01(col[2] * bright) * 255);\n winData[(i + 1) * 4 + 3] = Math.round(clamp01(v) * 255);\n });\n LAMPS.forEach((l, i) => {\n const target = dark > 0.28 + i * 0.035 ? 1 : 0;\n lampLv[i] += (target - lampLv[i]) * Math.min(1, 0.12 * k);\n let v = lampLv[i];\n if (target && v < 0.9) v *= hash(Math.floor(time * 18) + i * 13) > 0.4 ? 1 : 0.15;\n lampArr.set([l[0], l[1], l[2], v], i * 4);\n });\n WALL.forEach((p, i) => {\n const target = dark > 0.4 + hash(i + 71) * 0.25 ? 1 : 0;\n wallLv[i] += (target - wallLv[i]) * Math.min(1, 0.1 * k);\n wallArr.set([p[0], p[1], wallLv[i]], i * 3);\n });\n }\n\n // ---------- V\u00f6gel ----------\n // Kleine 3D-Silhouetten statt Striche: K\u00f6rper, Kopf, Schwanz und zwei Fl\u00fcgel mit Arm und Hand, die beim\n // Aufschlag einknicken. Gesehen von schr\u00e4g unten \u2013 je h\u00f6her am Himmel, desto steiler. Jede Art fliegt anders:\n // Spatzen im Bogenflug, Mauersegler mit Sichelfl\u00fcgeln in schnellen Kurven, Tauben im lockeren Schwarm,\n // Kr\u00e4hen mit gespreizten Handschwingen, G\u00e4nse in V-Formation, M\u00f6wen im Gleitflug mit abgeknickten Fl\u00fcgeln.\n // Blickrichtung und Bewegung passen immer zusammen: Die V\u00f6gel fliegen quer \u00fcbers Bild, leicht zum Betrachter\n // hin oder von ihm weg, mit sanften Kurven und Schr\u00e4glage \u2013 kein Kreisen auf der Stelle.\n const WING = { // Umriss eines Fl\u00fcgels: [Profil (vorn +), Spannweite 0 bis 1]\n rund: [[0.3, 0], [0.36, 0.28], [0.32, 0.5], [0.18, 0.78], [0.02, 0.95], [-0.14, 1], [-0.3, 0.93], [-0.42, 0.75], [-0.45, 0.5], [-0.4, 0.25], [-0.34, 0]],\n spitz: [[0.3, 0], [0.38, 0.3], [0.35, 0.48], [0.12, 0.8], [-0.1, 1], [-0.26, 0.86], [-0.4, 0.62], [-0.42, 0.35], [-0.34, 0]],\n finger: [[0.3, 0], [0.36, 0.3], [0.33, 0.5], [0.22, 0.72], [0.14, 0.84], [0.08, 1], [0, 0.86], [-0.08, 0.99], [-0.15, 0.85], [-0.23, 0.96], [-0.29, 0.83], [-0.37, 0.9], [-0.42, 0.76], [-0.48, 0.55], [-0.44, 0.3], [-0.36, 0]],\n sichel: [[0.25, 0], [0.35, 0.25], [0.3, 0.45], [0.06, 0.72], [-0.26, 0.93], [-0.46, 1], [-0.36, 0.88], [-0.28, 0.62], [-0.28, 0.3], [-0.25, 0]]\n };\n const TAIL = { // Schwanz in der Draufsicht: [l\u00e4ngs, quer]\n kurz: [[-0.4, 0.07], [-0.72, 0.1], [-0.76, 0], [-0.72, -0.1], [-0.4, -0.07]],\n faecher: [[-0.4, 0.08], [-0.85, 0.2], [-0.95, 0.12], [-0.98, 0], [-0.95, -0.12], [-0.85, -0.2], [-0.4, -0.08]],\n gabel: [[-0.4, 0.06], [-1.05, 0.2], [-0.78, 0.03], [-0.78, -0.03], [-1.05, -0.2], [-0.4, -0.06]],\n rund: [[-0.4, 0.08], [-0.85, 0.16], [-0.93, 0.08], [-0.95, 0], [-0.93, -0.08], [-0.85, -0.16], [-0.4, -0.08]]\n };\n // Ma\u00dfe relativ zur K\u00f6rperl\u00e4nge \u00b7 hz Fl\u00fcgelschl\u00e4ge/s \u00b7 amp Schlagweite \u00b7 fold Einknicken beim Aufschlag\n // glide Anteil Gleitflug \u00b7 dih/droop Haltung im Gleitflug (Arm / Hand) \u00b7 size K\u00f6rperl\u00e4nge in px (in der Bildmitte)\n // L K\u00f6rperl\u00e4nge in m \u00b7 V Reisegeschwindigkeit in m/s \u00b7 GR Gleitzahl (Sinken im Gleitflug) \u00b7 turn gr\u00f6\u00dfte Wenderate\n // in rad/s \u00b7 bank Schr\u00e4glage in Kurven \u00b7 path: Leitkurs des Schwarms \u00b7 ya/yw Kurven (Weite, Tempo)\n // pa/pw Steigen und Sinken (m, Tempo) \u00b7 gap Abstand im Schwarm in m\n const SPECIES = {\n spatz: { girth: 0.38, head: 0.2, neck: 0, tail: 'kurz', wing: 'rund', span: 0.95, chord: 0.48, sweep: 0.08, hz: 10, amp: 0.95, fold: 0.6, glide: 0, hop: true, dih: 0.1, droop: 0, L: 0.15, V: 8.5, GR: 4, turn: 2.2, bank: 0.8, size: [6, 8.5], flock: [3, 7], tone: [0.22, 0.18, 0.15], path: { ya: 0.12, yw: 0.5, pa: 0.8, pw: 0.6, gap: 0.9 } },\n segler: { girth: 0.3, head: 0.17, neck: 0, tail: 'gabel', wing: 'sichel', span: 1.45, chord: 0.32, sweep: 0.08, hz: 7, amp: 0.55, fold: 0.25, glide: 0.55, dih: 0.02, droop: -0.05, L: 0.17, V: 12, GR: 12, turn: 3.2, bank: 1, size: [6.5, 9.5], flock: [3, 8], tone: [0.07, 0.07, 0.09], path: { ya: 0.45, yw: 0.9, pa: 2.5, pw: 0.8, gap: 3 } },\n taube: { girth: 0.42, head: 0.18, neck: 0, tail: 'faecher', wing: 'spitz', span: 1.12, chord: 0.46, sweep: 0.2, hz: 6, amp: 0.95, fold: 0.5, glide: 0.18, dih: 0.3, droop: -0.1, L: 0.32, V: 13, GR: 7, turn: 1.4, bank: 0.8, size: [8, 11], flock: [6, 14], tone: [0.34, 0.36, 0.4], path: { ya: 0.22, yw: 0.45, pa: 1.5, pw: 0.5, gap: 1.6 } },\n kraehe: { girth: 0.38, head: 0.19, neck: 0, tail: 'rund', wing: 'finger', span: 1.25, chord: 0.52, sweep: 0.05, hz: 4.2, amp: 0.8, fold: 0.45, glide: 0.25, dih: 0.12, droop: -0.06, L: 0.46, V: 10.5, GR: 8, turn: 1.1, bank: 0.7, size: [10, 14], flock: [2, 5], tone: [0.05, 0.05, 0.06], path: { ya: 0.12, yw: 0.35, pa: 1, pw: 0.4, gap: 4 } },\n gans: { girth: 0.36, head: 0.14, neck: 0.72, tail: 'kurz', wing: 'finger', span: 1.35, chord: 0.44, sweep: 0.1, hz: 3.1, amp: 0.7, fold: 0.35, glide: 0, dih: 0.05, droop: 0, L: 0.8, V: 15, GR: 9, turn: 0.4, bank: 0.5, size: [11, 15], flock: [5, 10], tone: [0.24, 0.22, 0.2], formation: true, path: { ya: 0.04, yw: 0.25, pa: 0.5, pw: 0.3, gap: 1.7 } },\n moewe: { girth: 0.3, head: 0.17, neck: 0, tail: 'kurz', wing: 'spitz', span: 1.7, chord: 0.3, sweep: 0.28, hz: 3.4, amp: 0.62, fold: 0.7, glide: 0.65, dih: 0.24, droop: -0.5, L: 0.5, V: 9, GR: 14, turn: 0.8, bank: 1, size: [11, 15], flock: [1, 3], tone: [0.82, 0.84, 0.86], path: { ya: 0.3, yw: 0.3, pa: 2, pw: 0.35, gap: 8 } }\n };\n const flocks = [], nearFlocks = [];\n let nextFlock = 1.2, nextNear = rnd(9, 16);\n\n // Flug in einer echten Szene (Meter; Kamera im Ursprung, x nach rechts, y nach oben, z in die Tiefe), perspektivisch\n // abgebildet: Wer auf die Kamera zufliegt, wird gr\u00f6\u00dfer und schneller, wer wegfliegt, kleiner und langsamer. Tempo,\n // Fl\u00fcgelschlag und K\u00f6rpergr\u00f6\u00dfe passen zueinander (echte Reisegeschwindigkeiten, etwas gebremst: PACE). Vorher kamen\n // die V\u00f6gel mit einem Zehntel ihres Tempos voran, w\u00e4hrend die Fl\u00fcgel echt schlugen, und blieben immer gleich gro\u00df \u2013\n // das wirkte wie Treten auf der Stelle. Nicht zur\u00fcckbauen.\n const TAU = Math.PI * 2, PACE = 0.7, GRAV = 9.81;\n const angDiff = (a, b) => { let d = (a - b) % TAU; if (d > Math.PI) d -= TAU; if (d < -Math.PI) d += TAU; return d; };\n // Kamera in Hero-Pixeln: Bildmitte, Horizont wie im Himmels-Shader, Brennweite\n const cam = { cx: 0, yH: 0, f: 1 };\n const skyOff = { x: 0, y: 0 }, noOff = { x: 0, y: 0 }; // Lage der Canvas im Hero (Himmel ragt \u00fcber den Rand)\n const camera = () => { cam.cx = wxW / 2; cam.f = 0.9 * wxH; cam.yH = skyOff.y + env.HZ * cssH; };\n // Punkt der Szene (m) -> Canvas-Pixel; k = Pixel pro Meter in dieser Tiefe\n const project = (p, o) => { const k = cam.f / p[2]; return { x: cam.cx + p[0] * k - o.x, y: cam.yH - p[1] * k - o.y, k }; };\n\n // Welche Art gerade unterwegs ist: nach Jahreszeit und Tageszeit (G\u00e4nse nur in der Ferne)\n function pickSpecies(near) {\n const w = env.w, dusk = env.day < 0.65;\n const t = {\n spatz: 0.28 * w[0] + 0.22 * w[1] + 0.12 * w[2] + 0.1 * w[3],\n segler: (0.12 * w[0] + 0.45 * w[1]) * (dusk ? 1.8 : 1),\n taube: 0.28 * w[0] + 0.22 * w[1] + 0.2 * w[2] + 0.25 * w[3],\n kraehe: 0.06 * w[0] + 0.05 * w[1] + 0.26 * w[2] + 0.42 * w[3] * (dusk ? 1.8 : 1),\n gans: near ? 0 : 0.1 * w[0] + 0.34 * w[2] + 0.05 * w[3],\n moewe: 0.08 * w[0] + 0.06 * w[1] + 0.1 * w[2] + 0.24 * w[3]\n };\n let sum = 0;\n for (const k in t) { if (env.day < (k === 'segler' || k === 'kraehe' ? 0.12 : 0.3)) t[k] = 0; sum += t[k]; }\n if (sum <= 0) return null;\n let r = Math.random() * sum;\n for (const k in t) { r -= t[k]; if (r <= 0) return k; }\n return 'taube';\n }\n\n // Neuer Schwarm: Tiefe aus der gew\u00fcnschten Gr\u00f6\u00dfe in der Bildmitte, H\u00f6he aus der Bildzeile, Kurs quer \u00fcbers Bild und\n // leicht auf die Kamera zu (yaw < 0) oder von ihr weg (yaw > 0). Start au\u00dferhalb des Bildes, so dass der Schwarm\n // ungef\u00e4hr durch die Mitte fliegt. at (0 \u2026 1) setzt ihn zum Testen direkt an diese Stelle.\n function spawnFlock(kind, near, at, dir) {\n const sp = SPECIES[kind], P = sp.path;\n camera();\n dir = dir || (Math.random() < 0.5 ? 1 : -1);\n const o = near ? noOff : skyOff, W = near ? wxW : cssW, H = near ? wxH : cssH;\n const n = near ? (Math.random() < 0.65 ? 1 : 2) : Math.round(rnd(sp.flock[0], sp.flock[1]));\n const px = rnd(sp.size[0], sp.size[1]) * (near ? rnd(2.2, 3.1) : 1) * (mobile ? 0.85 : 1);\n const Z = sp.L * cam.f / px;\n // Ferne Schw\u00e4rme unterhalb der Navigation; nahe V\u00f6gel oben \u00fcber den D\u00e4chern oder unten vor den Fassaden\n const row = near ? (Math.random() < 0.6 ? rnd(0.08, 0.28) : rnd(0.54, 0.66)) * H : rnd(sp.formation ? 0.24 : 0.13, 0.42) * H;\n const Y = (cam.yH - (row + o.y)) * Z / cam.f;\n // Nahe V\u00f6gel fliegen meist leicht weg (auf die Kamera zu stiegen sie sofort oben aus dem Bild); die V-Formation\n // quer zum Blick, sonst st\u00fcnde ein Arm als senkrechte Reihe im Bild\n let yaw = Math.random() < (near ? 0.2 : 0.45) ? -rnd(0.06, near ? 0.15 : 0.35) : rnd(0.08, near ? 0.45 : 0.5);\n if (sp.formation) yaw *= 0.3;\n const th = dir > 0 ? yaw : Math.PI - yaw, c = Math.cos(th), s = Math.sin(th);\n let X, Zs = Z;\n if (at != null) X = (at * W + o.x - cam.cx) * Z / cam.f;\n else {\n X = rnd(-0.12, 0.12) * W * Z / cam.f;\n const m = px * 6 + 40;\n for (let i = 0; i < 800; i++) {\n const u = cam.cx + X * cam.f / Zs - o.x;\n if (u < -m || u > W + m || Zs < 3) break;\n X -= c * 0.5; Zs -= s * 0.5;\n }\n }\n const V = sp.V * PACE;\n const fl = { sp, near, t: 0, alt: Y, th0: th, p0: rnd(0, TAU), p1: rnd(0, TAU), seen: false, gone: 0, lead: { p: [X, Y, Zs], th, vy: 0 }, birds: [] };\n // Pl\u00e4tze im Schwarm (m: l\u00e4ngs, H\u00f6he, seitlich): G\u00e4nse in V-Formation, sonst locker und gleichm\u00e4\u00dfig verteilt\n const slots = [];\n if (sp.formation) {\n for (let i = 0; i < n; i++) { const r = Math.ceil(i / 2); slots.push([-r * P.gap * 1.1, rnd(-0.1, 0.1), (i % 2 ? 1 : -1) * r * P.gap]); }\n } else {\n const R = P.gap * Math.sqrt(n) * 0.9;\n for (let i = 0; i < n; i++) {\n let best = null;\n for (let k = 0; k < 12; k++) {\n const q = [rnd(-R, R), rnd(-R, R) * 0.35, rnd(-R, R)];\n const d = slots.reduce((mn, o2) => Math.min(mn, Math.hypot(q[0] - o2[0], q[1] - o2[1], q[2] - o2[2])), 1e9);\n if (!best || d > best.d) best = { q, d };\n }\n slots.push(best.q);\n }\n }\n for (const off of slots) {\n fl.birds.push({\n p: [X + c * off[0] - s * off[2], Y + off[1], Zs + s * off[0] + c * off[2]], off, th, V, vy: 0, roll: 0, gam: 0,\n L: sp.L * rnd(0.92, 1.08), n1: rnd(0, TAU), n2: rnd(0, TAU), size: px,\n ph: rnd(0, TAU), hz: sp.hz * rnd(0.9, 1.1), mode: 'flap', modeT: rnd(0.2, 1.4), flap: 1, tuck: 0\n });\n }\n (near ? nearFlocks : flocks).push(fl);\n return fl;\n }\n\n // Schwarm um dt weiterbewegen: Der Leitkurs macht sanfte Kurven und steigt und sinkt leicht; jeder Vogel steuert\n // seinen Platz an, der langsam wandert (lockerer Schwarm, der sich in Kurven dehnt), und h\u00e4lt Abstand zu den anderen.\n function stepFlock(fl, dt) {\n const sp = fl.sp, P = sp.path, Ld = fl.lead;\n const t = (fl.t += dt), V = sp.V * PACE;\n const thD = fl.th0 + P.ya * (Math.sin(P.yw * t + fl.p1) + 0.4 * Math.sin(P.yw * 2.3 * t + fl.p0));\n Ld.th += Math.max(-sp.turn, Math.min(sp.turn, angDiff(thD, Ld.th) * 1.2)) * dt;\n const yT = fl.alt + P.pa * Math.sin(P.pw * t + fl.p0);\n Ld.vy += (Math.max(-1.5, Math.min(1.5, (yT - Ld.p[1]) * 0.7)) - Ld.vy) * Math.min(1, dt * 2);\n const c = Math.cos(Ld.th), s = Math.sin(Ld.th);\n Ld.p[0] += c * V * dt; Ld.p[2] += s * V * dt; Ld.p[1] += Ld.vy * dt;\n const wob = sp.formation ? 0.12 : 0.6 * P.gap;\n for (const b of fl.birds) {\n const ox = b.off[0] + wob * Math.sin(t * 0.31 + b.n1), oz = b.off[2] + wob * Math.sin(t * 0.23 + b.n2);\n const oy = b.off[1] + wob * 0.4 * Math.sin(t * 0.37 + b.n1 * 1.7);\n const tx = Ld.p[0] + c * ox - s * oz, tz = Ld.p[2] + s * ox + c * oz;\n stepBird(sp, b, dt, c * V + (tx - b.p[0]) * 0.8, s * V + (tz - b.p[2]) * 0.8, Ld.p[1] + oy, V);\n }\n const B = fl.birds, dmin = sp.L * sp.span * 2 + 0.3;\n for (let i = 0; i < B.length; i++) {\n for (let j = i + 1; j < B.length; j++) {\n const a = B[i].p, q = B[j].p, dx = a[0] - q[0], dy = a[1] - q[1], dz = a[2] - q[2], d = Math.hypot(dx, dy, dz);\n if (d >= dmin || d < 1e-4) continue;\n const k = (dmin - d) / d * 0.5 * Math.min(1, dt * 6);\n a[0] += dx * k; a[1] += dy * k; a[2] += dz * k; q[0] -= dx * k; q[1] -= dy * k; q[2] -= dz * k;\n }\n }\n }\n\n // Ein Vogel: Fl\u00fcgelschlag, Gleiten oder (Spatz) Bogenflug mit kurz angelegten Fl\u00fcgeln; Kurs mit begrenzter\n // Wenderate und passender Schr\u00e4glage; im Gleitflug langsamer und sinkend (Gleitzahl), im Schlag zur\u00fcck auf die H\u00f6he\n function stepBird(sp, b, dt, vx, vz, ty, V) {\n b.modeT -= dt;\n const low = ty - b.p[1] > 1.2;\n if (b.modeT <= 0 || (b.mode === 'glide' && low)) {\n if (b.mode === 'flap') {\n if (sp.hop) { b.mode = 'tuck'; b.modeT = rnd(0.12, 0.22); }\n else if (!low && Math.random() < sp.glide) { b.mode = 'glide'; b.modeT = rnd(0.8, 2.6); }\n else b.modeT = rnd(0.5, 1.4);\n } else { b.mode = 'flap'; b.modeT = sp.hop ? rnd(0.24, 0.42) : rnd(0.6, 1.8) * (1 - sp.glide * 0.5); }\n }\n const flapping = b.mode === 'flap', gliding = b.mode === 'glide';\n if (flapping || b.flap > 0.02) b.ph += TAU * b.hz * dt;\n b.flap += ((flapping ? 1 : 0) - b.flap) * Math.min(1, dt * 7);\n b.tuck += ((b.mode === 'tuck' ? 1 : 0) - b.tuck) * Math.min(1, dt * 16);\n const w = Math.max(-sp.turn * 1.4, Math.min(sp.turn * 1.4, angDiff(Math.atan2(vz, vx), b.th) * 2.5));\n b.th += w * dt;\n const Vd = Math.max(V * 0.7, Math.min(V * 1.35, Math.hypot(vx, vz)));\n b.V += ((gliding ? Math.min(Vd, V * 0.92) : Vd) - b.V) * Math.min(1, dt * (gliding ? 0.4 : 1.2));\n let vyT = Math.max(-1.6, Math.min(1.6, (ty - b.p[1]) * 0.9));\n if (gliding) vyT = Math.min(vyT, 0) - b.V / sp.GR;\n if (sp.hop) vyT += b.mode === 'tuck' ? -1.7 : 1.0;\n b.vy += (vyT - b.vy) * Math.min(1, dt * 3);\n b.roll += (Math.max(-0.85, Math.min(0.85, -Math.atan(b.V * w / GRAV) * sp.bank)) - b.roll) * Math.min(1, dt * 4);\n b.gam = Math.atan2(b.vy, b.V) * 0.7;\n b.p[0] += Math.cos(b.th) * b.V * dt; b.p[2] += Math.sin(b.th) * b.V * dt; b.p[1] += b.vy * dt;\n }\n\n // Achsen des Vogels in der Szene: vorw\u00e4rts (Kurs und Steigwinkel), rechts, oben; gerollt um die L\u00e4ngsachse\n function axesOf(th, gam, roll) {\n const cg = Math.cos(gam), F = [Math.cos(th) * cg, Math.sin(gam), Math.sin(th) * cg];\n const rn = Math.hypot(F[0], F[2]) || 1, R = [-F[2] / rn, 0, F[0] / rn];\n const U = [-R[2] * F[1], R[2] * F[0] - R[0] * F[2], R[0] * F[1]]; // oben = rechts \u00d7 vorw\u00e4rts\n const cr = Math.cos(roll), sr = Math.sin(roll);\n return { F, R: [R[0] * cr + U[0] * sr, U[1] * sr, R[2] * cr + U[2] * sr], U: [U[0] * cr - R[0] * sr, U[1] * cr, U[2] * cr - R[2] * sr] };\n }\n // Abbildung der Vogel-Achsen auf den Bildschirm an der Stelle p (Ableitung der Perspektive). bias: Blick etwas\n // steiler von unten, damit die Fl\u00fcgelform lesbar bleibt (sonst in Augenh\u00f6he nur Striche)\n function frameAt(ax, p, bias) {\n const kx = p[0] / p[2], ky = p[1] / p[2] + bias, F = ax.F, U = ax.U, R = ax.R;\n return { ax: F[0] - kx * F[2], ay: U[0] - kx * U[2], az: R[0] - kx * R[2], bx: F[1] - ky * F[2], by: U[1] - ky * U[2], bz: R[1] - ky * R[2] };\n }\n // Lage f\u00fcr den Katalog (birdSheet): Flugrichtung, Rollen, Blick von leicht unten (elev), ohne Perspektive\n function frameOf(fx, fy, fz, roll, elev) {\n const n = Math.hypot(fx, fy, fz) || 1;\n fx /= n; fy /= n; fz /= n;\n let rx = -fz, rz = fx;\n const rn = Math.hypot(rx, rz) || 1;\n rx /= rn; rz /= rn;\n const ux = -rz * fy, uy = rz * fx - rx * fz, uz = rx * fy;\n const cr = Math.cos(roll), sr = Math.sin(roll);\n const Rx = rx * cr + ux * sr, Ry = uy * sr, Rz = rz * cr + uz * sr;\n const Ux = ux * cr - rx * sr, Uy = uy * cr, Uz = uz * cr - rz * sr;\n const ce = Math.cos(elev), se = Math.sin(elev);\n return { ax: fx, ay: Ux, az: Rx, bx: fy * ce - fz * se, by: Uy * ce - Uz * se, bz: Ry * ce - Rz * se };\n }\n\n // Fl\u00fcgelhaltung: Abschlag mit gestreckter Hand, Aufschlag mit eingeknickter Hand, Gleiten, angelegt (Bogenflug).\n // Der Abschlag dauert l\u00e4nger als der Aufschlag (58 zu 42 %), wie bei echten V\u00f6geln.\n const warp = ph => { const p = (((ph % TAU) + TAU) % TAU) / TAU; return (p < 0.58 ? p / 0.58 * 0.5 : 0.5 + (p - 0.58) / 0.42 * 0.5) * TAU; };\n function poseOf(sp, b, ph) {\n const w = warp(ph), c = Math.cos(w), s = Math.sin(w), up = Math.max(0, -s);\n let t1 = sp.amp * (0.28 + 0.72 * c); // Schlag etwas nach oben verschoben: weit hoch, nur m\u00e4\u00dfig unter den K\u00f6rper\n let t2 = t1 - sp.fold * up * 0.9 + sp.amp * 0.12 * Math.max(0, s);\n let hand = 1 - sp.fold * up * 0.45, back = sp.fold * up * 0.35;\n t1 = lerp(sp.dih, t1, b.flap); t2 = lerp(sp.dih + sp.droop, t2, b.flap); hand = lerp(1, hand, b.flap); back *= b.flap;\n t1 = lerp(t1, -0.2, b.tuck); t2 = lerp(t2, -0.35, b.tuck);\n return { t1, t2, hand, back, len: 1 - 0.72 * b.tuck };\n }\n\n function drawBird(ctx, sp, b, X, Y, fr, color) {\n const S = b.size;\n const P = (x, y, z) => [X + S * (fr.ax * x + fr.ay * y + fr.az * z), Y - S * (fr.bx * x + fr.by * y + fr.bz * z)];\n const poly = pts => {\n ctx.beginPath();\n pts.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1])));\n ctx.closePath(); ctx.fill(); ctx.stroke();\n };\n const wing = (pose, side) => {\n const s1 = Math.sin(pose.t1), c1 = Math.cos(pose.t1), s2 = Math.sin(pose.t2), c2 = Math.cos(pose.t2), L = sp.span * pose.len;\n poly(WING[sp.wing].map(([c, s]) => {\n const arm = Math.min(s, 0.42), hand = Math.max(0, s - 0.42) * pose.hand;\n const x = 0.06 + c * sp.chord - sp.sweep * s * s - pose.back * Math.max(0, s - 0.42);\n return P(x, (arm * s1 + hand * s2) * L, side * (sp.girth * 0.3 + (arm * c1 + hand * c2) * L));\n }));\n };\n ctx.fillStyle = ctx.strokeStyle = color;\n // schneller Fl\u00fcgelschlag: leichte Bewegungsunsch\u00e4rfe\n if (b.hz > 7 && b.flap > 0.5 && b.tuck < 0.5) {\n ctx.globalAlpha = 0.28;\n const gp = poseOf(sp, b, b.ph - 0.9);\n wing(gp, 1); wing(gp, -1);\n ctx.globalAlpha = 1;\n }\n const pose = poseOf(sp, b, b.ph);\n wing(pose, 1); wing(pose, -1);\n poly(TAIL[sp.tail].map(([x, z]) => P(x, 0, z * (1 + sp.girth * 0.3))));\n // K\u00f6rper als Ellipse entlang der Flugrichtung\n const p0 = P(-0.46, 0, 0), p1 = P(0.44, 0, 0), gir = sp.girth * S * 0.5;\n const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) / 2;\n ctx.beginPath();\n ctx.ellipse((p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2, Math.max(len, gir) + gir * 0.2, gir, Math.atan2(p1[1] - p0[1], p1[0] - p0[0]), 0, 6.2832);\n ctx.fill();\n // Hals (Gans), Kopf und \u2013 wenn gro\u00df genug \u2013 Schnabel\n const hx = 0.5 + sp.neck, hp = P(hx, 0.03, 0);\n if (sp.neck) {\n const n0 = P(0.3, 0, 0);\n ctx.lineWidth = gir * 0.9;\n ctx.beginPath(); ctx.moveTo(n0[0], n0[1]); ctx.lineTo(hp[0], hp[1]); ctx.stroke();\n ctx.lineWidth = 0.7;\n }\n ctx.beginPath(); ctx.arc(hp[0], hp[1], sp.head * S * 0.5, 0, 6.2832); ctx.fill();\n if (S > 12) { const bx = hx + sp.head * 0.4; poly([P(bx, 0.05, 0.03), P(bx + 0.14, 0.02, 0), P(bx, -0.01, -0.03)]); }\n }\n\n function birdColor(sp, y, H, near, size) {\n const light = 0.35 + 0.65 * env.day * (1 - env.over * 0.4);\n let c = sp.tone.map(v => v * light);\n if (!near) { // weiter weg: Farbe geht in den Himmel \u00fcber\n const sky = mix3(env.zen, env.hor, Math.pow(clamp01(y / (H * env.HZ)), 1.45));\n c = mix3(c, sky, 0.14 + 0.22 * (1 - clamp01((size / sp.size[1] - 0.7) / 0.6)));\n }\n return `rgb(${c.map(v => Math.round(clamp01(v) * 255)).join(',')})`;\n }\n\n // Schw\u00e4rme bewegen und zeichnen (o: Lage der Canvas im Hero, W \u00d7 H ihre Gr\u00f6\u00dfe). Ein Schwarm verschwindet, sobald\n // er das Bild verlassen hat.\n function drawFlocks(ctx, list, dt, o, W, H) {\n ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.lineWidth = 0.7;\n for (let f = list.length - 1; f >= 0; f--) {\n const fl = list[f], sp = fl.sp;\n if (dt > 0) stepFlock(fl, dt);\n let vis = false;\n for (const b of fl.birds) {\n if (b.p[2] < 1.5) continue;\n const q = project(b.p, o), S = b.L * q.k;\n if (S < 0.8 || q.x < -S * 3 || q.x > W + S * 3 || q.y < -S * 3 || q.y > H + S * 3) continue;\n vis = true;\n b.size = S;\n const fr = frameAt(axesOf(b.th, b.gam, b.roll), b.p, fl.near ? 0.12 : 0.05 + sp.glide * 0.12);\n const bob = -Math.sin(warp(b.ph)) * 0.05 * S * b.flap; // im Abschlag hebt sich der K\u00f6rper leicht\n drawBird(ctx, sp, b, q.x, q.y + bob, fr, birdColor(sp, q.y, H, fl.near, S));\n }\n if (vis) { fl.seen = true; fl.gone = 0; } else fl.gone += dt;\n if ((fl.seen && fl.gone > 0.6) || fl.t > 60 || (!fl.seen && fl.t > 15)) list.splice(f, 1);\n }\n }\n\n function drawBirds(dt) {\n const d = birdCv.width / Math.max(1, cssW);\n b2.setTransform(1, 0, 0, 1, 0, 0);\n b2.clearRect(0, 0, birdCv.width, birdCv.height);\n camera();\n const ok = env.wx.rain < 0.25 && env.wx.snow < 0.35 && env.wx.fog < 0.5;\n nextFlock -= dt; nextNear -= dt;\n if (autoBirds && ok && nextFlock <= 0 && flocks.length < 3) { const k = pickSpecies(false); if (k) spawnFlock(k, false); nextFlock = rnd(3, 9); }\n if (autoBirds && ok && nextNear <= 0 && !nearFlocks.length) { const k = pickSpecies(true); if (k) spawnFlock(k, true); nextNear = rnd(18, 32); }\n if (!flocks.length) return;\n b2.setTransform(d, 0, 0, d, 0, 0);\n drawFlocks(b2, flocks, dt, skyOff, cssW, cssH);\n }\n\n // ---------- Regen, Schnee, Laub, Bl\u00fcten ----------\n const leafPath = (() => { const p = document.querySelector('#s-leaf path'); return p ? new Path2D(p.getAttribute('d')) : null; })();\n const drops = [], flakes = [], leaves = [], petals = [];\n const LEAF_COLS = ['#E0741F', '#C1461A', '#E8A628', '#B5301B', '#D98A2B'];\n // Teilchen-Anzahl an das Wetter anpassen: neue blenden ein, \u00fcberz\u00e4hlige blenden aus (kein Aufploppen)\n function settle(arr, target, dt, make) {\n let alive = 0;\n for (const p of arr) if (!p.out) alive++;\n for (let i = 0; i < arr.length && alive > target; i++) if (!arr[i].out) { arr[i].out = true; alive--; }\n while (alive < target) { const p = make(); p.a = 0; arr.push(p); alive++; }\n for (let i = arr.length - 1; i >= 0; i--) {\n const p = arr[i];\n if (p.out) { p.a -= dt * 1.4; if (p.a <= 0) arr.splice(i, 1); }\n else if (p.a < 1) p.a = Math.min(1, p.a + dt * 1.6);\n }\n }\n function drawWeather(dt) {\n const d = wxCv.width / Math.max(1, wxW);\n w2.setTransform(1, 0, 0, 1, 0, 0);\n w2.clearRect(0, 0, wxCv.width, wxCv.height);\n w2.setTransform(d, 0, 0, d, 0, 0);\n const x = env.wx, wind = x.wind * (mobile ? 0.8 : 1);\n const area = (wxW * wxH) / (1440 * 900);\n const less = mobile ? 0.6 : lowPower ? 0.7 : 1;\n\n settle(drops, Math.round(x.rain * 420 * area * less), dt, () => ({ x: rnd(-0.1, 1.1) * wxW, y: rnd(-wxH, wxH), z: rnd(0.35, 1), l: rnd(14, 26) }));\n w2.lineCap = 'round';\n const rainRGB = env.night > 0.5 ? '170,185,215' : '215,226,240';\n for (const p of drops) {\n const vy = 1300 * p.z, vx = -wind * 260 * p.z;\n p.x += vx * dt; p.y += vy * dt;\n if (p.y > wxH + 30) { p.y = rnd(-80, -10); p.x = rnd(-0.1, 1.1) * wxW; }\n w2.strokeStyle = `rgba(${rainRGB},${(0.18 + 0.32 * p.z) * p.a})`;\n w2.lineWidth = 0.6 + p.z * 0.9;\n w2.beginPath();\n w2.moveTo(p.x, p.y);\n w2.lineTo(p.x - vx * 0.018, p.y - p.l * p.z * 1.6);\n w2.stroke();\n }\n\n const dc = dt * CALM; // Schnee, Laub und Bl\u00fcten ruhiger, Regen bleibt echt\n settle(flakes, Math.round(x.snow * 300 * area * less), dc, () => ({ x: rnd(-0.05, 1.05) * wxW, y: rnd(-wxH * 0.2, wxH), z: rnd(0.3, 1), ph: rnd(0, 6.28) }));\n w2.fillStyle = '#fff';\n for (const p of flakes) {\n p.ph += dc * 1.4;\n p.x += (-wind * 60 + Math.sin(p.ph) * 22) * p.z * dc;\n p.y += (40 + 85 * p.z) * dc;\n if (p.y > wxH + 10) { p.y = rnd(-40, -6); p.x = rnd(-0.05, 1.05) * wxW; }\n w2.globalAlpha = (0.35 + 0.6 * p.z) * p.a;\n w2.beginPath();\n w2.arc(p.x, p.y, 0.8 + p.z * 2.4, 0, 6.2832);\n w2.fill();\n }\n w2.globalAlpha = 1;\n\n if (leafPath) {\n settle(leaves, Math.round(x.leaves * env.day * 14), dc, () => ({ x: rnd(0, wxW), y: rnd(-wxH * 0.2, wxH * 0.7), s: rnd(14, 28), r: rnd(0, 6.28), vr: rnd(-2.4, 2.4), ph: rnd(0, 6.28), c: LEAF_COLS[Math.floor(Math.random() * LEAF_COLS.length)] }));\n for (const p of leaves) {\n p.ph += dc * 2;\n p.x += (-wind * 90 + Math.sin(p.ph) * 55) * dc;\n p.y += (55 + Math.cos(p.ph * 0.7) * 25) * dc;\n p.r += p.vr * dc;\n if (p.y > wxH + 40 || p.x < -80 || p.x > wxW + 80) { p.y = rnd(-80, -30); p.x = rnd(0.1, 1.2) * wxW; }\n const sc = p.s / 124;\n w2.setTransform(d * sc * Math.cos(p.r), d * sc * Math.sin(p.r), -d * sc * Math.sin(p.r) * Math.cos(p.ph), d * sc * Math.cos(p.r) * Math.cos(p.ph), p.x * d, p.y * d);\n w2.translate(-62, -57);\n w2.fillStyle = p.c;\n w2.globalAlpha = 0.9 * p.a;\n w2.fill(leafPath);\n }\n w2.globalAlpha = 1;\n w2.setTransform(d, 0, 0, d, 0, 0);\n }\n\n settle(petals, Math.round(x.petals * env.day * 18), dc, () => ({ x: rnd(0, wxW), y: rnd(-wxH * 0.1, wxH), s: rnd(3, 6), r: rnd(0, 6.28), ph: rnd(0, 6.28), c: Math.random() < 0.5 ? '#F7C6D6' : '#FFF1F4' }));\n for (const p of petals) {\n p.ph += dc * 2.2;\n p.x += (-wind * 70 + Math.sin(p.ph) * 40) * dc;\n p.y += (32 + Math.cos(p.ph) * 18) * dc;\n p.r += dc * 2;\n if (p.y > wxH + 20 || p.x < -40 || p.x > wxW + 40) { p.y = rnd(-40, -10); p.x = rnd(0, 1.1) * wxW; }\n w2.save();\n w2.globalAlpha = p.a;\n w2.translate(p.x, p.y);\n w2.rotate(p.r);\n w2.scale(1, Math.abs(Math.cos(p.ph)) * 0.8 + 0.2);\n w2.fillStyle = p.c;\n w2.beginPath();\n w2.ellipse(0, 0, p.s, p.s * 0.62, 0, 0, 6.2832);\n w2.fill();\n w2.restore();\n }\n\n drawFlies(dt, d, x);\n\n // gelegentlich ein Vogel nah vor den H\u00e4usern (gr\u00f6\u00dfer, mit allen Details)\n if (nearFlocks.length) { w2.setTransform(d, 0, 0, d, 0, 0); drawFlocks(w2, nearFlocks, dt, noOff, wxW, wxH); }\n }\n\n // ---------- Gl\u00fchw\u00fcrmchen (Sommern\u00e4chte) ----------\n // Die heimischen Johannisw\u00fcrmchen leuchten beim Fliegen ruhig und gleichm\u00e4\u00dfig (sie blinken nicht wie die\n // amerikanischen) und schweben langsam \u00fcber Hecken und Wiesen; ein paar sitzen leuchtend im Gras. Geboren werden sie\n // an echten Pflanzenstellen des Renderings (flySpots). Nur im Sommer, nachts, trocken und bei wenig Wind.\n const flies = [];\n let glow = null;\n function glowSprite() {\n if (glow) return glow;\n glow = document.createElement('canvas');\n glow.width = glow.height = 64;\n const g = glow.getContext('2d'), gr = g.createRadialGradient(32, 32, 0, 32, 32, 32);\n gr.addColorStop(0, 'rgba(252,255,220,1)');\n gr.addColorStop(0.1, 'rgba(228,255,150,0.95)');\n gr.addColorStop(0.3, 'rgba(196,242,96,0.38)');\n gr.addColorStop(0.6, 'rgba(176,232,76,0.1)');\n gr.addColorStop(1, 'rgba(170,228,70,0)');\n g.fillStyle = gr;\n g.fillRect(0, 0, 64, 64);\n return glow;\n }\n function makeFly() {\n const s = flySpots[Math.floor(Math.random() * flySpots.length)];\n const b = buildCv.getBoundingClientRect(), r = wxCv.getBoundingClientRect();\n const kx = wxW / Math.max(1, r.width), ky = wxH / Math.max(1, r.height);\n const hx = (b.left + s[0] * b.width - r.left) * kx, hy = (b.top + s[1] * b.height - r.top) * ky;\n const sit = Math.random() < 0.2;\n return { hx, hy: hy - (sit ? 0 : rnd(6, 26)), sit, t: rnd(0, 60), r: rnd(8, 30), w1: rnd(0.14, 0.32), w2: rnd(0.2, 0.45), w3: rnd(0.12, 0.3),\n a1: rnd(0, 6.28), a2: rnd(0, 6.28), a3: rnd(0, 6.28), gp: rnd(0, 6.28), gs: rnd(0.35, 0.9), vx: rnd(-4, 4), size: sit ? rnd(6, 8) : rnd(9, 14), x: hx, y: hy };\n }\n function drawFlies(dt, d, x) {\n const want = flySpots.length ? env.w[1] * sstep(0.35, 0.85, env.night) * (1 - sstep(0.08, 0.35, x.rain)) * (1 - sstep(0.55, 0.9, x.wind)) : 0;\n settle(flies, Math.round(want * (mobile ? 18 : 34)), dt * 0.35, makeFly); // langsam auf- und abblenden\n if (!flies.length) return;\n const spr = glowSprite();\n w2.setTransform(d, 0, 0, d, 0, 0);\n w2.globalCompositeOperation = 'lighter';\n for (const f of flies) {\n f.t += dt;\n if (!f.sit) {\n f.hx += f.vx * dt;\n f.x = f.hx + f.r * (0.8 * Math.sin(f.t * f.w1 + f.a1) + 0.35 * Math.sin(f.t * f.w2 + f.a2));\n f.y = f.hy + f.r * 0.45 * Math.sin(f.t * f.w3 + f.a3);\n }\n // ruhiges Glimmen, das beim Wegdrehen schw\u00e4cher wird\n const g = f.sit ? 0.8 + 0.12 * Math.sin(f.t * 0.6 + f.gp) : 0.45 + 0.55 * Math.max(0, Math.sin(f.t * f.gs + f.gp)) ** 0.7;\n w2.globalAlpha = f.a * g;\n const R = f.size;\n w2.drawImage(spr, f.x - R, f.y - R, R * 2, R * 2);\n }\n w2.globalCompositeOperation = 'source-over';\n w2.globalAlpha = 1;\n }\n\n // ---------- Rendern ----------\n function renderSky() {\n const gl = S.gl, u = S.u, E = env;\n gl.viewport(0, 0, SW, SH);\n gl.uniform2f(u('uRes'), SW, SH);\n gl.uniform1f(u('uTime'), time);\n gl.uniform1f(u('uHz'), E.HZ);\n gl.uniform2f(u('uSun'), E.sunX, E.sunY);\n gl.uniform2f(u('uMoon'), E.moonX, E.moonY);\n gl.uniform2f(u('uDrift'), drift[0], drift[1]);\n gl.uniform1f(u('uEvolve'), evolve);\n gl.uniform1f(u('uCover'), E.wx.cover);\n gl.uniform1f(u('uDark'), E.wx.dark);\n gl.uniform1f(u('uFog'), E.wx.fog);\n gl.uniform1f(u('uStars'), E.stars);\n gl.uniform1f(u('uBow'), E.wx.bow * E.day * sstep(0.05, 0.2, E.e));\n gl.uniform1f(u('uFlash'), flash);\n gl.uniform1f(u('uSunVis'), E.sunVis);\n gl.uniform1f(u('uMoonVis'), E.moonVis);\n gl.uniform1f(u('uNight'), E.night);\n gl.uniform1f(u('uGlow'), E.glow);\n gl.uniform1f(u('uMoonK'), Math.cos(E.moon * Math.PI * 2));\n gl.uniform1f(u('uMoonWax'), E.moon < 0.5 ? 1 : 0);\n gl.uniform1f(u('uMoonLit'), E.moonLit);\n gl.uniform3fv(u('uZen'), E.zen);\n gl.uniform3fv(u('uHor'), E.hor);\n gl.uniform3fv(u('uSunCol'), E.sunCol);\n gl.uniform3fv(u('uLit'), E.lit);\n gl.uniform3fv(u('uShade'), E.shade);\n gl.uniform3fv(u('uFogCol'), E.fogCol);\n gl.uniform3fv(u('uGlowCol'), E.glowCol);\n if (ship.on) {\n // quer \u00fcber die ganze Breite, knapp au\u00dferhalb beginnen und enden; sanftes Auf und Ab, leichtes Nicken\n const m = ship.hl * 1.15 / (SW / SH), p = ship.t;\n ship.cx = ship.dir > 0 ? -m + (1 + 2 * m) * p : 1 + m - (1 + 2 * m) * p;\n ship.cy = ship.y + 0.004 * Math.sin(time * 0.6);\n gl.uniform4f(u('uShip'), ship.cx, ship.cy, ship.hl, ship.dir);\n gl.uniform2f(u('uShipB'), 0.014 * Math.sin(time * 0.45 + 1), clamp01(E.night * 1.3));\n } else gl.uniform4f(u('uShip'), 0, 0, 0, 1);\n gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n }\n function renderBuild() {\n if (!texReady) return false;\n const gl = B.gl, u = B.u, E = env;\n gl.viewport(0, 0, BW, BH);\n gl.activeTexture(gl.TEXTURE2);\n gl.bindTexture(gl.TEXTURE_2D, texWin);\n gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, 64, 1, gl.RGBA, gl.UNSIGNED_BYTE, winData.subarray(0, 256));\n gl.uniform2f(u('uTex'), texW, texH);\n gl.uniform3fv(u('uAmb'), E.amb);\n gl.uniform3fv(u('uFogCol'), E.fogCol);\n gl.uniform1f(u('uSat'), E.sat);\n gl.uniform1f(u('uNight'), 1 - sstep(0.02, 0.18, E.e) * (1 - E.wx.dark * 0.4));\n gl.uniform1f(u('uDay'), E.day);\n gl.uniform1f(u('uOver'), E.over);\n gl.uniform1f(u('uWet'), wet);\n gl.uniform1f(u('uFog'), E.wx.fog);\n gl.uniform1f(u('uFlash'), flash);\n gl.uniform1f(u('uLampDay'), sstep(-0.02, 0.12, E.e));\n gl.uniform1f(u('uDeep'), clamp01(sstep(0.02, -0.2, E.e) + E.wx.dark * 0.4));\n gl.uniform4fv(u('uSeason'), E.w);\n gl.uniform1f(u('uSnowCover'), E.snowCover);\n gl.uniform1f(u('uManual'), manualOn ? 1 : 0);\n gl.uniform4fv(u('uLamps[0]'), lampArr);\n gl.uniform3fv(u('uWall[0]'), wallArr);\n gl.clearColor(0, 0, 0, 0);\n gl.clear(gl.COLOR_BUFFER_BIT);\n gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n return true;\n }\n\n let wet = 0, dirty = true, revealed = false, lastLabel = '', lastNight = -1;\n let hold = false, manual = false, autoBirds = true; // Debug: Uhr anhalten, Bild f\u00fcr Bild rendern, V\u00f6gel nur per Hand\n function step(dt) {\n const k = dt * 60;\n if (playing) {\n if (!hold) {\n // live (auch beim Start bis release()): echte Uhr \u00fcbernehmen statt hochzuz\u00e4hlen. Sonst ging sie nach, wenn die\n // Seite im Hintergrund-Tab lag (dann l\u00e4uft kein Bild, dt ist je Bild auf 0,05 s begrenzt)\n if (mode === 'live' || (clock.live && !released)) syncLive();\n else {\n // Start: erst echte Zeit; nach release() beschleunigt die Uhr in 3,5 s auf Zeitraffer-Tempo\n const sp = released ? sstep(0, 3.5, time - relT) : 0, fast = rate(env) * TEMPO * sp;\n clock.hour += (fast + (1 - sp) / 3600) * dt;\n if (clock.hour >= 24) { clock.hour -= 24; clock.day++; }\n if (clock.live && released) {\n sinceRel += fast * dt;\n clock.live.mix = 1 - sstep(0.5, 5, sinceRel); // das Echte blendet \u00fcber rund f\u00fcnf Zeitraffer-Stunden aus\n if (clock.live.mix <= 0) clock.live = null;\n }\n }\n }\n time += dt;\n const cloud = clock.live ? lerp(1, 0.3, clock.live.mix) : 1; // live ziehen die Wolken langsamer\n evolve += dt * (0.35 + env.wx.wind * 0.5) * TEMPO * cloud;\n drift[0] += dt * (0.012 + env.wx.wind * 0.03) * TEMPO * cloud;\n drift[1] -= dt * 0.004 * TEMPO * cloud;\n }\n env = environment(clock);\n // N\u00e4sse bleibt nach dem Regen noch eine Weile\n wet = Math.max(env.wx.rain, wet - dt * 0.05 * TEMPO);\n // Blitze bei Gewitter\n if (playing && env.wx.storm > 0.3) {\n nextFlash -= dt;\n if (nextFlash <= 0) {\n gsap.timeline().to(fl, { v: 1, duration: 0.05 }).to(fl, { v: 0.15, duration: 0.08 }).to(fl, { v: 0.85, duration: 0.04 }).to(fl, { v: 0, duration: 0.45, ease: 'power2.out' });\n nextFlash = rnd(2.5, 6) / TEMPO;\n onFlash?.(env.wx.storm); // Donner (sound.js)\n }\n }\n flash = fl.v;\n lights(dt, k);\n if (playing) stepShip(dt);\n }\n const fl = { v: 0 };\n\n // ---------- Luftschiff ----------\n // Zieht selten und gem\u00e4chlich quer \u00fcber den Himmel: das erste Mal nach 20\u201335 s im Hero, danach alle 2,5\u20134 min;\n // nie bei Gewitter, Starkregen oder dichtem Nebel. In Echtzeit (nicht im Zeitraffer), knapp eine Minute lang.\n // Gezeichnet im Himmels-Shader (airship); Klick darauf f\u00fchrt in die Geschichte (main.js, shipAt).\n const ship = { on: false, t: 0, dur: 50, dir: 1, y: 0.2, hl: 0.07, wait: rnd(20, 35), cx: -9, cy: 0 };\n function launchShip(dir) {\n const r = skyCv.getBoundingClientRect(), h = hero.getBoundingClientRect();\n const y = r.height ? (h.top + h.height * rnd(0.17, 0.28) - r.top) / r.height : rnd(0.13, 0.22);\n Object.assign(ship, { on: true, t: 0, dur: rnd(44, 56), dir: dir || (Math.random() < 0.5 ? 1 : -1), y, hl: rnd(0.062, 0.074) * (mobile ? 0.9 : 1) });\n }\n function stepShip(dt) {\n if (!ship.on) {\n ship.wait -= dt;\n if (ship.wait > 0) return;\n const x = env.wx;\n if (x.storm > 0.2 || x.rain > 0.5 || x.fog > 0.6) { ship.wait = rnd(8, 16); return; }\n launchShip();\n }\n ship.t += dt / ship.dur;\n if (ship.t >= 1) { ship.on = false; ship.cx = -9; ship.wait = rnd(150, 240); }\n }\n // Liegt der Punkt (Bildschirm-Pixel) auf dem Luftschiff? Gro\u00dfz\u00fcgig, damit es auch am Handy gut zu treffen ist.\n function shipAt(x, y) {\n if (!ship.on || hero.classList.contains('sky-lost')) return false;\n const r = skyCv.getBoundingClientRect();\n if (!r.width || !r.height) return false;\n const dx = ((x - r.left) / r.width - ship.cx) * (r.width / r.height), dy = (y - r.top) / r.height - ship.cy;\n return Math.abs(dx) < ship.hl * 1.08 && Math.abs(dy) < ship.hl * 0.34 + 10 / r.height;\n }\n\n function frame(dt) {\n if (!playing && !dirty) return; // angehalten: nichts neu zeichnen\n if (!SW) resize();\n step(playing ? dt : 0);\n renderSky();\n const ok = renderBuild();\n drawBirds(playing ? dt : 0);\n drawWeather(playing ? dt : 0);\n if (ok) dirty = false;\n if (ok && !revealed) { revealed = true; hero.classList.add('has-sky'); }\n // Schriftzug leuchtet nachts wie eine beleuchtete Werbeschrift\n const nt = Math.round(env.night * 100) / 100;\n if (nt !== lastNight) { lastNight = nt; hero.style.setProperty('--night', nt); }\n const live = isLive();\n const label = SEASONS[clock.season()] + '|' + (live ? Math.floor(clock.hour * 60) : fmt(clock.hour)) + '|' + weatherWord(env) + '|' + live;\n if (label !== lastLabel) {\n lastLabel = label;\n const skyLum = 0.2126 * env.zen[0] + 0.7152 * env.zen[1] + 0.0722 * env.zen[2];\n onChange?.({ season: SEASONS[clock.season()], clock: live ? hhmm(clock.hour) : fmt(clock.hour), weather: weatherWord(env), playing, night: env.night, dark: skyLum < 0.46, live, temp: live ? liveTemp : null });\n }\n }\n // Echt ist nur, was nicht schon beschleunigt: Nach release() lief die Uhr sonst gut eine halbe Stunde vor, w\u00e4hrend\n // Punkt und Temperatur noch \u201elive\u201c zeigten\n const isLive = () => !!clock.live && clock.live.mix > 0.999 && (mode === 'live' || !released);\n const fmt = h => { const m = Math.floor(h * 6) * 10; return String(Math.floor(m / 60) % 24).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0'); };\n const hhmm = h => { const m = Math.floor(h * 60); return String(Math.floor(m / 60) % 24).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0'); };\n\n // ---------- Licht per Fingertipp ----------\n // Welches Fenster liegt unter diesem Punkt (Bildschirm-Pixel)? Gepr\u00fcft wird gegen die vermessenen Scheiben\n // (Front: Rechteck mit schr\u00e4ger Unterkante, Seiten: Parallelogramm), mit ein paar Pixeln Spielraum. -1 = keins.\n function windowAt(x, y) {\n const r = buildCv.getBoundingClientRect();\n if (!r.width || !r.height) return -1;\n const u = (x - r.left) / r.width, v = (y - r.top) / r.height, tu = 6 / r.width, tv = 6 / r.height;\n for (let i = 0; i < FRONT.length; i++) {\n const f = FRONT[i];\n if (u < f[0] - tu || u > f[2] + tu) continue;\n const t = clamp01((u - f[0]) / (f[2] - f[0]));\n if (v >= f[1] - tv && v <= f[3] + (f[4] - f[3]) * t + tv) return i;\n }\n for (let n = 0; n < SIDE.length; n++) {\n const q = SIDE[n];\n if (u < q[0] - tu || u > q[1] + tu) continue;\n const t = clamp01((u - q[0]) / (q[1] - q[0]));\n if (v >= q[2] + (q[3] - q[2]) * t - tv && v <= q[4] + (q[5] - q[4]) * t + tv) return FRONT.length + n;\n }\n return -1;\n }\n // Licht in diesem Fenster umschalten; liefert den neuen Zustand (true = an)\n function toggleWindow(i) {\n const w = WIN[i];\n if (!w) return null;\n const on = w.man != null ? w.man === 1 : w.autoOn;\n w.man = on ? 0 : 1; w.manAuto = w.autoOn;\n dirty = true;\n return !on;\n }\n\n // Leistung beobachten: l\u00e4uft der Hero dauerhaft unter ~45 fps, wird eine Stufe gr\u00f6ber gerendert.\n // Bringt die gr\u00f6bere Stufe nichts (Bildrate fest gedrosselt, z. B. Stromsparmodus am iPhone mit 30 Bildern/s),\n // geht es zur\u00fcck auf die sch\u00e4rfere Stufe, und die Messung endet.\n let perfN = 0, perfSum = 0, probe = null, settled = false;\n const setTier = t => {\n tier = t;\n lowPower = tier === TIERS.length - 1;\n skyScale = TIERS[tier].sky * (lowPower ? 0.7 : 1);\n resize(); dirty = true;\n };\n const perf = ms => {\n if (settled || !playing || document.hidden || document.documentElement.classList.contains('is-loading')) return;\n perfSum += ms; perfN++;\n if (perfN < 120) return;\n const avg = perfSum / perfN;\n perfN = 0; perfSum = 0;\n if (probe) {\n const gain = avg < probe.avg * 0.85;\n if (!gain) { setTier(probe.tier); settled = true; }\n probe = null;\n if (settled) return;\n }\n if (avg > 22 && tier < TIERS.length - 1) { probe = { tier, avg }; setTier(tier + 1); }\n };\n\n let last = 0, running = false;\n const tick = () => {\n const now = performance.now();\n const ms = now - last;\n const dt = Math.min(0.05, ms / 1000 || 0.016);\n last = now;\n if (!active || manual) return;\n // Leistung vor dem Zeichnen pr\u00fcfen: Eine neue Qualit\u00e4tsstufe \u00e4ndert die Canvas-Gr\u00f6\u00dfe und leert sie dabei.\n // Danach muss im selben Bild gezeichnet werden, sonst blitzt der deckende Himmel ein Bild lang schwarz auf.\n if (ms < 250) perf(ms);\n frame(dt);\n };\n const start = () => { if (running) return; running = true; last = performance.now(); gsap.ticker.add(tick); };\n const stop = () => { if (!running) return; running = false; gsap.ticker.remove(tick); };\n\n let rt;\n // nach dem Anpassen sofort zeichnen, damit keine geleerte Canvas zu sehen ist\n const onResize = () => { clearTimeout(rt); rt = setTimeout(() => { resize(); dirty = true; frame(0); }, 150); };\n addEventListener('resize', onResize);\n // jedes neu geladene Bild \u00fcbernehmen: beim Start, aber auch, wenn der Browser sp\u00e4ter ein anderes aus dem srcset holt\n img.addEventListener('load', () => { texW = 0; resize(); dirty = true; });\n\n // WebGL weg (iOS unter Speicherdruck oder nach l\u00e4ngerem Hintergrund): ruhig auf die Bilder zur\u00fcckfallen. Auch\n // V\u00f6gel und Wetter ausblenden, sonst standen sie mitten in der Bewegung eingefroren \u00fcber den Bildern.\n for (const cv of [skyCv, buildCv]) cv.addEventListener('webglcontextlost', e => { e.preventDefault(); stop(); hero.classList.remove('has-sky'); hero.classList.add('sky-lost'); }, { once: true });\n\n resize();\n start();\n\n return {\n get playing() { return playing; },\n // Zustand f\u00fcr die Ger\u00e4usche (sound.js): Anteile der Jahreszeiten, Tageszeit, Wetter\n get state() {\n const x = env.wx;\n return { w: env.w, hour: clock.hour, rise: env.rise, day: env.day, night: env.night, rain: x.rain, snow: x.snow, wind: x.wind, storm: x.storm, leaves: x.leaves };\n },\n play() { playing = true; lastLabel = ''; },\n // Karlshorst live: nach dem Start in den Zeitraffer \u00fcbergehen (mode 'start')\n release() { if (!released) { released = true; relT = time; lastLabel = ''; } },\n get mode() { return mode; },\n get live() { return isLive(); },\n windowAt, toggleWindow, shipAt,\n // Konsole: KL.debug.sky.ship() l\u00e4sst das Luftschiff sofort losfahren (1 nach rechts, -1 nach links)\n ship(dir) { launchShip(dir); },\n get airship() { return ship; },\n windowOn(i) { const w = WIN[i]; return !!w && (w.man != null ? w.man === 1 : w.autoOn); },\n pause() { playing = false; lastLabel = ''; dirty = true; },\n toggle() { playing ? this.pause() : this.play(); return playing; },\n setActive(v) { active = v; if (v) last = performance.now(); },\n // Konsole: KL.debug.sky.set({ hour: 21, season: 3 })\n set({ hour, season, day } = {}) {\n mode = 'zeitraffer'; released = true; clock.live = null; // Konsole: ab hier ohne Live-Werte\n if (hour != null) clock.hour = ((hour % 24) + 24) % 24;\n if (season != null) { clock.s0 = season; clock.day = 0; }\n if (day != null) clock.day = day;\n dirty = true; lastLabel = '';\n WIN.forEach(w => { w.lv = 0; });\n drops.length = flakes.length = leaves.length = petals.length = flocks.length = nearFlocks.length = 0;\n for (let i = 0; i < 90; i++) { env = environment(clock); lights(1 / 30, 2); } // Lichter sofort einschwingen\n },\n // Konsole: KL.debug.sky.birds('kraehe') startet einen Schwarm (Arten: spatz, segler, taube, kraehe, gans, moewe);\n // mit true als zweitem Wert fliegt ein einzelner Vogel nah vor den H\u00e4usern. at (0 \u2026 1) setzt ihn zum Testen direkt\n // an diese Stelle im Bild, dir 1 fliegt nach rechts, -1 nach links. Mitschnitt als Video: webflow/birdfilm.py\n birds(kind, near = false, at, dir) {\n if (!SPECIES[kind]) return;\n camera();\n spawnFlock(kind, near, at, dir);\n },\n get flocks() { return { far: flocks, near: nearFlocks }; },\n // Debug f\u00fcr Mitschnitte: Uhr anhalten (Wolken, Wetter und V\u00f6gel laufen weiter), von Hand Bild f\u00fcr Bild\n // rendern (manual + advance) und die zuf\u00e4lligen V\u00f6gel abschalten\n hold(v = true) { hold = v; },\n manual(v = true) { manual = v; },\n advance(dt = 1 / 30) { frame(dt); },\n autoBirds(v = true) { autoBirds = v; },\n // Debug: alle Arten in acht Phasen des Fl\u00fcgelschlags auf ein Canvas zeichnen\n birdSheet(cv, size = 34, yaw = 0.35, elev = 0.25) {\n const ctx = cv.getContext('2d'), kinds = Object.keys(SPECIES);\n cv.width = 9 * size * 3.2; cv.height = kinds.length * size * 3.2;\n ctx.fillStyle = '#8fbfe6'; ctx.fillRect(0, 0, cv.width, cv.height);\n ctx.lineJoin = 'round'; ctx.lineWidth = 0.7;\n kinds.forEach((k, r) => {\n const sp = SPECIES[k];\n for (let i = 0; i < 9; i++) {\n const glide = i === 8, b = { size, flap: glide ? 0 : 1, tuck: 0, hz: 1 };\n b.ph = glide ? 0 : i / 8 * 6.2832;\n drawBird(ctx, sp, b, (i + 0.5) * size * 3.2, (r + 0.5) * size * 3.2, frameOf(Math.cos(yaw), 0, Math.sin(yaw), 0, elev), '#1d2733');\n }\n });\n },\n get env() { return env; },\n get clock() { return clock; }\n };\n }\n\n KL.heroSky = { create };\n})();\n"},{"name":"sound.js","code":"/* ==========================================================================\n KARLS LIVING \u2014 Sound (nur nach Klick auf den Sound-Schalter)\n Alle Kl\u00e4nge werden live mit Web Audio erzeugt \u2013 keine Audiodateien.\n Im Hero folgt die Atmo dem Zeitraffer: Regen, Wind in B\u00f6en, Donner nach Blitzen,\n V\u00f6gel am Tag (vor allem Fr\u00fchling und Sommer) und Grillen in warmen N\u00e4chten.\n ========================================================================== */\n(() => {\n 'use strict';\n const KL = (window.KL = window.KL || {});\n const AC = window.AudioContext || window.webkitAudioContext;\n const rand = (a, b) => a + Math.random() * (b - a);\n const clamp01 = v => Math.min(1, Math.max(0, v));\n const sstep = (a, b, v) => { const t = clamp01((v - a) / (b - a)); return t * t * (3 - 2 * t); };\n const pick = weights => {\n let r = Math.random() * Object.values(weights).reduce((a, b) => a + b, 0);\n for (const [k, w] of Object.entries(weights)) { r -= w; if (r <= 0) return k; }\n return Object.keys(weights)[0];\n };\n\n let ctx = null, master = null, noise = null, white = null, hall = null, birdWave = null;\n let enabled = false, nature = 0, lastTick = 0, lastWhoosh = 0;\n let scene = null, heroMix = 1, atmo = null, loop = null, suspendTimer = null;\n\n function ensure() {\n if (ctx || !AC) return !!ctx;\n ctx = new AC();\n const comp = ctx.createDynamicsCompressor();\n comp.threshold.value = -18;\n comp.ratio.value = 4;\n master = ctx.createGain();\n master.gain.value = 0;\n master.connect(comp).connect(ctx.destination);\n const sr = ctx.sampleRate, len = sr * 4;\n // leicht gefiltertes (rosa-\u00e4hnliches) Rauschen und wei\u00dfes Rauschen, je 4 s (k\u00fcrzere Schleifen h\u00f6rt man)\n noise = ctx.createBuffer(1, len, sr);\n white = ctx.createBuffer(1, len, sr);\n const d = noise.getChannelData(0), wd = white.getChannelData(0);\n let b0 = 0, b1 = 0, b2 = 0;\n for (let i = 0; i < len; i++) {\n const w = Math.random() * 2 - 1;\n b0 = 0.99765 * b0 + w * 0.099;\n b1 = 0.963 * b1 + w * 0.2965;\n b2 = 0.57 * b2 + w * 1.0527;\n d[i] = (b0 + b1 + b2 + w * 0.1848) * 0.2;\n wd[i] = Math.random() * 2 - 1;\n }\n // Hall f\u00fcr drau\u00dfen (V\u00f6gel, Donner): abklingendes Rauschen als Raumantwort\n const irLen = Math.floor(sr * 1.6), ir = ctx.createBuffer(2, irLen, sr);\n for (let c = 0; c < 2; c++) {\n const id = ir.getChannelData(c);\n for (let i = 0; i < irLen; i++) id[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / irLen, 3.4);\n }\n hall = ctx.createConvolver();\n hall.buffer = ir;\n const hallLp = ctx.createBiquadFilter();\n hallLp.type = 'lowpass';\n hallLp.frequency.value = 5200;\n const hallOut = ctx.createGain();\n hallOut.gain.value = 0.55;\n hall.connect(hallLp).connect(hallOut).connect(master);\n // Vogelstimme: fast reiner Sinus mit leisen Obert\u00f6nen\n birdWave = ctx.createPeriodicWave(new Float32Array([0, 0, 0, 0]), new Float32Array([0, 1, 0.1, 0.03]));\n return true;\n }\n\n const env = (g, t, peak, attack, dur) => {\n g.gain.setValueAtTime(0.0001, t);\n g.gain.exponentialRampToValueAtTime(Math.max(0.0002, peak), t + attack);\n g.gain.exponentialRampToValueAtTime(0.0001, t + dur);\n };\n const filter = (type, f, q = 0.7) => { const b = ctx.createBiquadFilter(); b.type = type; b.frequency.value = f; b.Q.value = q; return b; };\n const gainOf = (v = 0) => { const g = ctx.createGain(); g.gain.value = v; return g; };\n const glide = (param, v, tc) => param.setTargetAtTime(v, ctx.currentTime, tc);\n // Ausgang mit Stereo-Position und etwas Hall (rev: Vogel-Hall oder der Raum f\u00fcr Wetter-Ger\u00e4usche)\n function out(node, pan = 0, wet = 0, dest = master, rev = hall) {\n let o = node;\n if (ctx.createStereoPanner) { const p = ctx.createStereoPanner(); p.pan.value = pan; node.connect(p); o = p; }\n o.connect(dest);\n if (wet > 0 && rev) { const s = gainOf(wet); o.connect(s).connect(rev); }\n }\n\n function tone({ f = 880, f2 = f, dur = 0.08, type = 'sine', gain = 0.1, attack = 0.004, pan = 0, when = 0 } = {}) {\n if (!enabled) return;\n const t = ctx.currentTime + when;\n const o = ctx.createOscillator();\n const g = ctx.createGain();\n o.type = type;\n o.frequency.setValueAtTime(f, t);\n if (f2 !== f) o.frequency.exponentialRampToValueAtTime(f2, t + dur);\n env(g, t, gain, attack, dur);\n o.connect(g);\n out(g, pan);\n o.start(t);\n o.stop(t + dur + 0.05);\n }\n\n function whoosh({ dur = 0.9, gain = 0.32, from = 280, peak = 2400, to = 420 } = {}) {\n if (!enabled) return;\n const now = performance.now();\n if (now - lastWhoosh < 250) return;\n lastWhoosh = now;\n const t = ctx.currentTime;\n const src = ctx.createBufferSource();\n src.buffer = noise;\n const bp = filter('bandpass', from, 0.8);\n bp.frequency.setValueAtTime(from, t);\n bp.frequency.exponentialRampToValueAtTime(peak, t + dur * 0.45);\n bp.frequency.exponentialRampToValueAtTime(to, t + dur);\n const g = ctx.createGain();\n env(g, t, gain, dur * 0.4, dur);\n src.connect(bp).connect(g).connect(master);\n src.start(t, rand(0, 3));\n src.stop(t + dur + 0.05);\n }\n\n const tick = () => {\n const now = performance.now();\n if (now - lastTick < 55) return;\n lastTick = now;\n tone({ f: rand(2100, 2500), f2: 1700, dur: 0.03, gain: 0.025 });\n };\n const click = () => tone({ f: 1150, f2: 480, dur: 0.08, type: 'triangle', gain: 0.09 });\n const pop = () => { tone({ f: rand(240, 300), f2: rand(680, 820), dur: 0.12, gain: 0.16 }); tone({ f: 1400, f2: 900, dur: 0.05, gain: 0.03, when: 0.01 }); };\n const chime = () => [784, 988, 1175, 1568].forEach((f, i) => tone({ f, dur: 0.9, gain: 0.05, attack: 0.01, when: i * 0.09, pan: (i - 1.5) * 0.3 }));\n // Lichtschalter (Fingertipp auf ein Fenster im Hero): heller Kunststoff-Klick mit kurzem Nachklingen der Wippe und\n // dumpfem Anschlag kurz danach; \u201ean\u201c klingt etwas heller als \u201eaus\u201c\n function lightSwitch(on = true) {\n if (!enabled) return;\n const t = ctx.currentTime + 0.005, k = on ? 1.1 : 0.9;\n const burst = (at, type, f, q, dur, peak) => {\n const src = ctx.createBufferSource(), g = gainOf(0);\n src.buffer = white;\n g.gain.setValueAtTime(0, at);\n g.gain.linearRampToValueAtTime(peak, at + 0.0007);\n g.gain.exponentialRampToValueAtTime(0.0001, at + dur);\n src.connect(filter(type, f, q)).connect(g);\n out(g, 0);\n src.start(at, rand(0, 3));\n src.stop(at + dur + 0.02);\n };\n const ring = (at, f, dur, peak) => {\n const o = ctx.createOscillator(), g = gainOf(0);\n o.frequency.setValueAtTime(f, at);\n o.frequency.exponentialRampToValueAtTime(f * 0.97, at + dur);\n g.gain.setValueAtTime(0, at);\n g.gain.linearRampToValueAtTime(peak, at + 0.001);\n g.gain.exponentialRampToValueAtTime(0.0001, at + dur);\n o.connect(g);\n out(g, 0);\n o.start(at);\n o.stop(at + dur + 0.02);\n };\n burst(t, 'bandpass', 4200 * k, 1.1, 0.011, 0.26);\n ring(t, 2650 * k, 0.03, 0.03);\n ring(t, 5400 * k, 0.018, 0.012);\n burst(t + 0.007, 'lowpass', 650 * k, 0.8, 0.035, 0.2);\n }\n // Z\u00e4hlwerk: Ticks, die langsamer werden (wie ein auslaufendes Rad)\n const roll = (count = 18, duration = 2.2) => {\n if (!enabled) return;\n for (let i = 0; i < count; i++) {\n const p = i / (count - 1);\n tone({ f: rand(1500, 1900), f2: 1200, dur: 0.02, gain: 0.03, when: duration * (1 - Math.pow(1 - p, 2.2)) });\n }\n };\n\n /* ---------- V\u00f6gel ---------- */\n // Ein Pfiff: Tonh\u00f6he folgt den St\u00fctzpunkten, weiche H\u00fcllkurve ohne Knacken, auf Wunsch mit Vibrato\n function note(t, dur, freqs, peak, v, vib = 0) {\n const o = ctx.createOscillator(), g = ctx.createGain();\n o.setPeriodicWave(birdWave);\n o.frequency.setValueAtTime(freqs[0], t);\n for (let i = 1; i < freqs.length; i++) o.frequency.linearRampToValueAtTime(freqs[i], t + dur * i / (freqs.length - 1));\n if (vib) {\n const l = ctx.createOscillator(), lg = gainOf(freqs[0] * 0.014);\n l.frequency.value = vib;\n l.connect(lg).connect(o.frequency);\n l.start(t); l.stop(t + dur + 0.15);\n }\n g.gain.setValueAtTime(0, t);\n g.gain.linearRampToValueAtTime(peak, t + Math.min(0.014, dur * 0.3));\n g.gain.setTargetAtTime(0, t + dur * 0.6, dur * 0.16);\n o.connect(g).connect(v);\n o.start(t);\n o.stop(t + dur + 0.15);\n }\n // Arten als Muster; jede liefert die Dauer ihrer Strophe\n const SONGS = {\n // Kohlmeise: \u201ezi-d\u00e4, zi-d\u00e4, zi-d\u00e4\u201c\n meise(t, L, v) {\n const hi = rand(5400, 6600), lo = hi * rand(0.6, 0.7), n = Math.round(rand(3, 5)), step = rand(0.23, 0.3);\n for (let i = 0; i < n; i++) {\n note(t + i * step, 0.08, [hi * 1.03, hi * 0.96], 0.068 * L, v);\n note(t + i * step + 0.11, 0.1, [lo * 1.06, lo * 0.95], 0.062 * L, v);\n }\n return n * step;\n },\n // Amsel: langsame, fl\u00f6tende Strophe mit Gleitt\u00f6nen, am Ende ein leiser Schn\u00f6rkel\n amsel(t, L, v) {\n let x = 0, f = rand(1700, 2500);\n const n = Math.round(rand(3, 6));\n for (let i = 0; i < n; i++) {\n const d = rand(0.13, 0.32), to = Math.min(3300, Math.max(1400, f * rand(0.78, 1.3)));\n note(t + x, d, [f, (f + to) / 2 * rand(0.96, 1.05), to], 0.075 * L, v, rand(18, 28));\n x += d + rand(0.05, 0.13);\n f = Math.min(3200, Math.max(1500, to * rand(0.85, 1.2)));\n }\n const m = Math.round(rand(2, 5));\n for (let i = 0; i < m; i++) { const q = rand(4800, 7000); note(t + x, 0.035, [q, q * 0.8], 0.03 * L, v); x += 0.05; }\n return x;\n },\n // Spatz: kurze \u201etschilp\u201c-Rufe in unregelm\u00e4\u00dfigem Abstand\n spatz(t, L, v) {\n let x = 0;\n const n = Math.round(rand(2, 6));\n for (let i = 0; i < n; i++) {\n const f = rand(2800, 4200);\n note(t + x, rand(0.07, 0.11), [f * 0.88, f * 1.24, f * 0.95], 0.064 * L, v);\n x += rand(0.16, 0.42);\n }\n return x;\n },\n // Rotkehlchen/Zaunk\u00f6nig: schnelles, perlendes Trillern, das abw\u00e4rts l\u00e4uft\n triller(t, L, v) {\n const n = Math.round(rand(10, 22)), rate = rand(16, 24), f0 = rand(5200, 6800), f1 = f0 * rand(0.6, 0.78);\n for (let i = 0; i < n; i++) {\n const p = i / (n - 1), f = f0 + (f1 - f0) * p;\n note(t + i / rate, 0.032, [f * 1.06, f * 0.94], 0.054 * L * (0.25 + 0.75 * Math.sin(Math.PI * Math.min(1, p * 1.25))), v);\n }\n return n / rate;\n }\n };\n // Ein Vogel irgendwo im Raum: n\u00e4her = lauter und trockener, weiter weg = leiser mit mehr Hall\n function song(kind, L, dest, answer = true) {\n const t = ctx.currentTime + 0.05, dist = rand(0.15, 1);\n const v = gainOf(1 - dist * 0.55);\n out(v, rand(-0.85, 0.85), 0.1 + dist * 0.35, dest);\n const len = SONGS[kind](t, L, v);\n setTimeout(() => v.disconnect(), (len + 2) * 1000);\n // manchmal antwortet ein zweiter Vogel derselben Art von woanders\n if (answer && Math.random() < 0.3) setTimeout(() => { if (enabled) song(kind, L * 0.8, dest, false); }, (len + rand(0.4, 1.2)) * 1000);\n }\n // Wer singt wann: Fr\u00fchling und Sommer viel, Herbst wenig, Winter kaum; morgens am meisten, bei Regen gar nicht\n function birdLevel(s) {\n const [sp, su, au, wi] = s.w;\n const season = sp + su * 0.85 + au * 0.25 + wi * 0.18;\n const dawn = Math.exp(-(((s.hour - (s.rise + 1)) / 1.6) ** 2));\n return season * clamp01(s.day * 0.8 + dawn * 0.7) * (1 - clamp01(s.rain * 1.4)) * (1 - s.storm) * (1 - s.snow * 0.7);\n }\n function birdKind(s) {\n if (!s) return pick({ meise: 3, spatz: 3, amsel: 2, triller: 2 });\n const [sp, su, au, wi] = s.w, dusk = s.hour > 14 && s.day < 0.6 ? 1 : 0; // abends singt vor allem die Amsel\n return pick({\n meise: sp * 3.5 + su * 2 + au * 3 + wi * 6,\n spatz: sp * 2 + su * 3.5 + au * 2 + wi * 4,\n amsel: (sp * 2.5 + su * 2.5 + au * 0.5) * (1 + dusk * 4),\n triller: sp * 2 + su * 2 + au * 5\n });\n }\n\n /* ---------- Raum f\u00fcr Wetter-Ger\u00e4usche ---------- */\n // Eigener Hall f\u00fcr Regen und Donner (die V\u00f6gel behalten ihren abgenommenen Hall): ein paar fr\u00fche Reflexionen\n // der Hausw\u00e4nde, dann ein Nachhall, der nach hinten dunkler wird (H\u00f6hen klingen schneller ab) \u2013 wie zwischen H\u00e4usern\n let space = null;\n function makeSpace() {\n const sr = ctx.sampleRate, len = Math.floor(sr * 2.6), ir = ctx.createBuffer(2, len, sr);\n for (let c = 0; c < 2; c++) {\n const d = ir.getChannelData(c);\n let lp = 0;\n for (let i = 0; i < len; i++) {\n const t = i / sr;\n lp += (Math.random() * 2 - 1 - lp) * (0.1 + 0.8 * Math.exp(-t * 2.2));\n d[i] = lp * Math.exp(-t * 2.4) * Math.min(1, t / 0.015);\n }\n [0.017, 0.029, 0.043, 0.061, 0.083, 0.11].forEach((dl, k) => { d[Math.floor((dl + c * 0.003 + Math.random() * 0.002) * sr)] += (0.5 - k * 0.06) * (Math.random() < 0.5 ? -1 : 1); });\n }\n space = ctx.createConvolver();\n space.buffer = ir;\n space.connect(filter('lowpass', 6500, 0.5)).connect(gainOf(0.55)).connect(master);\n }\n\n /* ---------- Regen ---------- */\n // Tropfen als Schleifen: viele weiche Ticks (Laub, Rasen), einige h\u00e4rtere Aufschl\u00e4ge mit etwas Klang (Blech, Pflaster)\n // und ab und zu ein \u201ePlink\u201c in einer Pf\u00fctze (Luftblase: kurzer Ton, der nach oben zieht). Die L\u00e4ngen passen nicht\n // zueinander, damit man die Schleifen nicht heraush\u00f6rt.\n function dropBuffer(sec, perSec) {\n const sr = ctx.sampleRate, len = Math.floor(sr * sec);\n const b = ctx.createBuffer(1, len, sr), d = b.getChannelData(0);\n for (let k = 0, n = Math.floor(sec * perSec); k < n; k++) {\n const at = Math.floor(Math.random() * len), kind = Math.random(), amp = 0.12 + Math.pow(Math.random(), 3.2) * 0.88;\n if (kind < 0.7) {\n const dec = rand(0.0015, 0.006), L = Math.floor(sr * dec * 6), a = rand(0.25, 0.7), q = sr * dec;\n let y = 0;\n for (let i = 0; i < L; i++) { y += (Math.random() * 2 - 1 - y) * a; d[(at + i) % len] += amp * y * Math.exp(-i / q) * Math.min(1, i / 6); }\n } else if (kind < 0.93) {\n const dec = rand(0.004, 0.014), w = 2 * Math.PI * rand(1800, 5200) / sr, L = Math.floor(sr * dec * 6), q = sr * dec;\n for (let i = 0; i < L; i++) d[(at + i) % len] += amp * Math.exp(-i / q) * Math.min(1, i / 8) * ((Math.random() * 2 - 1) * 0.55 + Math.sin(w * i) * 0.45);\n } else {\n const dur = rand(0.02, 0.05), f0 = rand(900, 1700), f1 = f0 * rand(1.5, 2.3), L = Math.floor(sr * dur * 1.5), q = sr * dur;\n let ph = 0;\n for (let i = 0; i < L; i++) { const p = i / q; ph += 2 * Math.PI * (f0 + (f1 - f0) * Math.min(1, p)) / sr; d[(at + i) % len] += amp * 0.55 * Math.sin(ph) * Math.exp(-p * 3) * Math.min(1, i / 20); }\n }\n }\n return normalize(b, 0.8);\n }\n // Wei\u00dfes Rauschen mit eigener L\u00e4nge je Kanal: So klingt der Regen breit statt aus der Mitte\n function noiseBuf(sec) {\n const sr = ctx.sampleRate, b = ctx.createBuffer(1, Math.floor(sr * sec), sr), d = b.getChannelData(0);\n for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;\n return b;\n }\n // Laub: k\u00f6rniges Rauschen in kleinen Sch\u00fcben, einzelne Bl\u00e4tter knistern (statt gleichm\u00e4\u00dfigem Zischen)\n function rustleBuffer(sec) {\n const sr = ctx.sampleRate, len = Math.floor(sr * sec), b = ctx.createBuffer(1, len, sr), d = b.getChannelData(0);\n let e = 0, goal = 0;\n for (let i = 0; i < len; i++) {\n if (Math.random() < 0.0009) goal = Math.random() < 0.6 ? rand(0.3, 1) : 0;\n e += (goal - e) * 0.0008;\n d[i] = (Math.random() * 2 - 1) * e * (Math.random() < 0.02 ? rand(1, 3) : 1);\n }\n const f = Math.floor(sr * 0.05); // weiche \u00dcberg\u00e4nge an den Schleifen-Enden\n for (let i = 0; i < f; i++) { d[i] *= i / f; d[len - 1 - i] *= i / f; }\n return normalize(b, 0.8);\n }\n function normalize(b, to) {\n let pk = 0;\n for (let c = 0; c < b.numberOfChannels; c++) { const d = b.getChannelData(c); for (let i = 0; i < d.length; i++) pk = Math.max(pk, Math.abs(d[i])); }\n for (let c = 0; c < b.numberOfChannels; c++) { const d = b.getChannelData(c); for (let i = 0; i < d.length; i++) d[i] *= to / (pk || 1); }\n return b;\n }\n const peaking = (f, q, db) => { const b = filter('peaking', f, q); b.gain.value = db; return b; };\n // Einzelner Tropfen aus der Regenrinne oder vom Balkon: runder, tiefer \u201ePlopp\u201c, der nach oben zieht, mit Spritzer\n function drip(level, dest) {\n const t = ctx.currentTime + rand(0.01, 0.15), f = rand(380, 820), dur = rand(0.05, 0.1), pan = rand(-0.85, 0.85);\n const o = ctx.createOscillator(), g = gainOf(0);\n o.frequency.setValueAtTime(f, t);\n o.frequency.exponentialRampToValueAtTime(f * rand(1.3, 1.8), t + dur * 0.6);\n g.gain.setValueAtTime(0, t);\n g.gain.linearRampToValueAtTime(level, t + 0.003);\n g.gain.exponentialRampToValueAtTime(0.0001, t + dur);\n o.connect(g);\n out(g, pan, 0.25, dest, atmo ? atmo.rev : space);\n o.start(t);\n o.stop(t + dur + 0.02);\n const s = ctx.createBufferSource(), sg = gainOf(0);\n s.buffer = white;\n sg.gain.setValueAtTime(0, t);\n sg.gain.linearRampToValueAtTime(level * 0.45, t + 0.002);\n sg.gain.exponentialRampToValueAtTime(0.0001, t + 0.03);\n s.connect(filter('bandpass', rand(2500, 4500), 1.2)).connect(sg);\n out(sg, pan, 0, dest);\n s.start(t, rand(0, 3));\n s.stop(t + 0.05);\n }\n\n /* ---------- Atmo im Hero ---------- */\n function startAtmo() {\n if (atmo) return;\n if (!space) makeSpace();\n const srcs = [];\n const loopOf = (buf, offset) => { const s = ctx.createBufferSource(); s.buffer = buf; s.loop = true; s.start(ctx.currentTime, offset); srcs.push(s); return s; };\n const bus = gainOf(0);\n bus.connect(master);\n // Hall-Zuleitung f\u00fcr Donner und Tropfen: folgt wie der Bus dem Hero (heroMix). Direkt in den Raum geschickt\n // hallte ein Donner nach dem Wegscrollen noch sekundenlang in voller St\u00e4rke \u00fcber dem n\u00e4chsten Abschnitt nach.\n const rev = gainOf(heroMix);\n rev.connect(space);\n // Regen: breites Rauschen (je Kanal eigenes, betont um 3 kHz wie echter Landregen), drei Tropfen-Schleifen und bei\n // Starkregen das Rauschen der ganzen Fl\u00e4che, das langsam an- und abschwillt\n const hiss = gainOf(0), drops = gainOf(0), body = gainOf(0);\n [[noiseBuf(8.3), -0.8], [noiseBuf(7.1), 0.8]].forEach(([buf, pan]) => out(loopOf(buf, 0).connect(filter('highpass', 420, 0.5)).connect(peaking(3200, 0.9, 4)).connect(filter('lowpass', 9000, 0.5)), pan, 0, hiss));\n [[7.3, -0.6], [9.1, 0.6], [8.2, 0]].forEach(([sec, pan]) => out(loopOf(dropBuffer(sec, 48), 0).connect(filter('lowpass', 9500)), pan, 0, drops));\n const dropWet = gainOf(0); // Hall erst hinter dem Pegel und mit dem Hero ausgeblendet, sonst hallt es auch ohne Regen\n drops.connect(dropWet).connect(space);\n const swell = gainOf(1), lfo = ctx.createOscillator(), lfoG = gainOf(0.25);\n lfo.frequency.value = 0.13;\n lfo.connect(lfoG).connect(swell.gain);\n lfo.start();\n srcs.push(lfo);\n loopOf(noise, 0.9).connect(filter('highpass', 160)).connect(filter('lowpass', 1400)).connect(swell).connect(body);\n // Wind als Luft in den B\u00e4umen (neu 27.09., Kumpel: \u201epenetrant, klingt eher nach einem vorbeifahrenden Zug\u201c: vorher\n // kam 2/3 der Energie aus tiefem Brausen unter 200 Hz, dazu ein Band, das mit jeder B\u00f6e hoch- und runterzog, und ein\n // Pfeifen). Jetzt zwei breite, leise Rauschb\u00e4nder um 0,7\u20132 kHz, links und rechts leicht versetzt, ohne Tiefen, die\n // in langsamen B\u00f6en an- und abschwellen; dazu raschelt das Laub (im Winter kaum)\n const wind = gainOf(0), windL = filter('bandpass', 700, 0.5), windR = filter('bandpass', 850, 0.5), rustle = gainOf(0);\n out(loopOf(noiseBuf(9.7), 0).connect(filter('highpass', 260, 0.5)).connect(filter('lowpass', 3200, 0.5)).connect(windL), -0.55, 0, wind);\n out(loopOf(noiseBuf(8.9), 0).connect(filter('highpass', 260, 0.5)).connect(filter('lowpass', 3200, 0.5)).connect(windR), 0.55, 0, wind);\n [[6.7, -0.45], [5.9, 0.5]].forEach(([sec, pan]) => out(loopOf(rustleBuffer(sec), 0).connect(filter('highpass', 1500)).connect(filter('lowpass', 5500)), pan, 0, rustle));\n [hiss, drops, body, wind, rustle].forEach(n => n.connect(bus));\n // Grillen: Dauerton, der in kurzen Pulsen aufgeht (drei Pulse je Zirpen)\n const crickets = [[4450, -0.45], [4700, 0.55]].map(([f, pan]) => {\n const o = ctx.createOscillator(), g = gainOf(0);\n o.frequency.value = f + rand(-60, 60);\n o.connect(g);\n out(g, pan, 0, bus);\n o.start();\n srcs.push(o);\n return { g, next: 0, period: rand(0.5, 0.68) };\n });\n atmo = { bus, rev, hiss, drops, dropWet, body, wind, windL, windR, rustle, crickets, srcs, nextBird: 0, nextGust: 0, nextDrip: 0 };\n prepareThunder();\n }\n function stopAtmo() {\n if (!atmo) return;\n const a = atmo;\n atmo = null;\n a.srcs.forEach(s => { try { s.stop(); } catch (e) { /* schon gestoppt */ } });\n a.bus.disconnect();\n a.rev.disconnect();\n }\n\n // Alle 200 ms: Pegel an das Wetter angleichen und die n\u00e4chsten V\u00f6gel, Tropfen und Grillen einplanen\n function update() {\n if (!enabled || !atmo) return;\n const now = ctx.currentTime, s = scene ? scene() : null, a = atmo;\n glide(a.bus.gain, heroMix, 0.3);\n glide(a.rev.gain, heroMix, 0.3);\n glide(a.dropWet.gain, 0.06 * heroMix, 0.3);\n const rain = s ? s.rain : 0;\n glide(a.hiss.gain, 0.076 * Math.pow(rain, 1.2), 0.8);\n glide(a.drops.gain, 0.215 * sstep(0.02, 0.6, rain), 0.8);\n glide(a.body.gain, 0.066 * sstep(0.5, 1, rain), 1.2);\n // einzelne Tropfen aus Rinnen und von Balkonen\n if (rain > 0.05 && heroMix > 0.05 && now >= a.nextDrip) { drip(0.05 * (0.4 + 0.6 * rain), a.bus); a.nextDrip = now + rand(0.15, 1.1) / (0.4 + rain); }\n if (now >= a.nextGust) {\n // B\u00f6en langsam wie echte Luftbewegung: alle 4\u20139 s ein neues Ziel, An- und Abschwellen \u00fcber mehrere Sekunden\n // (vorher alle 1\u20133 s in gut einer Sekunde, das klang nach vorbeifahrenden Fahrzeugen)\n const windy = s ? clamp01(sstep(0.45, 0.95, s.wind) + s.storm * 0.4) : 0;\n const gust = windy * rand(0.3, 1), tc = rand(1.6, 3.2);\n const canopy = s && s.w ? 1 - 0.75 * s.w[3] : 1; // Laub an den B\u00e4umen, im Winter kaum\n glide(a.wind.gain, 0.026 * gust, tc);\n glide(a.windL.frequency, 520 + 650 * gust + rand(-60, 60), tc * 1.3);\n glide(a.windR.frequency, 600 + 720 * gust + rand(-60, 60), tc * 1.5);\n glide(a.rustle.gain, (0.0045 * canopy + 0.0035 * (s ? s.leaves : 0)) * gust, tc * 0.8);\n a.nextGust = now + rand(4, 9);\n }\n // V\u00f6gel: im Hero nach Jahreszeit und Wetter, in den gr\u00fcnen Abschnitten immer ein paar\n if (now >= a.nextBird) {\n const hero = s ? birdLevel(s) * heroMix : 0, green = nature * 0.7;\n const L = Math.max(hero, green);\n if (L > 0.04) song(birdKind(hero >= green ? s : null), 0.55 + 0.45 * L, hero >= green ? a.bus : master);\n a.nextBird = now + rand(2.5, 7) / (0.45 + L * 0.8);\n }\n // Grillen in warmen N\u00e4chten\n const cr = s ? (s.w[1] + s.w[0] * 0.35) * s.night * (1 - clamp01(s.rain * 2)) * (1 - s.snow) : 0;\n a.crickets.forEach(c => {\n if (cr < 0.03) { c.next = 0; return; }\n if (c.next < now) c.next = now + rand(0.05, 0.4);\n while (c.next < now + 0.35) {\n for (let k = 0; k < 3; k++) {\n const t = c.next + k * 0.034;\n c.g.gain.setValueAtTime(0, t);\n c.g.gain.linearRampToValueAtTime(0.026 * cr, t + 0.006);\n c.g.gain.linearRampToValueAtTime(0, t + 0.02);\n }\n c.next += c.period * rand(0.94, 1.06);\n }\n });\n }\n\n /* ---------- Donner ---------- */\n // Echtes Grollen besteht aus vielen einzelnen Schl\u00e4gen, die aus verschiedenen Teilen des Blitzkanals zu verschiedenen\n // Zeiten ankommen: am Anfang dicht, dann seltener, tief und rollend; nahe Blitze beginnen mit einem hellen Krachen.\n // Die Aufnahmen werden einmal vorab berechnet (je zwei nahe und drei ferne, nacheinander, damit nichts ruckelt) und\n // mit etwas anderem Tempo abgespielt, damit jeder Donner anders klingt. Dazu der Hall zwischen den H\u00e4usern.\n const thunders = { near: [], far: [] };\n let thunderPrep = false;\n // Als Generator: Nach jedem Einzelschlag darf prepareThunder eine Pause machen (Rechnen in H\u00e4ppchen)\n function* thunderGen(near) {\n const sr = ctx.sampleRate, dur = near ? rand(6.5, 8.5) : rand(5.5, 7.5), len = Math.floor(sr * dur);\n const b = ctx.createBuffer(2, len, sr), L = b.getChannelData(0), R = b.getChannelData(1);\n for (let e = 0, n = Math.round(near ? rand(26, 40) : rand(16, 28)); e < n; e++) {\n const at = (near ? 0.02 : 0.1) + Math.pow(Math.random(), 1.7) * dur * 0.7;\n const amp = (0.3 + 0.7 * Math.random()) * Math.exp(-at / (dur * 0.33));\n const dec = rand(0.2, 1.2), fc = rand(45, near ? 520 : 260), a = 1 - Math.exp(-2 * Math.PI * fc / sr);\n const pan = rand(-0.8, 0.8), gl = Math.sqrt(0.5 * (1 - pan)), gr = Math.sqrt(0.5 * (1 + pan)), k = amp / Math.pow(a, 0.75);\n const s0 = Math.floor(at * sr), m = Math.min(len - s0, Math.floor(dec * 5 * sr)), q = dec * sr, att = 0.03 * sr;\n // Abklingen exp(-i / q) als laufendes Produkt: gleicher Klang, aber ohne Exponentialfunktion je Sample\n const fall = Math.exp(-1 / q);\n let y1 = 0, y2 = 0, env = k;\n for (let i = 0; i < m; i++) {\n y1 += (Math.random() * 2 - 1 - y1) * a;\n y2 += (y1 - y2) * a;\n const v = y2 * env * (i < att ? i / att : 1);\n env *= fall;\n L[s0 + i] += v * gl;\n R[s0 + i] += v * gr;\n }\n yield;\n }\n if (near) {\n for (let c = 0, n = Math.round(rand(3, 6)); c < n; c++) {\n const s0 = Math.floor(rand(0, 0.18) * sr), q = rand(0.006, 0.03) * sr, amp = rand(0.5, 1.2), m = Math.floor(q * 6), wr = rand(0.7, 1);\n let x0 = 0;\n for (let i = 0; i < m; i++) { const x = Math.random() * 2 - 1, v = (x - x0) * amp * Math.exp(-i / q); x0 = x; L[s0 + i] += v; R[s0 + i] += v * wr; }\n }\n }\n return normalize(b, 0.9);\n }\n function prepareThunder() {\n if (thunderPrep) return;\n thunderPrep = true;\n const todo = [true, false, false, true, false];\n let gen = null, near = false;\n // in H\u00e4ppchen von gut 6 ms (vorher je Aufnahme ein Block von 30\u201385 ms, am Handy ein Vielfaches: Zeitraffer ruckelte\n // nach dem Einschalten des Sounds)\n const slice = () => {\n if (!ctx) return;\n const t0 = performance.now();\n while (performance.now() - t0 < 6) {\n if (!gen) { if (!todo.length) return; near = todo.shift(); gen = thunderGen(near); }\n const r = gen.next();\n if (r.done) { (near ? thunders.near : thunders.far).push(r.value); gen = null; }\n }\n setTimeout(slice, 16);\n };\n setTimeout(slice, 400);\n }\n function thunder(k = 1) {\n if (!enabled || !atmo || heroMix < 0.05) return;\n const near = Math.random() < 0.35, list = near ? thunders.near : thunders.far;\n if (!list.length) return;\n const strength = 0.6 + 0.4 * clamp01(k), t = ctx.currentTime + (near ? rand(0.25, 0.6) : rand(0.8, 2.2));\n const src = ctx.createBufferSource(), g = gainOf((near ? 0.62 : 0.5) * strength);\n src.buffer = list[Math.floor(Math.random() * list.length)];\n src.playbackRate.value = rand(0.88, 1.08);\n src.connect(g);\n out(g, rand(-0.3, 0.3), near ? 0.3 : 0.5, atmo.bus, atmo.rev);\n src.start(t);\n }\n\n // iPhone: Web Audio l\u00e4uft sonst als \u201eUmgebungs\u201c-Ton und bleibt bei Lautlos-Schalter stumm. Als Wiedergabe\n // (wie ein Video) ist der Sound nach dem Einschalten h\u00f6rbar; beim Ausschalten zur\u00fcck auf automatisch.\n const session = type => { try { if (navigator.audioSession) navigator.audioSession.type = type; } catch (e) { /* \u00e4lteres iOS */ } };\n // Vor iOS 16.4 fehlt audioSession: Dort schaltet ein stummes