Files
NotPika69andClaude Opus 5.5 09f40da363 Echte Karte auf Unternehmen und Kontakt (MapLibre + OpenFreeMap)
Ersetzt die schematische Karte durch MapLibre GL 6.11 (lokal
eingebunden, ohne React, da die Seite statisch ist). Kacheln vom
OpenFreeMap-Stil "dark", auf die Nachtblau-Palette umgefärbt; Marker
mit Label, lädt beim Heranscrollen und zoomt sanft auf den Standort.
Die schematische Karte bleibt als Vorschau und Fallback ohne WebGL.
Datenschutzerklärung um den Kartenanbieter ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:00:33 +02:00

325 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ==========================================================================
Kirschner Gebäudeservice — Motion
Ein Modul. Alles Bewegte läuft in gsap.matchMedia(): bei reduced motion
bleibt nur Funktion (Slider, Menü, Umschalter), keine Choreografie.
Jede Animation hat einen Zweck — Liste in DESIGN.md.
========================================================================== */
(() => {
const root = document.documentElement;
root.classList.add('motion-ready');
const $ = (s, c = document) => c.querySelector(s);
const $$ = (s, c = document) => [...c.querySelectorAll(s)];
let lenis = null;
/* ---------- Fakten, die sich selbst aktualisieren ---------- */
const years = new Date().getFullYear() - 2003;
$$('[data-years]').forEach((el) => { el.textContent = years; });
$$('[data-year]').forEach((el) => { el.textContent = new Date().getFullYear(); });
/* ---------- Header: fest nach 40px, weg beim Runterscrollen ---------- */
const header = $('.site-header');
const menuToggle = $('.menu-toggle');
let lastY = scrollY;
const onScroll = () => {
const y = scrollY;
if (header) {
header.classList.toggle('is-solid', y > 40);
const menuOpen = menuToggle && menuToggle.getAttribute('aria-expanded') === 'true';
header.classList.toggle('is-hidden', !menuOpen && y > 480 && y > lastY + 2);
if (y < lastY - 2) header.classList.remove('is-hidden');
}
lastY = y;
};
addEventListener('scroll', onScroll, { passive: true });
onScroll();
/* ---------- Mobiles Menü ---------- */
const mnav = $('#mobile-nav');
if (menuToggle && mnav) {
mnav.inert = true;
const setMenu = (open) => {
menuToggle.setAttribute('aria-expanded', String(open));
menuToggle.setAttribute('aria-label', open ? 'Menü schließen' : 'Menü öffnen');
mnav.classList.toggle('is-open', open);
mnav.inert = !open;
document.body.style.overflow = open ? 'hidden' : '';
if (lenis) open ? lenis.stop() : lenis.start();
if (open && window.gsap && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.fromTo($$('.mobile-nav__link, .mobile-nav__meta', mnav),
{ yPercent: 60, opacity: 0 },
{ yPercent: 0, opacity: 1, duration: .8, ease: 'expo.out', stagger: .05, delay: .18 });
}
if (open) $('.mobile-nav__link', mnav)?.focus({ preventScroll: true });
};
menuToggle.addEventListener('click', () => setMenu(menuToggle.getAttribute('aria-expanded') !== 'true'));
addEventListener('keydown', (e) => { if (e.key === 'Escape' && mnav.classList.contains('is-open')) { setMenu(false); menuToggle.focus(); } });
$$('a', mnav).forEach((a) => a.addEventListener('click', () => setMenu(false)));
matchMedia('(min-width: 1081px)').addEventListener('change', (e) => { if (e.matches) setMenu(false); });
}
/* ---------- Vorher/Nachher-Slider (Zeiger + Tastatur) ---------- */
$$('.compare').forEach((cmp) => {
let pos = 50;
const set = (v) => {
pos = Math.max(0, Math.min(100, v));
cmp.style.setProperty('--pos', pos + '%');
cmp.setAttribute('aria-valuenow', Math.round(pos));
cmp.setAttribute('aria-valuetext', `${Math.round(pos)} % Vorher-Ansicht`);
};
const fromEvent = (e) => {
const r = cmp.getBoundingClientRect();
set(((e.clientX - r.left) / r.width) * 100);
};
let dragging = false;
cmp.addEventListener('pointerdown', (e) => { dragging = true; cmp.setPointerCapture(e.pointerId); fromEvent(e); });
cmp.addEventListener('pointermove', (e) => { if (dragging) fromEvent(e); });
cmp.addEventListener('pointerup', () => { dragging = false; });
cmp.addEventListener('pointercancel', () => { dragging = false; });
cmp.addEventListener('keydown', (e) => {
const step = e.shiftKey ? 10 : 2;
const map = { ArrowLeft: pos - step, ArrowDown: pos - step, ArrowRight: pos + step, ArrowUp: pos + step, Home: 0, End: 100, PageDown: pos - 10, PageUp: pos + 10 };
if (e.key in map) { e.preventDefault(); set(map[e.key]); }
});
set(50);
cmp._set = set;
});
/* ---------- Notruf-Pille: nach dem Hero sichtbar, am Seitenende weg ---------- */
const sos = $('.sos');
if (sos) {
const hero = $('.hero, .page-hero');
const footer = $('.site-footer');
let pastHero = false, atFooter = false;
const update = () => sos.classList.toggle('is-visible', pastHero && !atFooter);
if (hero) new IntersectionObserver(([e]) => { pastHero = !e.isIntersecting; update(); }, { rootMargin: '-20% 0px 0px 0px' }).observe(hero);
if (footer) new IntersectionObserver(([e]) => { atFooter = e.isIntersecting; update(); }).observe(footer);
}
/* ======================================================================
Ab hier: Choreografie. Ohne GSAP bleibt alles sichtbar und benutzbar.
====================================================================== */
if (!window.gsap || !window.ScrollTrigger || !window.SplitText) {
root.classList.remove('js', 'has-intro');
return;
}
gsap.registerPlugin(ScrollTrigger, SplitText);
gsap.defaults({ ease: 'expo.out', duration: 1 });
const intro = $('.intro');
/* Intro: zwei Chevrons + Wortmarke, dann Winkel-Wipe (nur 1× pro Session) */
const playIntro = () => new Promise((resolve) => {
if (!root.classList.contains('has-intro') || !intro) { resolve(); return; }
try { sessionStorage.setItem('kk-intro', '1'); } catch (e) { /* privat */ }
intro.style.animation = 'none';
gsap.set(intro, { clipPath: 'polygon(0% 0%, 120% 0%, 100% 50%, 120% 100%, 0% 100%)' });
const tl = gsap.timeline({
onComplete: () => { root.classList.remove('has-intro'); resolve(); },
});
tl.from('.intro__mark .chev', { xPercent: 140, opacity: 0, duration: .5, stagger: .09 })
.from('.intro__word span, .intro__word small', { yPercent: 115, duration: .5, stagger: .06 }, .15)
.to(intro, { clipPath: 'polygon(0% 0%, 0% 0%, -20% 50%, 0% 100%, 0% 100%)', duration: .7, ease: 'expo.inOut' }, .75)
.add(resolve, 1.0); /* Hero startet, während der Wipe noch läuft */
intro.addEventListener('click', () => tl.progress(1), { once: true });
});
const splitLines = (el, vars = {}) => SplitText.create(el, {
type: 'lines',
mask: 'lines',
linesClass: 'line',
autoSplit: true,
onSplit(self) {
el.classList.add('is-split');
return gsap.from(self.lines, { yPercent: 115, duration: 1.15, stagger: .09, ...vars });
},
});
const mm = gsap.matchMedia();
mm.add({
motion: '(prefers-reduced-motion: no-preference)',
desktop: '(min-width: 1024px)',
fine: '(pointer: fine)',
}, (ctx) => {
const { motion, desktop, fine } = ctx.conditions;
if (!motion) return;
/* ---- Smooth-Scroll (Lenis), synchron mit ScrollTrigger; Touch bleibt nativ ---- */
lenis = new Lenis({ lerp: .1, wheelMultiplier: 1, anchors: { offset: -90 } });
lenis.on('scroll', ScrollTrigger.update);
window.lenis = lenis; /* für Anker-Sprünge & Debugging */
const raf = (t) => lenis.raf(t * 1000);
gsap.ticker.add(raf);
gsap.ticker.lagSmoothing(0);
/* ---- Hero ---- */
const hero = $('.hero, .page-hero');
const heroH1 = hero && $('[data-split]', hero);
const heroRest = hero ? $$('[data-hero]', hero) : [];
const heroPaths = hero ? $$('.hero__chevrons path', hero) : [];
if (heroRest.length) gsap.set(heroRest, { opacity: 0, y: 24 });
if (heroPaths.length) gsap.set(heroPaths, { strokeDasharray: 1, strokeDashoffset: 1 });
const startHero = () => {
if (heroH1) splitLines(heroH1, { delay: .05 });
if (heroRest.length) gsap.to(heroRest, { opacity: 1, y: 0, duration: 1.1, stagger: .08, delay: .35 });
if (heroPaths.length) gsap.to(heroPaths, { strokeDashoffset: 0, duration: 1.8, ease: 'expo.inOut', stagger: .12, delay: .1 });
const media = hero && $('.hero__media img', hero);
if (media) gsap.from(media, { scale: 1.12, duration: 2.2, ease: 'expo.out' });
};
document.fonts.ready.then(playIntro).then(startHero);
if (hero) {
const chev = $('.hero__chevrons', hero);
const media = $('.hero__media img', hero);
const content = $('.hero__content, .page-hero__grid', hero);
const st = { trigger: hero, start: 'top top', end: 'bottom top', scrub: true };
if (chev) gsap.to(chev, { yPercent: -18, xPercent: 6, ease: 'none', scrollTrigger: st });
if (media) gsap.to(media, { yPercent: 10, ease: 'none', scrollTrigger: { ...st } });
if (content) gsap.to(content, { y: -60, opacity: .2, ease: 'none', scrollTrigger: { ...st, start: 'center center' } });
}
/* ---- Leistungen: horizontal gepinnt (Desktop) ---- */
const hs = $('.hscroll');
let hsTween = null;
if (hs && desktop) {
hs.classList.add('is-pinned');
const track = $('.hscroll__track', hs);
const bar = $('.hscroll__bar i', hs);
const count = $('[data-hs-count]', hs);
const panels = $$('.panel', hs);
gsap.set(panels, { opacity: 1, y: 0 });
const dist = () => Math.max(0, track.scrollWidth - innerWidth);
hsTween = gsap.to(track, {
x: () => -dist(),
ease: 'none',
scrollTrigger: {
trigger: hs,
start: 'top top',
end: () => '+=' + dist(),
pin: true,
scrub: .8,
invalidateOnRefresh: true,
onUpdate: (self) => {
if (bar) bar.style.transform = `scaleX(${self.progress})`;
if (count) count.textContent = String(Math.min(panels.length, Math.floor(self.progress * panels.length) + 1)).padStart(2, '0');
},
},
});
panels.forEach((p) => {
const img = $('img', p);
if (img) gsap.fromTo(img, { xPercent: -6, scale: 1.18 }, {
xPercent: 6, scale: 1.18, ease: 'none',
scrollTrigger: { trigger: p, containerAnimation: hsTween, start: 'left right', end: 'right left', scrub: true },
});
});
}
/* ---- Textzeilen aus der Maske ---- */
$$('[data-split]').forEach((el) => {
if (hero && hero.contains(el)) return;
splitLines(el, { scrollTrigger: { trigger: el, start: 'top 86%', once: true } });
});
/* ---- Blöcke gestaffelt einblenden ---- */
const reveals = $$('[data-reveal]').filter((el) => !(hs && desktop && hs.contains(el)));
ScrollTrigger.batch(reveals, {
start: 'top 88%',
once: true,
onEnter: (batch) => gsap.to(batch, { opacity: 1, y: 0, duration: 1, stagger: .08, overwrite: true }),
});
/* ---- Winkel-Reveal für Bilder (Signatur) ---- */
$$('.reveal-img').forEach((el) => {
const img = $('img', el);
const tl = gsap.timeline({ scrollTrigger: { trigger: el, start: 'top 82%', once: true } });
tl.to(el, { '--x': '0%', duration: 1.35, ease: 'expo.inOut' });
if (img) tl.from(img, { scale: 1.18, duration: 1.8, ease: 'expo.out' }, 0);
});
/* ---- Philosophie: Wort für Wort hell (gescrubbt) ---- */
$$('[data-scrub-words]').forEach((el) => {
SplitText.create(el, {
type: 'words',
wordsClass: 'word',
autoSplit: true,
onSplit(self) {
return gsap.fromTo(self.words, { opacity: .14 }, {
opacity: 1, ease: 'none', stagger: .12,
scrollTrigger: { trigger: el, start: 'top 78%', end: 'bottom 42%', scrub: true },
});
},
});
});
/* ---- Timeline-Linie zeichnet sich ---- */
$$('.timeline').forEach((tl) => {
const line = $('.timeline__line i', tl);
if (line) gsap.fromTo(line, { scaleY: 0 }, {
scaleY: 1, ease: 'none',
scrollTrigger: { trigger: tl, start: 'top 70%', end: 'bottom 65%', scrub: true },
});
});
/* ---- Philosophie: Hintergrundbild läuft langsamer mit ---- */
$$('.philosophy__media picture').forEach((el) => {
gsap.fromTo(el, { yPercent: -8 }, {
yPercent: 8, ease: 'none',
scrollTrigger: { trigger: el.closest('section'), start: 'top bottom', end: 'bottom top', scrub: true },
});
});
/* ---- Parallax für einzelne Elemente ---- */
$$('[data-parallax]').forEach((el) => {
const amt = Number(el.dataset.parallax) || 10;
gsap.fromTo(el, { yPercent: -amt }, {
yPercent: amt, ease: 'none',
scrollTrigger: { trigger: el.parentElement, start: 'top bottom', end: 'bottom top', scrub: true },
});
});
/* ---- Vorher/Nachher: einmal kurz zeigen, dass man ziehen kann ---- */
$$('.compare').forEach((cmp) => {
const o = { v: 50 };
gsap.timeline({ scrollTrigger: { trigger: cmp, start: 'top 70%', once: true } })
.to(o, { v: 72, duration: .9, ease: 'expo.inOut', onUpdate: () => cmp._set(o.v) })
.to(o, { v: 50, duration: .9, ease: 'expo.inOut', onUpdate: () => cmp._set(o.v) });
});
/* ---- Magnet-Buttons (nur Maus) ---- */
if (fine) {
$$('.btn, [data-magnetic]').forEach((btn) => {
const xTo = gsap.quickTo(btn, 'x', { duration: .6, ease: 'expo.out' });
const yTo = gsap.quickTo(btn, 'y', { duration: .6, ease: 'expo.out' });
const clamp = gsap.utils.clamp(-6, 6);
const move = (e) => {
const r = btn.getBoundingClientRect();
xTo(clamp((e.clientX - r.left - r.width / 2) * .18));
yTo(clamp((e.clientY - r.top - r.height / 2) * .3));
};
const leave = () => { xTo(0); yTo(0); };
btn.addEventListener('pointermove', move);
btn.addEventListener('pointerleave', leave);
ctx.add(() => () => { btn.removeEventListener('pointermove', move); btn.removeEventListener('pointerleave', leave); });
});
}
/* Nach Font-Load und Bildern Layout neu messen */
document.fonts.ready.then(() => ScrollTrigger.refresh());
addEventListener('load', () => ScrollTrigger.refresh(), { once: true });
return () => {
gsap.ticker.remove(raf);
lenis?.destroy();
lenis = null;
hs?.classList.remove('is-pinned');
};
});
/* Reduced motion: Intro nie zeigen, Zähler stehen schon auf Endwert (HTML) */
mm.add('(prefers-reduced-motion: reduce)', () => {
root.classList.remove('has-intro');
});
})();