Template
Maßgeschneiderte Website für Olzinger Haustechnik
Neuer statischer Webauftritt für die Olzinger GmbH (Heizung, Sanitär, Lüftung, regenerative Energien – Ergolding bei Landshut, seit 1985). - Hero mit live berechnetem WebGL-„Wärmebild“: Maus als Wärmequelle, Wärmespur, Isothermen und Temperaturanzeige; Preloader „Thermostat“ - Gestapelte Leistungskarten mit animierten SVG-Illustrationen - Interaktives Energie-Haus (Wärmepumpe, Solarthermie, Speicher, Fußbodenheizung, Bad, Lüftung) mit Tabs, Hotspots und Autoplay - Heizungs-Finder mit Empfehlung und Übernahme ins Kontaktformular - Ablauf als sich füllende Rohrleitung, Zeitleiste mit Zählwerk, Live-Öffnungsstatus inkl. bayerischer Feiertage - Kontaktformular mit PHP-Versand (Honeypot, Zeitfalle) und mailto-Fallback; Impressum, Datenschutz, 404-Seite - Smooth-Scroll (Lenis) und GSAP-Animationen, alles lokal gehostet; reduzierte Bewegung, Betrieb ohne JavaScript, axe ohne Befunde - SEO: Schema.org HVACBusiness, Open-Graph-Bild, Sitemap, robots.txt Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kjd7UMh3HKyVziCgiZ9A2i
This commit is contained in:
34 files changed
+5377
No files matched your search
@@ -0,0 +1,233 @@
|
||||
/* ==========================================================================
|
||||
Olzinger – Öffnungszeiten (live, inkl. bayerischer Feiertage) & Kontaktformular
|
||||
========================================================================== */
|
||||
(function (w, d) {
|
||||
'use strict';
|
||||
|
||||
/* ---------- Öffnungszeiten ---------- */
|
||||
// Minuten seit Mitternacht; Index = Wochentag (0 = Sonntag)
|
||||
var SCHEDULE = {
|
||||
0: [],
|
||||
1: [[420, 720], [780, 990]],
|
||||
2: [[420, 720], [780, 990]],
|
||||
3: [[420, 720], [780, 990]],
|
||||
4: [[420, 720], [780, 990]],
|
||||
5: [[420, 690]],
|
||||
6: []
|
||||
};
|
||||
var DAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
|
||||
|
||||
function fmt(min) {
|
||||
var h = Math.floor(min / 60), m = min % 60;
|
||||
return h + ':' + (m < 10 ? '0' : '') + m;
|
||||
}
|
||||
|
||||
function easter(y) {
|
||||
var a = y % 19, b = Math.floor(y / 100), c = y % 100, dd = Math.floor(b / 4), e = b % 4;
|
||||
var f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3);
|
||||
var h = (19 * a + b - dd - g + 15) % 30, i = Math.floor(c / 4), k = c % 4;
|
||||
var l = (32 + 2 * e + 2 * i - h - k) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
var month = Math.floor((h + l - 7 * m + 114) / 31), day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return Date.UTC(y, month - 1, day);
|
||||
}
|
||||
|
||||
// Gesetzliche Feiertage in Bayern (Gemeinde mit überwiegend katholischer Bevölkerung)
|
||||
var holidayCache = {};
|
||||
function holidays(y) {
|
||||
if (holidayCache[y]) return holidayCache[y];
|
||||
var DAY = 864e5, e = easter(y), map = {};
|
||||
var add = function (t, name) { var dt = new Date(t); map[dt.getUTCMonth() + 1 + '-' + dt.getUTCDate()] = name; };
|
||||
add(Date.UTC(y, 0, 1), 'Neujahr');
|
||||
add(Date.UTC(y, 0, 6), 'Heilige Drei Könige');
|
||||
add(e - 2 * DAY, 'Karfreitag');
|
||||
add(e + 1 * DAY, 'Ostermontag');
|
||||
add(Date.UTC(y, 4, 1), 'Tag der Arbeit');
|
||||
add(e + 39 * DAY, 'Christi Himmelfahrt');
|
||||
add(e + 50 * DAY, 'Pfingstmontag');
|
||||
add(e + 60 * DAY, 'Fronleichnam');
|
||||
add(Date.UTC(y, 7, 15), 'Mariä Himmelfahrt');
|
||||
add(Date.UTC(y, 9, 3), 'Tag der Deutschen Einheit');
|
||||
add(Date.UTC(y, 10, 1), 'Allerheiligen');
|
||||
add(Date.UTC(y, 11, 25), '1. Weihnachtstag');
|
||||
add(Date.UTC(y, 11, 26), '2. Weihnachtstag');
|
||||
return (holidayCache[y] = map);
|
||||
}
|
||||
function holidayName(y, m, day) { return holidays(y)[m + '-' + day] || null; }
|
||||
|
||||
function berlinNow() {
|
||||
var now = new Date();
|
||||
try {
|
||||
var parts = new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin', year: 'numeric', month: 'numeric', day: 'numeric',
|
||||
hour: 'numeric', minute: 'numeric', hourCycle: 'h23'
|
||||
}).formatToParts(now);
|
||||
var get = function (t) { for (var i = 0; i < parts.length; i++) if (parts[i].type === t) return parseInt(parts[i].value, 10); return 0; };
|
||||
var y = get('year'), mo = get('month'), da = get('day');
|
||||
return { y: y, m: mo, d: da, mins: get('hour') * 60 + get('minute'), wd: new Date(Date.UTC(y, mo - 1, da)).getUTCDay() };
|
||||
} catch (e) {
|
||||
return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate(), mins: now.getHours() * 60 + now.getMinutes(), wd: now.getDay() };
|
||||
}
|
||||
}
|
||||
|
||||
function computeStatus() {
|
||||
var n = berlinNow();
|
||||
var hol = holidayName(n.y, n.m, n.d);
|
||||
var slots = hol ? [] : SCHEDULE[n.wd];
|
||||
var i;
|
||||
for (i = 0; i < slots.length; i++) {
|
||||
if (n.mins >= slots[i][0] && n.mins < slots[i][1]) {
|
||||
return { state: 'open', text: 'Jetzt geöffnet · bis ' + fmt(slots[i][1]) + ' Uhr', wd: n.wd };
|
||||
}
|
||||
}
|
||||
if (slots.length > 1 && n.mins >= slots[0][1] && n.mins < slots[1][0]) {
|
||||
return { state: 'break', text: 'Mittagspause · ab ' + fmt(slots[1][0]) + ' Uhr wieder erreichbar', wd: n.wd };
|
||||
}
|
||||
if (slots.length && n.mins < slots[0][0]) {
|
||||
return { state: 'closed', text: 'Geschlossen · öffnet heute um ' + fmt(slots[0][0]) + ' Uhr', wd: n.wd };
|
||||
}
|
||||
var base = Date.UTC(n.y, n.m - 1, n.d);
|
||||
for (i = 1; i <= 14; i++) {
|
||||
var dt = new Date(base + i * 864e5);
|
||||
var wd = dt.getUTCDay();
|
||||
var s = SCHEDULE[wd];
|
||||
if (!s.length || holidayName(dt.getUTCFullYear(), dt.getUTCMonth() + 1, dt.getUTCDate())) continue;
|
||||
var when = i === 1 ? 'morgen' : (i < 7 ? 'am ' + DAYS[wd] : 'am ' + dt.getUTCDate() + '.' + (dt.getUTCMonth() + 1) + '.');
|
||||
var prefix = hol ? 'Heute Feiertag (' + hol + ')' : 'Geschlossen';
|
||||
return { state: 'closed', text: prefix + ' · öffnet ' + when + ' um ' + fmt(s[0][0]) + ' Uhr', wd: n.wd };
|
||||
}
|
||||
return { state: 'closed', text: 'Geschlossen', wd: n.wd };
|
||||
}
|
||||
|
||||
var statusEls = d.querySelectorAll('[data-status]');
|
||||
var dayRows = d.querySelectorAll('[data-days]');
|
||||
|
||||
function renderStatus() {
|
||||
var st = computeStatus();
|
||||
Array.prototype.forEach.call(statusEls, function (el) {
|
||||
el.classList.remove('is-open', 'is-break', 'is-closed');
|
||||
el.classList.add('is-' + st.state);
|
||||
var t = el.querySelector('.status__text');
|
||||
if (t) t.textContent = st.text;
|
||||
});
|
||||
Array.prototype.forEach.call(dayRows, function (row) {
|
||||
var days = row.getAttribute('data-days').split(',').map(Number);
|
||||
row.classList.toggle('is-today', days.indexOf(st.wd) > -1);
|
||||
});
|
||||
}
|
||||
renderStatus();
|
||||
w.setInterval(renderStatus, 60000);
|
||||
|
||||
/* ---------- Kontaktformular ---------- */
|
||||
var form = d.querySelector('[data-contact-form]');
|
||||
if (!form) return;
|
||||
|
||||
var statusBox = form.querySelector('[data-form-status]');
|
||||
var ts = form.querySelector('[data-ts]');
|
||||
if (ts) ts.value = String(Date.now());
|
||||
|
||||
var RECIPIENT = 'kontakt@olzinger.de';
|
||||
var fields = {
|
||||
name: form.querySelector('[name="name"]'),
|
||||
email: form.querySelector('[name="email"]'),
|
||||
message: form.querySelector('[name="message"]'),
|
||||
privacy: form.querySelector('[name="privacy"]')
|
||||
};
|
||||
|
||||
Object.keys(fields).forEach(function (k) {
|
||||
var el = fields[k];
|
||||
if (!el) return;
|
||||
el.setAttribute('aria-describedby', 'e-' + k);
|
||||
el.addEventListener(el.type === 'checkbox' ? 'change' : 'input', function () {
|
||||
if (el.closest('.field').classList.contains('is-invalid')) validateField(k);
|
||||
});
|
||||
});
|
||||
|
||||
function validateField(k) {
|
||||
var el = fields[k], ok = true;
|
||||
if (k === 'email') ok = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(el.value.trim());
|
||||
else if (k === 'privacy') ok = el.checked;
|
||||
else if (k === 'message') ok = el.value.trim().length >= 5;
|
||||
else ok = el.value.trim().length > 0;
|
||||
el.closest('.field').classList.toggle('is-invalid', !ok);
|
||||
el.setAttribute('aria-invalid', ok ? 'false' : 'true');
|
||||
return ok;
|
||||
}
|
||||
|
||||
function setStatus(type, html) {
|
||||
statusBox.className = 'form__status' + (type ? ' is-' + type : '');
|
||||
statusBox.innerHTML = html || '';
|
||||
}
|
||||
|
||||
function selectTopic(topic) {
|
||||
if (!topic) return;
|
||||
var radios = form.querySelectorAll('[name="topic"]');
|
||||
Array.prototype.forEach.call(radios, function (r) { r.checked = (r.value === topic); });
|
||||
}
|
||||
|
||||
w.addEventListener('olz:topic', function (e) { selectTopic(e.detail); });
|
||||
w.addEventListener('olz:prefill', function (e) {
|
||||
var det = e.detail || {};
|
||||
selectTopic(det.topic);
|
||||
if (det.message && fields.message) {
|
||||
fields.message.value = det.message;
|
||||
fields.message.closest('.field').classList.remove('is-invalid');
|
||||
}
|
||||
});
|
||||
|
||||
function buildMail(data) {
|
||||
var topic = data.get('topic') || 'Allgemeine Anfrage';
|
||||
var lines = [
|
||||
'Anliegen: ' + topic,
|
||||
'Name: ' + (data.get('name') || ''),
|
||||
'E-Mail: ' + (data.get('email') || ''),
|
||||
'Telefon: ' + (data.get('phone') || '–'),
|
||||
'PLZ / Ort: ' + (data.get('place') || '–'),
|
||||
'',
|
||||
String(data.get('message') || '')
|
||||
];
|
||||
return 'mailto:' + RECIPIENT +
|
||||
'?subject=' + encodeURIComponent('Anfrage über olzinger.de – ' + topic) +
|
||||
'&body=' + encodeURIComponent(lines.join('\n'));
|
||||
}
|
||||
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var valid = ['name', 'email', 'message', 'privacy'].map(validateField);
|
||||
if (valid.indexOf(false) > -1) {
|
||||
var firstBad = form.querySelector('.is-invalid input, .is-invalid textarea');
|
||||
if (firstBad) firstBad.focus();
|
||||
setStatus('error', 'Bitte prüfen Sie die markierten Felder.');
|
||||
return;
|
||||
}
|
||||
var data = new FormData(form);
|
||||
form.classList.add('is-busy');
|
||||
setStatus('', '');
|
||||
|
||||
var done = function (ok, serverValidation) {
|
||||
form.classList.remove('is-busy');
|
||||
if (ok) {
|
||||
form.reset();
|
||||
if (ts) ts.value = String(Date.now());
|
||||
setStatus('success', '<strong>Vielen Dank!</strong> Ihre Anfrage ist bei uns angekommen. Wir melden uns schnellstmöglich bei Ihnen.');
|
||||
} else if (serverValidation) {
|
||||
setStatus('error', 'Bitte prüfen Sie Ihre Angaben – einige Felder sind unvollständig.');
|
||||
} else {
|
||||
w.location.href = buildMail(data);
|
||||
setStatus('info', '<strong>Fast geschafft:</strong> Ihr E-Mail-Programm öffnet sich mit Ihrer vorbereiteten Anfrage – bitte dort nur noch auf „Senden“ klicken. Alternativ erreichen Sie uns unter <a href="tel:+4987172617">0871 72617</a>.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!/^https?:$/.test(w.location.protocol) || !w.fetch) { done(false); return; }
|
||||
|
||||
w.fetch(form.getAttribute('action'), { method: 'POST', body: data, headers: { Accept: 'application/json' } })
|
||||
.then(function (res) {
|
||||
return res.text().then(function (txt) {
|
||||
var json = null;
|
||||
try { json = JSON.parse(txt); } catch (err) { json = null; }
|
||||
if (res.status === 422 && json) return done(false, true);
|
||||
done(!!(res.ok && json && json.ok === true));
|
||||
});
|
||||
})
|
||||
.catch(function () { done(false); });
|
||||
});
|
||||
})(window, document);
|
||||
@@ -0,0 +1,232 @@
|
||||
/* ==========================================================================
|
||||
Olzinger – Heizungs-Finder
|
||||
Vier Fragen → erste, unverbindliche Empfehlung (Wärmepumpe / Pellets / Hybrid).
|
||||
Das Ergebnis kann direkt ins Kontaktformular übernommen werden.
|
||||
========================================================================== */
|
||||
(function (w, d) {
|
||||
'use strict';
|
||||
|
||||
var app = d.querySelector('[data-finder]');
|
||||
if (!app) return;
|
||||
|
||||
var form = app.querySelector('[data-finder-form]');
|
||||
var steps = Array.prototype.slice.call(form.querySelectorAll('.finder__step'));
|
||||
var result = form.querySelector('.finder__result');
|
||||
var countEl = app.querySelector('[data-finder-count]');
|
||||
var barEl = app.querySelector('[data-finder-bar]');
|
||||
var backBtn = app.querySelector('[data-finder-back]');
|
||||
var nextBtn = app.querySelector('[data-finder-next]');
|
||||
var reduce = w.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var total = steps.length;
|
||||
var index = 0;
|
||||
var answers = {};
|
||||
var pointerSelect = false;
|
||||
var advanceTimer = 0;
|
||||
|
||||
var LABELS = {
|
||||
gebaeude: { neubau: 'Neubau', gedaemmt: 'Bestand, gut gedämmt', unsaniert: 'Bestand, wenig gedämmt' },
|
||||
verteilung: { fbh: 'Fußbodenheizung', hk: 'Heizkörper', mix: 'Fußbodenheizung & Heizkörper' },
|
||||
lager: { ja: 'vorhanden', nein: 'nicht vorhanden' },
|
||||
dach: { ja: 'ja', nein: 'nein / unbekannt' }
|
||||
};
|
||||
|
||||
var SYSTEMS = {
|
||||
wp: { name: 'Wärmepumpe', title: 'Luft-Wasser-Wärmepumpe', topic: 'Wärmepumpe & Regenerativ' },
|
||||
pellet: { name: 'Pelletheizung', title: 'Pelletheizung', topic: 'Wärmepumpe & Regenerativ' },
|
||||
hybrid: { name: 'Hybridheizung', title: 'Hybridheizung', topic: 'Heizung' }
|
||||
};
|
||||
|
||||
var TEXTS = {
|
||||
wp: {
|
||||
neubau: 'Für Ihren Neubau ist die Wärmepumpe die naheliegende Wahl: Sie heizt mit Umweltwärme, arbeitet mit niedrigen Vorlauftemperaturen besonders effizient und harmoniert perfekt mit einer Fußbodenheizung.',
|
||||
gedaemmt: 'Ihr Haus bringt gute Voraussetzungen mit: Dank Dämmung reichen niedrige Vorlauftemperaturen – genau dort arbeitet eine Wärmepumpe besonders effizient. Ob einzelne Heizkörper getauscht werden sollten, prüfen wir vor Ort.',
|
||||
unsaniert: 'Mit Flächenheizung kann eine Wärmepumpe auch im älteren Haus gut funktionieren. Eine Heizlastberechnung zeigt, ob vorher kleinere Maßnahmen sinnvoll sind.'
|
||||
},
|
||||
pellet: 'Holzpellets heizen nahezu CO<sub>2</sub>-neutral und liefern auch die hohen Vorlauftemperaturen, die klassische Heizkörper in älteren Häusern brauchen. Den nötigen Lagerraum haben Sie bereits.',
|
||||
hybrid: 'Wärmepumpe und Brennwertkessel im Team: Die Wärmepumpe übernimmt den Großteil des Jahres, der Kessel springt nur an sehr kalten Tagen ein. Oft ist auch eine Hochtemperatur-Wärmepumpe möglich – das klären wir mit einer Heizlastberechnung.'
|
||||
};
|
||||
|
||||
/* Bewertung – bewusst einfach und nachvollziehbar gehalten */
|
||||
function evaluate(a) {
|
||||
var s = { wp: 0, pellet: 0, hybrid: 0 };
|
||||
var G = { neubau: [7, 1, 0], gedaemmt: [6, 2, 2], unsaniert: [1, 4, 4] }[a.gebaeude] || [0, 0, 0];
|
||||
s.wp += G[0]; s.pellet += G[1]; s.hybrid += G[2];
|
||||
|
||||
if (a.verteilung === 'fbh') { s.wp += 4 + (a.gebaeude === 'unsaniert' ? 3 : 0); s.pellet += 1; s.hybrid += 1; }
|
||||
if (a.verteilung === 'mix') { s.wp += 2; s.pellet += 2; s.hybrid += 2; }
|
||||
if (a.verteilung === 'hk') { s.wp += (a.gebaeude === 'unsaniert' ? 0 : 3); s.pellet += 3; s.hybrid += 3; }
|
||||
|
||||
if (a.lager === 'ja') s.pellet += 3; else { s.pellet -= 6; s.hybrid += 1; }
|
||||
if (a.dach === 'ja') { s.wp += 1; s.pellet += 1; s.hybrid += 1; }
|
||||
|
||||
var max = { wp: 12, pellet: 11, hybrid: 9 };
|
||||
var pct = {};
|
||||
Object.keys(s).forEach(function (k) { pct[k] = Math.round(Math.max(0, Math.min(1, s[k] / max[k])) * 100); });
|
||||
var priority = ['wp', 'hybrid', 'pellet'];
|
||||
var order = Object.keys(s).sort(function (x, y) {
|
||||
return (pct[y] - pct[x]) || (priority.indexOf(x) - priority.indexOf(y));
|
||||
});
|
||||
return { pct: pct, order: order, top: order[0] };
|
||||
}
|
||||
|
||||
function addons(a, top) {
|
||||
var list = [];
|
||||
if (a.dach === 'ja') list.push(['i-sun', 'Solarthermie für Warmwasser']);
|
||||
if (a.gebaeude === 'neubau') list.push(['i-wind', 'Wohnraumlüftung mit Wärmerückgewinnung']);
|
||||
if (top === 'wp' && a.verteilung !== 'fbh') list.push(['i-gauge', 'Heizkörper-Check & hydraulischer Abgleich']);
|
||||
if (top === 'pellet') list.push(['i-tank', 'Pufferspeicher für mehr Effizienz']);
|
||||
if (top === 'hybrid') list.push(['i-gauge', 'Heizlastberechnung vor Ort']);
|
||||
list.push(['i-check', 'Beratung zu Fördermöglichkeiten']);
|
||||
return list;
|
||||
}
|
||||
|
||||
function icon(id) {
|
||||
return '<svg class="icon" aria-hidden="true"><use href="#' + id + '"/></svg>';
|
||||
}
|
||||
|
||||
function updateChrome() {
|
||||
var isResult = index >= total;
|
||||
countEl.textContent = isResult ? 'Ihr Ergebnis' : 'Frage ' + (index + 1) + ' von ' + total;
|
||||
barEl.style.transform = 'scaleX(' + (isResult ? 1 : (index + 1) / total) + ')';
|
||||
backBtn.disabled = index === 0;
|
||||
if (!isResult) {
|
||||
var name = steps[index].querySelector('input').name;
|
||||
nextBtn.disabled = !answers[name];
|
||||
nextBtn.textContent = '';
|
||||
nextBtn.insertAdjacentHTML('beforeend', (index === total - 1 ? 'Ergebnis zeigen' : 'Weiter') + icon('i-arrow'));
|
||||
}
|
||||
app.classList.toggle('is-done', isResult);
|
||||
}
|
||||
|
||||
function panel(i) { return i >= total ? result : steps[i]; }
|
||||
|
||||
function go(to, focus) {
|
||||
if (to === index) return;
|
||||
var from = panel(index);
|
||||
var target = panel(to);
|
||||
var dir = to > index ? 1 : -1;
|
||||
index = to;
|
||||
if (to >= total) renderResult();
|
||||
updateChrome();
|
||||
|
||||
var reveal = function () {
|
||||
from.hidden = true;
|
||||
from.classList.remove('is-active');
|
||||
target.hidden = false;
|
||||
target.classList.add('is-active');
|
||||
if (to >= total) animateBars();
|
||||
if (focus) {
|
||||
var f = to >= total ? result : target.querySelector('input:checked') || target.querySelector('input');
|
||||
if (f) f.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (w.gsap && !reduce) {
|
||||
w.gsap.to(from, {
|
||||
x: -28 * dir, opacity: 0, duration: 0.28, ease: 'power2.in',
|
||||
onComplete: function () {
|
||||
w.gsap.set(from, { clearProps: 'transform,opacity' });
|
||||
reveal();
|
||||
w.gsap.fromTo(target, { x: 36 * dir, opacity: 0 }, { x: 0, opacity: 1, duration: 0.7, ease: 'expo.out', clearProps: 'transform,opacity' });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
reveal();
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult() {
|
||||
var r = evaluate(answers);
|
||||
var sys = SYSTEMS[r.top];
|
||||
result.querySelector('[data-result-title]').textContent = sys.title;
|
||||
var text = r.top === 'wp' ? TEXTS.wp[answers.gebaeude] || TEXTS.wp.gedaemmt : TEXTS[r.top];
|
||||
result.querySelector('[data-result-text]').innerHTML = text;
|
||||
|
||||
var bars = result.querySelector('[data-result-bars]');
|
||||
bars.innerHTML = r.order.map(function (k, i) {
|
||||
return '<li class="' + (i === 0 ? 'is-top' : '') + '"><span>' + SYSTEMS[k].name + '</span>' +
|
||||
'<span class="bar" role="img" aria-label="' + SYSTEMS[k].name + ': ' + r.pct[k] + ' Prozent Übereinstimmung"><i data-v="' + (r.pct[k] / 100) + '"></i></span>' +
|
||||
'<b>' + r.pct[k] + ' %</b></li>';
|
||||
}).join('');
|
||||
|
||||
result.querySelector('[data-result-addons]').innerHTML = addons(answers, r.top).map(function (a) {
|
||||
return '<li>' + icon(a[0]) + a[1] + '</li>';
|
||||
}).join('');
|
||||
|
||||
var summary = 'Heizungs-Finder – meine Angaben: Gebäude: ' + LABELS.gebaeude[answers.gebaeude] +
|
||||
' · Wärmeverteilung: ' + LABELS.verteilung[answers.verteilung] +
|
||||
' · Lagerraum: ' + LABELS.lager[answers.lager] +
|
||||
' · Sonnige Dachfläche: ' + LABELS.dach[answers.dach] +
|
||||
'. Erste Empfehlung: ' + sys.title + '. Ich hätte gern eine persönliche Beratung.';
|
||||
var apply = result.querySelector('[data-finder-apply]');
|
||||
apply.setAttribute('data-topic', sys.topic);
|
||||
apply.setAttribute('data-message', summary);
|
||||
}
|
||||
|
||||
function animateBars() {
|
||||
var bars = result.querySelectorAll('.bar i');
|
||||
// Zwei Frames warten, damit die Transition sicher greift
|
||||
w.requestAnimationFrame(function () {
|
||||
w.requestAnimationFrame(function () {
|
||||
Array.prototype.forEach.call(bars, function (b, i) {
|
||||
b.style.transitionDelay = (0.12 * i) + 's';
|
||||
b.style.transform = 'scaleX(' + b.getAttribute('data-v') + ')';
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function restart() {
|
||||
answers = {};
|
||||
Array.prototype.forEach.call(form.querySelectorAll('input[type="radio"]'), function (r) { r.checked = false; });
|
||||
var current = panel(index);
|
||||
current.hidden = true;
|
||||
current.classList.remove('is-active');
|
||||
index = 0;
|
||||
steps[0].hidden = false;
|
||||
steps[0].classList.add('is-active');
|
||||
updateChrome();
|
||||
var first = steps[0].querySelector('input');
|
||||
if (first) first.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
form.addEventListener('pointerdown', function (e) {
|
||||
if (e.target.closest('.opt')) pointerSelect = true;
|
||||
});
|
||||
form.addEventListener('keydown', function () { pointerSelect = false; });
|
||||
|
||||
form.addEventListener('change', function (e) {
|
||||
var input = e.target;
|
||||
if (input.type !== 'radio') return;
|
||||
answers[input.name] = input.value;
|
||||
updateChrome();
|
||||
if (pointerSelect) {
|
||||
w.clearTimeout(advanceTimer);
|
||||
advanceTimer = w.setTimeout(function () { go(index + 1, false); }, 320);
|
||||
}
|
||||
});
|
||||
|
||||
nextBtn.addEventListener('click', function () {
|
||||
if (nextBtn.disabled) return;
|
||||
w.clearTimeout(advanceTimer);
|
||||
go(index + 1, true);
|
||||
});
|
||||
backBtn.addEventListener('click', function () {
|
||||
if (index > 0) { w.clearTimeout(advanceTimer); go(index - 1, true); }
|
||||
});
|
||||
form.addEventListener('submit', function (e) { e.preventDefault(); });
|
||||
|
||||
var restartBtn = result.querySelector('[data-finder-restart]');
|
||||
if (restartBtn) restartBtn.addEventListener('click', restart);
|
||||
|
||||
var applyBtn = result.querySelector('[data-finder-apply]');
|
||||
if (applyBtn) {
|
||||
applyBtn.addEventListener('click', function () {
|
||||
w.dispatchEvent(new CustomEvent('olz:prefill', {
|
||||
detail: { topic: applyBtn.getAttribute('data-topic'), message: applyBtn.getAttribute('data-message') }
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
updateChrome();
|
||||
})(window, document);
|
||||
@@ -0,0 +1,121 @@
|
||||
/* ==========================================================================
|
||||
Olzinger – Energie-Haus
|
||||
Tabs + Hotspots steuern, welches System im Hausschnitt hervorgehoben wird.
|
||||
Solange niemand klickt, wechseln die Komponenten automatisch.
|
||||
========================================================================== */
|
||||
(function (w, d) {
|
||||
'use strict';
|
||||
|
||||
var root = d.querySelector('[data-house]');
|
||||
if (!root) return;
|
||||
|
||||
var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]'));
|
||||
var parts = Array.prototype.slice.call(root.querySelectorAll('.part'));
|
||||
var hotspots = Array.prototype.slice.call(root.querySelectorAll('.hotspot'));
|
||||
var systems = Array.prototype.slice.call(root.querySelectorAll('.sys'));
|
||||
var progress = root.querySelector('[data-house-progress]');
|
||||
var order = tabs.map(function (t) { return t.getAttribute('data-part'); });
|
||||
var reduce = w.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
var DURATION = 6500;
|
||||
var current = null;
|
||||
var autoplay = !reduce;
|
||||
var inView = false;
|
||||
var hovering = false;
|
||||
var elapsed = 0, last = 0, raf = 0;
|
||||
|
||||
hotspots.forEach(function (h) {
|
||||
h.setAttribute('aria-controls', 'part-' + h.getAttribute('data-part'));
|
||||
});
|
||||
|
||||
function activate(id, focusTab) {
|
||||
if (id === current) return;
|
||||
current = id;
|
||||
root.setAttribute('data-active', id);
|
||||
tabs.forEach(function (t) {
|
||||
var on = t.getAttribute('data-part') === id;
|
||||
t.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
t.tabIndex = on ? 0 : -1;
|
||||
if (on && focusTab) t.focus();
|
||||
});
|
||||
parts.forEach(function (p) {
|
||||
var on = p.getAttribute('data-part') === id;
|
||||
p.hidden = !on;
|
||||
p.classList.toggle('is-active', on);
|
||||
});
|
||||
hotspots.forEach(function (h) {
|
||||
var on = h.getAttribute('data-part') === id;
|
||||
h.classList.toggle('is-active', on);
|
||||
h.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
});
|
||||
systems.forEach(function (s) { s.classList.toggle('is-on', s.getAttribute('data-sys') === id); });
|
||||
elapsed = 0;
|
||||
}
|
||||
|
||||
function stopAutoplay() {
|
||||
autoplay = false;
|
||||
if (progress) progress.style.transform = 'scaleX(0)';
|
||||
if (raf) w.cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
|
||||
function tick(now) {
|
||||
raf = 0;
|
||||
if (!autoplay || !inView || hovering) return;
|
||||
var dt = last ? now - last : 16;
|
||||
last = now;
|
||||
elapsed += Math.min(dt, 100);
|
||||
if (progress) progress.style.transform = 'scaleX(' + Math.min(elapsed / DURATION, 1) + ')';
|
||||
if (elapsed >= DURATION) {
|
||||
var next = order[(order.indexOf(current) + 1) % order.length];
|
||||
activate(next, false);
|
||||
}
|
||||
raf = w.requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (!autoplay || !inView || hovering || raf) return;
|
||||
last = 0;
|
||||
raf = w.requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
tabs.forEach(function (t) {
|
||||
t.addEventListener('click', function () { stopAutoplay(); activate(t.getAttribute('data-part'), false); });
|
||||
});
|
||||
hotspots.forEach(function (h) {
|
||||
h.addEventListener('click', function () { stopAutoplay(); activate(h.getAttribute('data-part'), false); });
|
||||
});
|
||||
|
||||
// Tastatursteuerung nach WAI-ARIA Tabs-Pattern
|
||||
var tablist = root.querySelector('[role="tablist"]');
|
||||
if (tablist) {
|
||||
tablist.addEventListener('keydown', function (e) {
|
||||
var i = order.indexOf(current), n = order.length, to = -1;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') to = (i + 1) % n;
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') to = (i - 1 + n) % n;
|
||||
else if (e.key === 'Home') to = 0;
|
||||
else if (e.key === 'End') to = n - 1;
|
||||
if (to < 0) return;
|
||||
e.preventDefault();
|
||||
stopAutoplay();
|
||||
activate(order[to], true);
|
||||
});
|
||||
}
|
||||
|
||||
// Pausieren, solange der Nutzer mit der Maus im Bereich ist
|
||||
root.addEventListener('pointerenter', function (e) {
|
||||
if (e.pointerType === 'mouse') { hovering = true; if (raf) { w.cancelAnimationFrame(raf); raf = 0; } }
|
||||
});
|
||||
root.addEventListener('pointerleave', function (e) {
|
||||
if (e.pointerType === 'mouse') { hovering = false; run(); }
|
||||
});
|
||||
|
||||
if ('IntersectionObserver' in w) {
|
||||
new IntersectionObserver(function (entries) {
|
||||
inView = entries[0].isIntersecting;
|
||||
if (inView) run();
|
||||
}, { threshold: 0.35 }).observe(root);
|
||||
}
|
||||
|
||||
activate(order[0], false);
|
||||
})(window, document);
|
||||
@@ -0,0 +1,504 @@
|
||||
/* ==========================================================================
|
||||
Olzinger – Hauptskript
|
||||
Preloader, Hero-Intro, Smooth-Scroll, Navigation und Scroll-Animationen.
|
||||
========================================================================== */
|
||||
(function (w, d) {
|
||||
'use strict';
|
||||
|
||||
var html = d.documentElement;
|
||||
var $ = function (s, c) { return (c || d).querySelector(s); };
|
||||
var $$ = function (s, c) { return Array.prototype.slice.call((c || d).querySelectorAll(s)); };
|
||||
|
||||
var reduce = w.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var finePointer = w.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
var fallback = !!w.__olzFallback;
|
||||
w.__olzReady = true;
|
||||
|
||||
/* ---------- Dynamische Jahreszahlen ---------- */
|
||||
var nowYear = new Date().getFullYear();
|
||||
$$('[data-current-year]').forEach(function (el) { el.textContent = nowYear; });
|
||||
$$('[data-years]').forEach(function (el) {
|
||||
el.textContent = nowYear - 1985;
|
||||
el.setAttribute('data-count', nowYear - 1985);
|
||||
});
|
||||
|
||||
/* ---------- Wärmebild ---------- */
|
||||
var thermal = null;
|
||||
var canvas = $('[data-thermal]');
|
||||
if (canvas && w.OlzThermal) {
|
||||
thermal = w.OlzThermal.init(canvas, {
|
||||
hero: $('[data-hero]'),
|
||||
probe: $('[data-probe]'),
|
||||
probeVal: $('[data-probe-val]'),
|
||||
legendMarker: $('[data-legend-marker]'),
|
||||
calm: $('.hero__aside'),
|
||||
reduceMotion: reduce
|
||||
});
|
||||
} else {
|
||||
html.classList.add('no-webgl');
|
||||
}
|
||||
|
||||
/* ---------- Mobiles Menü (auch ohne GSAP funktionsfähig) ---------- */
|
||||
var menu = $('[data-menu]');
|
||||
var toggle = $('[data-menu-toggle]');
|
||||
var menuOpen = false;
|
||||
var lenis = null;
|
||||
|
||||
if (menu) { menu.hidden = false; menu.setAttribute('inert', ''); menu.setAttribute('aria-hidden', 'true'); }
|
||||
|
||||
function openMenu() {
|
||||
if (!menu || menuOpen) return;
|
||||
menuOpen = true;
|
||||
menu.classList.add('is-open');
|
||||
menu.removeAttribute('inert');
|
||||
menu.setAttribute('aria-hidden', 'false');
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
toggle.querySelector('.sr-only').textContent = 'Menü schließen';
|
||||
html.classList.add('menu-open');
|
||||
if (lenis) lenis.stop();
|
||||
if (w.gsap && !reduce) {
|
||||
w.gsap.fromTo($$('.menu__links a, .menu__foot > *'), { y: 40, opacity: 0 },
|
||||
{ y: 0, opacity: 1, duration: 0.9, ease: 'expo.out', stagger: 0.045, delay: 0.25 });
|
||||
}
|
||||
}
|
||||
function closeMenu() {
|
||||
if (!menu || !menuOpen) return;
|
||||
menuOpen = false;
|
||||
menu.classList.remove('is-open');
|
||||
menu.setAttribute('inert', '');
|
||||
menu.setAttribute('aria-hidden', 'true');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
toggle.querySelector('.sr-only').textContent = 'Menü öffnen';
|
||||
html.classList.remove('menu-open');
|
||||
if (lenis) lenis.start();
|
||||
}
|
||||
if (toggle) toggle.addEventListener('click', function () { if (menuOpen) closeMenu(); else openMenu(); });
|
||||
d.addEventListener('keydown', function (e) { if (e.key === 'Escape' && menuOpen) { closeMenu(); toggle.focus(); } });
|
||||
|
||||
/* ---------- Ohne GSAP: alles statisch sichtbar lassen ---------- */
|
||||
if (!w.gsap || !w.ScrollTrigger) {
|
||||
html.classList.remove('has-loader', 'js');
|
||||
html.classList.add('no-js');
|
||||
if (thermal) thermal.heatUp();
|
||||
d.addEventListener('click', function (e) {
|
||||
var a = e.target.closest('a[href^="#"]');
|
||||
if (a && a.dataset.topic) w.dispatchEvent(new CustomEvent('olz:topic', { detail: a.dataset.topic }));
|
||||
if (a) closeMenu();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var gsap = w.gsap, ScrollTrigger = w.ScrollTrigger;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
if (w.SplitText) gsap.registerPlugin(w.SplitText);
|
||||
gsap.config({ nullTargetWarn: false });
|
||||
|
||||
/* ---------- Smooth Scroll ---------- */
|
||||
if (!reduce && typeof w.Lenis === 'function') {
|
||||
lenis = new w.Lenis({ lerp: 0.105, smoothWheel: true, wheelMultiplier: 1, touchMultiplier: 1.4 });
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
gsap.ticker.add(function (t) { lenis.raf(t * 1000); });
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
if (html.classList.contains('has-loader')) lenis.stop();
|
||||
}
|
||||
w.olzLenis = lenis;
|
||||
|
||||
function navHeight() {
|
||||
var bar = $('.nav__bar');
|
||||
return bar ? bar.getBoundingClientRect().height + 12 : 76;
|
||||
}
|
||||
|
||||
// Natürliche Position berechnen (auch für „sticky“-Elemente)
|
||||
function targetY(el) {
|
||||
if (el.id === 'top') return 0;
|
||||
var stack = el.closest('[data-stack]');
|
||||
if (stack && el.classList.contains('svc')) {
|
||||
var cards = $$('.svc', stack);
|
||||
var gap = parseFloat(getComputedStyle(stack).rowGap) || 0;
|
||||
var y = stack.getBoundingClientRect().top + w.scrollY;
|
||||
for (var i = 0; i < cards.length && cards[i] !== el; i++) y += cards[i].offsetHeight + gap;
|
||||
var stickyTop = parseFloat(getComputedStyle(el).top);
|
||||
return y - (isNaN(stickyTop) ? navHeight() : stickyTop);
|
||||
}
|
||||
return el.getBoundingClientRect().top + w.scrollY;
|
||||
}
|
||||
|
||||
function scrollToEl(el) {
|
||||
var y = targetY(el);
|
||||
if (lenis) lenis.scrollTo(y, { duration: 1.4, easing: function (t) { return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); } });
|
||||
else w.scrollTo({ top: y, behavior: reduce ? 'auto' : 'smooth' });
|
||||
}
|
||||
|
||||
d.addEventListener('click', function (e) {
|
||||
var a = e.target.closest('a[href^="#"]');
|
||||
if (!a) return;
|
||||
var id = a.getAttribute('href');
|
||||
if (id.length < 2) return;
|
||||
var target = d.getElementById(id.slice(1));
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
if (a.dataset.topic) w.dispatchEvent(new CustomEvent('olz:topic', { detail: a.dataset.topic }));
|
||||
var wasOpen = menuOpen;
|
||||
closeMenu();
|
||||
setTimeout(function () { scrollToEl(target); }, wasOpen ? 380 : 0);
|
||||
if (history.replaceState) history.replaceState(null, '', id === '#top' ? location.pathname : id);
|
||||
});
|
||||
|
||||
/* ---------- Seitenwechsel mit Vorhang ---------- */
|
||||
var curtain = d.createElement('div');
|
||||
curtain.className = 'curtain';
|
||||
curtain.setAttribute('aria-hidden', 'true');
|
||||
d.body.appendChild(curtain);
|
||||
d.addEventListener('click', function (e) {
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var a = e.target.closest('a[href]');
|
||||
if (!a || a.target === '_blank' || a.hasAttribute('download')) return;
|
||||
var url;
|
||||
try { url = new URL(a.getAttribute('href'), location.href); } catch (err) { return; }
|
||||
if (!/^https?:$/.test(url.protocol) || url.origin !== location.origin) return;
|
||||
if (url.pathname === location.pathname) return;
|
||||
e.preventDefault();
|
||||
closeMenu();
|
||||
html.classList.add('is-leaving');
|
||||
w.setTimeout(function () { location.href = url.href; }, reduce ? 0 : 560);
|
||||
});
|
||||
w.addEventListener('pageshow', function () { html.classList.remove('is-leaving'); });
|
||||
|
||||
/* ---------- Preloader & Hero-Intro ---------- */
|
||||
function heroIn() {
|
||||
var tl = gsap.timeline({ defaults: { ease: 'expo.out' } });
|
||||
tl.fromTo('.hero__word', { y: 0, yPercent: 112 }, { yPercent: 0, duration: 1.5, stagger: 0.1 }, 0)
|
||||
.fromTo('.hero__dot', { scale: 0 }, { scale: 1, duration: 1.1, ease: 'back.out(3)', stagger: 0.1 }, 0.55)
|
||||
.fromTo('.hero__tag', { opacity: 0, x: -12 }, { opacity: 1, x: 0, duration: 1.2, stagger: 0.1 }, 0.7)
|
||||
.fromTo('[data-hero-fade]', { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 1.3, stagger: 0.12 }, 0.35)
|
||||
.fromTo('.nav__bar', { opacity: 0, y: -16 }, { opacity: 1, y: 0, duration: 1.2 }, 0.2)
|
||||
.add(function () { if (thermal) thermal.measure(); });
|
||||
return tl;
|
||||
}
|
||||
|
||||
function intro() {
|
||||
if (fallback) { gsap.set('.nav__bar', { opacity: 1 }); if (thermal) thermal.heatUp(); return; }
|
||||
var loader = $('.loader');
|
||||
var useLoader = html.classList.contains('has-loader') && loader && !reduce;
|
||||
|
||||
if (reduce) {
|
||||
html.classList.remove('has-loader');
|
||||
gsap.set('.hero__word', { yPercent: 0, y: 0 });
|
||||
gsap.set('.hero__dot', { scale: 1 });
|
||||
gsap.set('.hero__tag, [data-hero-fade], .nav__bar', { opacity: 1 });
|
||||
if (thermal) thermal.heatUp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!useLoader) {
|
||||
html.classList.remove('has-loader');
|
||||
if (loader) loader.remove();
|
||||
if (thermal) thermal.heatUp();
|
||||
heroIn();
|
||||
return;
|
||||
}
|
||||
|
||||
var arc = $('.loader__arc', loader);
|
||||
var yearEl = $('[data-loader-year]', loader);
|
||||
var len = arc.getTotalLength();
|
||||
var counter = { v: 1985 };
|
||||
gsap.set(arc, { strokeDasharray: len, strokeDashoffset: len });
|
||||
|
||||
var tl = gsap.timeline();
|
||||
tl.from('.loader__center', { opacity: 0, scale: 0.92, duration: 0.6, ease: 'power2.out' }, 0)
|
||||
.from('.loader__caption', { opacity: 0, y: 10, duration: 0.6, stagger: 0.08 }, 0.1)
|
||||
.to(arc, { strokeDashoffset: 0, duration: 1.35, ease: 'power2.inOut' }, 0.15)
|
||||
.to(counter, {
|
||||
v: nowYear, duration: 1.35, ease: 'power2.inOut',
|
||||
onUpdate: function () { yearEl.textContent = Math.round(counter.v); }
|
||||
}, 0.15)
|
||||
.to('.loader__center, .loader__caption', { opacity: 0, y: -20, duration: 0.5, ease: 'power2.in', stagger: 0.03 }, 1.65)
|
||||
.add(function () { if (thermal) thermal.heatUp(); }, 1.7)
|
||||
.to(loader, { clipPath: 'inset(0% 0% 100% 0%)', duration: 1.05, ease: 'expo.inOut' }, 1.85)
|
||||
.add(heroIn(), 2.25)
|
||||
.add(function () {
|
||||
html.classList.remove('has-loader');
|
||||
loader.remove();
|
||||
if (lenis) lenis.start();
|
||||
ScrollTrigger.refresh();
|
||||
}, 2.95);
|
||||
}
|
||||
|
||||
/* ---------- Navigation ---------- */
|
||||
var nav = $('[data-nav]');
|
||||
ScrollTrigger.create({
|
||||
start: 0, end: 'max',
|
||||
onUpdate: function (self) {
|
||||
var y = self.scroll();
|
||||
nav.classList.toggle('is-scrolled', y > 40);
|
||||
if (menuOpen) return;
|
||||
if (y > 420 && self.direction === 1) nav.classList.add('is-hidden');
|
||||
else if (self.direction === -1 || y <= 420) nav.classList.remove('is-hidden');
|
||||
}
|
||||
});
|
||||
nav.addEventListener('focusin', function () { nav.classList.remove('is-hidden'); });
|
||||
|
||||
var navLinks = $$('[data-nav-link]');
|
||||
navLinks.forEach(function (link) {
|
||||
var sec = d.getElementById(link.getAttribute('href').slice(1));
|
||||
if (!sec) return;
|
||||
ScrollTrigger.create({
|
||||
trigger: sec, start: 'top 45%', end: 'bottom 45%',
|
||||
onToggle: function (self) {
|
||||
if (self.isActive) navLinks.forEach(function (l) { l.classList.toggle('is-active', l === link); });
|
||||
else link.classList.remove('is-active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ---------- Animationen, die Bewegung voraussetzen ---------- */
|
||||
function motion() {
|
||||
// Hero: Inhalte beim Scrollen leicht anheben und ausblenden
|
||||
gsap.to('.hero__inner', {
|
||||
yPercent: -10, opacity: 0.15, ease: 'none',
|
||||
scrollTrigger: { trigger: '.hero', start: 'top top', end: 'bottom top', scrub: true }
|
||||
});
|
||||
|
||||
// Überschriften: Zeilen-Reveal
|
||||
if (w.SplitText) {
|
||||
$$('[data-split]').forEach(function (el) {
|
||||
w.SplitText.create(el, {
|
||||
type: 'lines', mask: 'lines', linesClass: 'split-line', autoSplit: true,
|
||||
onSplit: function (self) {
|
||||
return gsap.from(self.lines, {
|
||||
yPercent: 110, duration: 1.25, ease: 'expo.out', stagger: 0.09,
|
||||
scrollTrigger: { trigger: el, start: 'top 86%', once: true }
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Intro-Text: Wort für Wort „aufheizen“
|
||||
var introText = $('[data-scrub-words]');
|
||||
if (introText) {
|
||||
var split = w.SplitText.create(introText, { type: 'words', wordsClass: 'scrub-word' });
|
||||
gsap.fromTo(split.words, { opacity: 0.13 }, {
|
||||
opacity: 1, ease: 'none', stagger: 0.1,
|
||||
scrollTrigger: { trigger: introText, start: 'top 82%', end: 'bottom 50%', scrub: true }
|
||||
});
|
||||
gsap.from('.ichip', {
|
||||
scale: 0, rotate: -25, duration: 0.9, ease: 'back.out(2.4)', stagger: 0.18,
|
||||
scrollTrigger: { trigger: introText, start: 'top 70%', once: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Allgemeine Reveals
|
||||
$$('[data-reveal]').forEach(function (el) {
|
||||
gsap.from(el, {
|
||||
y: 44, opacity: 0, duration: 1.25, ease: 'expo.out',
|
||||
scrollTrigger: { trigger: el, start: 'top 90%', once: true }
|
||||
});
|
||||
});
|
||||
$$('[data-reveal-stagger]').forEach(function (el) {
|
||||
gsap.from(el.children, {
|
||||
y: 40, opacity: 0, duration: 1.15, ease: 'expo.out', stagger: 0.09,
|
||||
scrollTrigger: { trigger: el, start: 'top 90%', once: true }
|
||||
});
|
||||
});
|
||||
|
||||
// Vorlauf/Rücklauf-Bänder
|
||||
$$('.tape').forEach(function (tape) {
|
||||
var track = $('.tape__track', tape);
|
||||
var unit = track.innerHTML;
|
||||
var unitWidth = track.scrollWidth;
|
||||
if (!unitWidth) return;
|
||||
var copies = Math.max(2, Math.ceil((tape.offsetWidth * 1.2) / unitWidth) + 1);
|
||||
track.innerHTML = new Array(copies + 1).join(unit);
|
||||
var dir = parseFloat(tape.getAttribute('data-tape')) || 1;
|
||||
var tween = gsap.fromTo(track,
|
||||
{ x: dir > 0 ? 0 : -unitWidth },
|
||||
{ x: dir > 0 ? -unitWidth : 0, duration: unitWidth / 70, ease: 'none', repeat: -1 });
|
||||
tween.totalTime(tween.duration() * 500);
|
||||
var boost = { v: 1 };
|
||||
ScrollTrigger.create({
|
||||
trigger: tape, start: 'top bottom', end: 'bottom top',
|
||||
onUpdate: function (self) {
|
||||
var v = Math.min(Math.abs(self.getVelocity()) / 250, 7);
|
||||
var sign = self.direction === 1 ? 1 : -1;
|
||||
gsap.to(boost, {
|
||||
v: (1 + v) * sign, duration: 0.25, overwrite: true,
|
||||
onUpdate: function () { tween.timeScale(boost.v); }
|
||||
});
|
||||
gsap.to(boost, {
|
||||
v: sign, duration: 1.2, delay: 0.25, ease: 'power2.out',
|
||||
onUpdate: function () { tween.timeScale(boost.v); }
|
||||
});
|
||||
}
|
||||
});
|
||||
gsap.fromTo(tape, { xPercent: dir * -4 }, {
|
||||
xPercent: dir * 4, ease: 'none',
|
||||
scrollTrigger: { trigger: '.tapes', start: 'top bottom', end: 'bottom top', scrub: true }
|
||||
});
|
||||
});
|
||||
|
||||
// Leistungskarten: gestapelt, die hinteren treten zurück
|
||||
var cards = $$('.svc');
|
||||
var mm = gsap.matchMedia();
|
||||
mm.add('(min-width: 900px) and (min-height: 660px)', function () {
|
||||
cards.forEach(function (card, i) {
|
||||
var next = cards[i + 1];
|
||||
if (!next) return;
|
||||
var inner = $('.svc__inner', card);
|
||||
gsap.to(inner, {
|
||||
scale: 0.9 + i * 0.012, '--shade': 0.55, ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: next, start: 'top 82%',
|
||||
end: function () { return 'top ' + (parseFloat(getComputedStyle(next).top) || 100) + 'px'; },
|
||||
scrub: true, invalidateOnRefresh: true
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Illustrationen: leichter Parallax
|
||||
$$('.svc__art .art').forEach(function (art) {
|
||||
gsap.fromTo(art, { yPercent: 4 }, {
|
||||
yPercent: -4, ease: 'none',
|
||||
scrollTrigger: { trigger: art.closest('.svc'), start: 'top bottom', end: 'bottom top', scrub: true }
|
||||
});
|
||||
});
|
||||
|
||||
// Ablauf: Rohr füllt sich
|
||||
var steps = $('[data-steps]');
|
||||
if (steps) {
|
||||
var fill = $('[data-steps-fill]', steps);
|
||||
var pipe = $('.steps__pipe', steps);
|
||||
var items = $$('[data-step-item]', steps);
|
||||
var mmSteps = gsap.matchMedia();
|
||||
mmSteps.add({ desktop: '(min-width: 901px)', mobile: '(max-width: 900px)' }, function (ctx) {
|
||||
var desktop = ctx.conditions.desktop;
|
||||
if (!desktop) {
|
||||
var nodes = items.map(function (it) { return $('.step__node', it); });
|
||||
var placePipe = function () {
|
||||
var top0 = steps.getBoundingClientRect().top;
|
||||
var a = nodes[0].getBoundingClientRect(), b = nodes[nodes.length - 1].getBoundingClientRect();
|
||||
pipe.style.top = (a.top - top0 + a.height / 2) + 'px';
|
||||
pipe.style.height = (b.top - a.top) + 'px';
|
||||
pipe.style.bottom = 'auto';
|
||||
};
|
||||
placePipe();
|
||||
ScrollTrigger.addEventListener('refreshInit', placePipe);
|
||||
}
|
||||
gsap.fromTo(fill, desktop ? { scaleX: 0, scaleY: 1 } : { scaleY: 0, scaleX: 1 }, {
|
||||
scaleX: 1, scaleY: 1, ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: steps, start: 'top 72%', end: desktop ? 'bottom 62%' : 'bottom 68%', scrub: 0.6,
|
||||
onUpdate: function (self) {
|
||||
var p = self.progress, n = items.length;
|
||||
items.forEach(function (it, i) { it.classList.toggle('is-on', p > (i / (n - 1)) * 0.97 + 0.01); });
|
||||
}
|
||||
}
|
||||
});
|
||||
return function () {
|
||||
pipe.style.top = pipe.style.height = pipe.style.bottom = '';
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Footer-Schriftzug „heizt auf“
|
||||
var word = $('[data-footer-word] span');
|
||||
if (word) {
|
||||
gsap.fromTo(word, { '--heat': '0%' }, {
|
||||
'--heat': '100%', ease: 'none',
|
||||
scrollTrigger: { trigger: '.footer', start: 'top 80%', end: 'bottom bottom', scrub: true }
|
||||
});
|
||||
gsap.from(word, {
|
||||
yPercent: 60, opacity: 0, duration: 1.4, ease: 'expo.out',
|
||||
scrollTrigger: { trigger: '[data-footer-word]', start: 'top 95%', once: true }
|
||||
});
|
||||
}
|
||||
|
||||
// Magnetische Buttons
|
||||
if (finePointer) {
|
||||
$$('[data-magnetic]').forEach(function (btn) {
|
||||
var xTo = gsap.quickTo(btn, 'x', { duration: 0.6, ease: 'power3' });
|
||||
var yTo = gsap.quickTo(btn, 'y', { duration: 0.6, ease: 'power3' });
|
||||
btn.addEventListener('pointermove', function (e) {
|
||||
var r = btn.getBoundingClientRect();
|
||||
xTo((e.clientX - r.left - r.width / 2) * 0.22);
|
||||
yTo((e.clientY - r.top - r.height / 2) * 0.32);
|
||||
});
|
||||
btn.addEventListener('pointerleave', function () { xTo(0); yTo(0); });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Funktionen, die immer laufen (auch bei reduzierter Bewegung) ---------- */
|
||||
function essentials() {
|
||||
// Illustrationen nur animieren, wenn sichtbar
|
||||
if ('IntersectionObserver' in w) {
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (en) { en.target.classList.toggle('is-inview', en.isIntersecting); });
|
||||
}, { threshold: 0.15 });
|
||||
$$('.svc').forEach(function (c) { io.observe(c); });
|
||||
} else {
|
||||
$$('.svc').forEach(function (c) { c.classList.add('is-inview'); });
|
||||
}
|
||||
|
||||
// Zähler
|
||||
$$('[data-count]').forEach(function (el) {
|
||||
var target = parseInt(el.getAttribute('data-count') || el.textContent, 10);
|
||||
if (isNaN(target)) return;
|
||||
if (reduce) { el.textContent = target; return; }
|
||||
var from = parseInt(el.getAttribute('data-count-from') || '0', 10);
|
||||
var obj = { v: from };
|
||||
el.textContent = from;
|
||||
gsap.to(obj, {
|
||||
v: target, duration: 2.2, ease: 'power3.out',
|
||||
scrollTrigger: { trigger: el, start: 'top 92%', once: true },
|
||||
onUpdate: function () { el.textContent = Math.round(obj.v); }
|
||||
});
|
||||
});
|
||||
|
||||
// Historie: Zählwerk mit Jahreszahl
|
||||
var hist = $('[data-history]');
|
||||
if (hist) {
|
||||
var strips = $$('[data-odo-digit] .odo__strip', hist);
|
||||
var caption = $('[data-odo-caption]', hist);
|
||||
var entries = $$('.tl', hist);
|
||||
var setYear = function (year, title) {
|
||||
String(year).split('').forEach(function (ch, i) {
|
||||
if (!strips[i]) return;
|
||||
gsap.to(strips[i], { yPercent: -10 * parseInt(ch, 10), duration: reduce ? 0 : 1.1, ease: 'expo.inOut', delay: reduce ? 0 : i * 0.06, overwrite: true });
|
||||
});
|
||||
if (caption) caption.textContent = title;
|
||||
};
|
||||
entries.forEach(function (entry) {
|
||||
var y = entry.getAttribute('data-year');
|
||||
var year = y === 'now' ? nowYear : parseInt(y, 10);
|
||||
var title = ($('.tl__title', entry) || {}).textContent || '';
|
||||
ScrollTrigger.create({
|
||||
trigger: entry, start: 'top 62%', end: 'bottom 62%',
|
||||
onToggle: function (self) {
|
||||
if (!self.isActive) return;
|
||||
entries.forEach(function (e2) { e2.classList.toggle('is-active', e2 === entry); });
|
||||
setYear(year, title);
|
||||
}
|
||||
});
|
||||
});
|
||||
entries[0].classList.add('is-active');
|
||||
setYear(1985, ($('.tl__title', entries[0]) || {}).textContent || '');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Start ---------- */
|
||||
essentials();
|
||||
if (!reduce) motion();
|
||||
intro();
|
||||
|
||||
// Nach dem Laden der Schriften Positionen neu berechnen
|
||||
if (d.fonts && d.fonts.ready) d.fonts.ready.then(function () { ScrollTrigger.refresh(); });
|
||||
w.addEventListener('load', function () { ScrollTrigger.refresh(); });
|
||||
|
||||
// Deep-Link (#kontakt etc.) nach dem Aufbau korrekt anspringen
|
||||
if (location.hash && location.hash.length > 1) {
|
||||
var deep = d.getElementById(location.hash.slice(1));
|
||||
if (deep) w.setTimeout(function () { w.scrollTo(0, targetY(deep)); }, 60);
|
||||
}
|
||||
})(window, document);
|
||||
@@ -0,0 +1,402 @@
|
||||
/* ==========================================================================
|
||||
Olzinger – „Wärmebild“ (WebGL)
|
||||
Ein thermografisches Feld mit Isothermen. Die Maus wirkt als Wärmequelle
|
||||
und hinterlässt eine Wärmespur, die langsam abkühlt und sich ausbreitet.
|
||||
Hinter Fließtext wird die Wärme gedämpft, damit alles lesbar bleibt.
|
||||
========================================================================== */
|
||||
(function (w) {
|
||||
'use strict';
|
||||
|
||||
var MAX_HEAT = 14;
|
||||
var T_MIN = 14; // °C bei Feldwert 0
|
||||
var T_MAX = 28; // °C bei Feldwert 1
|
||||
|
||||
var VERT = 'attribute vec2 aPos; void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }';
|
||||
|
||||
var FRAG = [
|
||||
'#ifdef GL_OES_standard_derivatives',
|
||||
'#extension GL_OES_standard_derivatives : enable',
|
||||
'#endif',
|
||||
'precision highp float;',
|
||||
'#define MAXH ' + MAX_HEAT,
|
||||
'uniform vec2 uRes;',
|
||||
'uniform float uTime;',
|
||||
'uniform float uIntro;',
|
||||
'uniform vec4 uCalm;',
|
||||
'uniform vec4 uHeat[MAXH];',
|
||||
'float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }',
|
||||
'float noise(vec2 p){ vec2 i = floor(p); vec2 f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f);',
|
||||
' 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); }',
|
||||
'float fbm(vec2 p){ float v = 0.0; float a = 0.5; mat2 m = mat2(1.6, 1.2, -1.2, 1.6);',
|
||||
' for (int i = 0; i < 5; i++){ v += a * noise(p); p = m * p; a *= 0.5; } return v; }',
|
||||
'vec3 pal(float t){',
|
||||
' t = clamp(t, 0.0, 1.0) * 8.0;',
|
||||
' vec3 c0 = vec3(0.020, 0.043, 0.071); vec3 c1 = vec3(0.043, 0.106, 0.180); vec3 c2 = vec3(0.078, 0.200, 0.353);',
|
||||
' vec3 c3 = vec3(0.231, 0.141, 0.400); vec3 c4 = vec3(0.541, 0.137, 0.314); vec3 c5 = vec3(0.878, 0.282, 0.165);',
|
||||
' vec3 c6 = vec3(1.000, 0.541, 0.165); vec3 c7 = vec3(1.000, 0.780, 0.420); vec3 c8 = vec3(1.000, 0.910, 0.700);',
|
||||
' if (t < 1.0) return mix(c0, c1, t);',
|
||||
' if (t < 2.0) return mix(c1, c2, t - 1.0);',
|
||||
' if (t < 3.0) return mix(c2, c3, t - 2.0);',
|
||||
' if (t < 4.0) return mix(c3, c4, t - 3.0);',
|
||||
' if (t < 5.0) return mix(c4, c5, t - 4.0);',
|
||||
' if (t < 6.0) return mix(c5, c6, t - 5.0);',
|
||||
' if (t < 7.0) return mix(c6, c7, t - 6.0);',
|
||||
' return mix(c7, c8, t - 7.0);',
|
||||
'}',
|
||||
'float calm(vec2 p){',
|
||||
' vec2 a = smoothstep(uCalm.xy - 0.08, uCalm.xy + 0.02, p);',
|
||||
' vec2 b = 1.0 - smoothstep(uCalm.zw - 0.02, uCalm.zw + 0.08, p);',
|
||||
' return a.x * a.y * b.x * b.y;',
|
||||
'}',
|
||||
'void main(){',
|
||||
' vec2 uv = gl_FragCoord.xy / uRes;',
|
||||
' float asp = uRes.x / uRes.y;',
|
||||
' vec2 p = vec2(uv.x * asp, uv.y);',
|
||||
' float t = uTime;',
|
||||
' vec2 s = p * 1.35;',
|
||||
' vec2 q = vec2(fbm(s + vec2(0.0, t * 0.05)), fbm(s + vec2(5.2, -t * 0.04)));',
|
||||
' vec2 r = vec2(fbm(s + 2.4 * q + vec2(1.7, 9.2) + t * 0.06), fbm(s + 2.4 * q + vec2(8.3, 2.8) - t * 0.05));',
|
||||
' float f = fbm(s + 2.2 * r);',
|
||||
' float base = smoothstep(0.18, 0.86, f) * 0.46;',
|
||||
' vec2 dz = p - vec2(asp * 0.8, 0.76);',
|
||||
' base += 0.36 * exp(-dot(dz, dz) / 0.11) * (0.8 + 0.2 * sin(t * 0.45));',
|
||||
' vec2 dc = p - vec2(asp * 0.16, 0.12);',
|
||||
' base -= 0.12 * exp(-dot(dc, dc) / 0.2);',
|
||||
' base *= mix(0.5, 1.0, smoothstep(0.0, 0.95, uv.x * 0.85 + uv.y * 0.55));',
|
||||
' float heat = 0.0;',
|
||||
' for (int i = 0; i < MAXH; i++){ vec4 h = uHeat[i]; vec2 d = p - h.xy; heat += h.z * exp(-dot(d, d) / max(h.w * h.w, 0.0001)); }',
|
||||
' heat *= 1.0 - 0.62 * calm(p);',
|
||||
' float v = max(base, 0.0) + heat * 0.66;',
|
||||
' v = 1.0 - exp(-v * 1.35);',
|
||||
' v *= uIntro;',
|
||||
' vec3 col = pal(v);',
|
||||
' float k = v * 15.0;',
|
||||
'#ifdef GL_OES_standard_derivatives',
|
||||
' float fw = fwidth(k) * 1.15;',
|
||||
'#else',
|
||||
' float fw = 0.05;',
|
||||
'#endif',
|
||||
' float dist = abs(fract(k + 0.5) - 0.5);',
|
||||
' float line = 1.0 - smoothstep(0.0, fw, dist);',
|
||||
' col += line * (0.03 + 0.2 * v) * vec3(1.0, 0.93, 0.86);',
|
||||
' float g = hash(gl_FragCoord.xy + fract(t * 7.0) * 91.7) - 0.5;',
|
||||
' col += g * 0.032;',
|
||||
' vec2 vc = uv - 0.5;',
|
||||
' col *= 1.0 - dot(vc, vc) * 0.5;',
|
||||
' gl_FragColor = vec4(col, 1.0);',
|
||||
'}'
|
||||
].join('\n');
|
||||
|
||||
/* ---------- CPU-Gegenstück des Feldes (für die Temperaturanzeige) ---------- */
|
||||
function fract(x) { return x - Math.floor(x); }
|
||||
function smoothstep(a, b, x) { var t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); }
|
||||
function hash(x, y) {
|
||||
x = fract(x * 123.34); y = fract(y * 456.21);
|
||||
var dd = x * (x + 45.32) + y * (y + 45.32);
|
||||
x += dd; y += dd;
|
||||
return fract(x * y);
|
||||
}
|
||||
function noise(x, y) {
|
||||
var ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy;
|
||||
var ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy);
|
||||
var a = hash(ix, iy), b = hash(ix + 1, iy), c = hash(ix, iy + 1), d = hash(ix + 1, iy + 1);
|
||||
var ab = a + (b - a) * ux, cd = c + (d - c) * ux;
|
||||
return ab + (cd - ab) * uy;
|
||||
}
|
||||
function fbm(x, y) {
|
||||
var v = 0, a = 0.5;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
v += a * noise(x, y);
|
||||
var nx = 1.6 * x - 1.2 * y, ny = 1.2 * x + 1.6 * y;
|
||||
x = nx; y = ny; a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function compile(gl, type, src) {
|
||||
var sh = gl.createShader(type);
|
||||
gl.shaderSource(sh, src);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
if (w.console) console.warn('[Wärmebild] Shader-Fehler:', gl.getShaderInfoLog(sh));
|
||||
gl.deleteShader(sh);
|
||||
return null;
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
function init(canvas, opts) {
|
||||
opts = opts || {};
|
||||
var root = document.documentElement;
|
||||
var hero = opts.hero || canvas.parentElement;
|
||||
var gl = null;
|
||||
try {
|
||||
gl = canvas.getContext('webgl', { antialias: false, alpha: false, depth: false, stencil: false, premultipliedAlpha: false, powerPreference: 'low-power' }) ||
|
||||
canvas.getContext('experimental-webgl');
|
||||
} catch (e) { gl = null; }
|
||||
if (!gl) { root.classList.add('no-webgl'); return null; }
|
||||
|
||||
gl.getExtension('OES_standard_derivatives');
|
||||
var vs = compile(gl, gl.VERTEX_SHADER, VERT);
|
||||
var fs = compile(gl, gl.FRAGMENT_SHADER, FRAG);
|
||||
if (!vs || !fs) { root.classList.add('no-webgl'); return null; }
|
||||
var prog = gl.createProgram();
|
||||
gl.attachShader(prog, vs);
|
||||
gl.attachShader(prog, fs);
|
||||
gl.linkProgram(prog);
|
||||
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { root.classList.add('no-webgl'); return null; }
|
||||
gl.useProgram(prog);
|
||||
|
||||
var buf = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
||||
var aPos = gl.getAttribLocation(prog, 'aPos');
|
||||
gl.enableVertexAttribArray(aPos);
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
var uRes = gl.getUniformLocation(prog, 'uRes');
|
||||
var uTime = gl.getUniformLocation(prog, 'uTime');
|
||||
var uIntro = gl.getUniformLocation(prog, 'uIntro');
|
||||
var uCalm = gl.getUniformLocation(prog, 'uCalm');
|
||||
var uHeat = gl.getUniformLocation(prog, 'uHeat');
|
||||
|
||||
var reduce = !!opts.reduceMotion;
|
||||
var finePointer = w.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
var probe = opts.probe, probeVal = opts.probeVal, marker = opts.legendMarker;
|
||||
var calmEl = opts.calm || null;
|
||||
|
||||
var heatData = new Float32Array(MAX_HEAT * 4);
|
||||
var calmRect = [-1, -1, -1, -1];
|
||||
var trail = [];
|
||||
var quality = (w.devicePixelRatio || 1) > 1.5 ? 0.62 : 0.85;
|
||||
var width = 0, height = 0, rect = null;
|
||||
var running = false, visible = true, raf = 0;
|
||||
var time = Math.random() * 40, last = 0, frame = 0;
|
||||
var intro = reduce ? 1 : 0, introTarget = reduce ? 1 : 0;
|
||||
var frameTimes = [], tuned = false;
|
||||
|
||||
// Cursor in Feldkoordinaten: x ∈ [0, Seitenverhältnis], y ∈ [0, 1] (y nach oben)
|
||||
var cur = { x: 0, y: 0, tx: 0, ty: 0, inside: false, lastMove: -1e9, heat: 0, clientX: 0, clientY: 0 };
|
||||
var auto = { heat: 0 };
|
||||
var lastEmit = 0;
|
||||
|
||||
function measureCalm() {
|
||||
if (!calmEl || !rect) return;
|
||||
var r = calmEl.getBoundingClientRect();
|
||||
if (!r.width) { calmRect = [-1, -1, -1, -1]; return; }
|
||||
var hgt = rect.height, pad = 18;
|
||||
calmRect = [
|
||||
(r.left - pad - rect.left) / hgt,
|
||||
1 - (r.bottom + pad - rect.top) / hgt,
|
||||
(r.right + pad - rect.left) / hgt,
|
||||
1 - (r.top - pad - rect.top) / hgt
|
||||
];
|
||||
}
|
||||
|
||||
function resize() {
|
||||
rect = canvas.getBoundingClientRect();
|
||||
var dpr = Math.min(w.devicePixelRatio || 1, 2);
|
||||
var nw = Math.max(1, Math.round(rect.width * dpr * quality));
|
||||
var nh = Math.max(1, Math.round(rect.height * dpr * quality));
|
||||
if (nw !== width || nh !== height) {
|
||||
width = canvas.width = nw;
|
||||
height = canvas.height = nh;
|
||||
gl.viewport(0, 0, width, height);
|
||||
}
|
||||
measureCalm();
|
||||
}
|
||||
|
||||
function toField(clientX, clientY) {
|
||||
return { x: (clientX - rect.left) / rect.height, y: 1 - (clientY - rect.top) / rect.height };
|
||||
}
|
||||
|
||||
function emit(x, y, i, r) {
|
||||
trail.unshift({ x: x, y: y, i: i, r: r });
|
||||
if (trail.length > MAX_HEAT - 2) trail.pop();
|
||||
}
|
||||
|
||||
function onMove(e) {
|
||||
if (e.pointerType && e.pointerType !== 'mouse') return;
|
||||
rect = canvas.getBoundingClientRect();
|
||||
var f = toField(e.clientX, e.clientY);
|
||||
cur.tx = f.x; cur.ty = f.y;
|
||||
cur.clientX = e.clientX - rect.left;
|
||||
cur.clientY = e.clientY - rect.top;
|
||||
if (!cur.inside) { cur.x = f.x; cur.y = f.y; }
|
||||
cur.inside = true;
|
||||
cur.lastMove = performance.now();
|
||||
if (probe) probe.classList.add('is-on');
|
||||
}
|
||||
function onLeave() {
|
||||
cur.inside = false;
|
||||
if (probe) probe.classList.remove('is-on');
|
||||
}
|
||||
function onTouch(e) {
|
||||
var t = e.touches && e.touches[0];
|
||||
if (!t) return;
|
||||
rect = canvas.getBoundingClientRect();
|
||||
var f = toField(t.clientX, t.clientY);
|
||||
emit(f.x, f.y, 0.85, 0.09);
|
||||
}
|
||||
|
||||
if (finePointer) {
|
||||
hero.addEventListener('pointermove', onMove, { passive: true });
|
||||
hero.addEventListener('pointerleave', onLeave, { passive: true });
|
||||
}
|
||||
hero.addEventListener('touchstart', onTouch, { passive: true });
|
||||
|
||||
function calmAt(px, py) {
|
||||
var a = smoothstep(calmRect[0] - 0.08, calmRect[0] + 0.02, px) * smoothstep(calmRect[1] - 0.08, calmRect[1] + 0.02, py);
|
||||
var b = (1 - smoothstep(calmRect[2] - 0.02, calmRect[2] + 0.08, px)) * (1 - smoothstep(calmRect[3] - 0.02, calmRect[3] + 0.08, py));
|
||||
return a * b;
|
||||
}
|
||||
|
||||
// Exakt dieselbe Rechnung wie im Shader – nur für einen Punkt
|
||||
function fieldAt(px, py) {
|
||||
var asp = width / height, t = time;
|
||||
var sx = px * 1.35, sy = py * 1.35;
|
||||
var qx = fbm(sx, sy + t * 0.05), qy = fbm(sx + 5.2, sy - t * 0.04);
|
||||
var rx = fbm(sx + 2.4 * qx + 1.7 + t * 0.06, sy + 2.4 * qy + 9.2 + t * 0.06);
|
||||
var ry = fbm(sx + 2.4 * qx + 8.3 - t * 0.05, sy + 2.4 * qy + 2.8 - t * 0.05);
|
||||
var f = fbm(sx + 2.2 * rx, sy + 2.2 * ry);
|
||||
var base = smoothstep(0.18, 0.86, f) * 0.46;
|
||||
var zx = px - asp * 0.8, zy = py - 0.76;
|
||||
base += 0.36 * Math.exp(-(zx * zx + zy * zy) / 0.11) * (0.8 + 0.2 * Math.sin(t * 0.45));
|
||||
var cx = px - asp * 0.16, cy = py - 0.12;
|
||||
base -= 0.12 * Math.exp(-(cx * cx + cy * cy) / 0.2);
|
||||
base *= 0.5 + 0.5 * smoothstep(0, 0.95, (px / asp) * 0.85 + py * 0.55);
|
||||
var heat = 0;
|
||||
for (var i = 0; i < MAX_HEAT; i++) {
|
||||
var o = i * 4, dx = px - heatData[o], dy = py - heatData[o + 1];
|
||||
heat += heatData[o + 2] * Math.exp(-(dx * dx + dy * dy) / Math.max(heatData[o + 3] * heatData[o + 3], 0.0001));
|
||||
}
|
||||
heat *= 1 - 0.62 * calmAt(px, py);
|
||||
var v = Math.max(base, 0) + heat * 0.66;
|
||||
v = 1 - Math.exp(-v * 1.35);
|
||||
return v * intro;
|
||||
}
|
||||
|
||||
function readTemperature() {
|
||||
if (!probeVal || !cur.inside) return;
|
||||
var v = Math.max(0, Math.min(1, fieldAt(cur.tx, cur.ty)));
|
||||
var temp = T_MIN + v * (T_MAX - T_MIN);
|
||||
probeVal.textContent = temp.toFixed(1).replace('.', ',') + ' °C';
|
||||
if (marker) marker.style.left = (v * 100).toFixed(1) + '%';
|
||||
}
|
||||
|
||||
function step(now) {
|
||||
raf = 0;
|
||||
if (!running) return;
|
||||
var dt = last ? Math.min((now - last) / 1000, 0.05) : 0.016;
|
||||
last = now;
|
||||
if (!reduce) time += dt;
|
||||
|
||||
// Auflösung automatisch an die Leistung anpassen
|
||||
if (!tuned) {
|
||||
frameTimes.push(dt);
|
||||
if (frameTimes.length >= 80) {
|
||||
var avg = frameTimes.reduce(function (a, b) { return a + b; }, 0) / frameTimes.length;
|
||||
if (avg > 0.024 && quality > 0.38) { quality *= 0.75; resize(); frameTimes = []; }
|
||||
else tuned = true;
|
||||
}
|
||||
}
|
||||
|
||||
intro += (introTarget - intro) * (1 - Math.exp(-dt * 1.8));
|
||||
var asp = width / height;
|
||||
|
||||
// Maus
|
||||
var k = 1 - Math.exp(-dt * 9);
|
||||
cur.x += (cur.tx - cur.x) * k;
|
||||
cur.y += (cur.ty - cur.y) * k;
|
||||
var idle = (now - cur.lastMove) > 3200;
|
||||
cur.heat += ((cur.inside ? 1 : 0) - cur.heat) * (1 - Math.exp(-dt * 3));
|
||||
if (cur.inside && !idle && now - lastEmit > 55) {
|
||||
emit(cur.x, cur.y, 0.42, 0.075);
|
||||
lastEmit = now;
|
||||
}
|
||||
|
||||
// Autopilot: wandernde Wärmequelle oben rechts, solange niemand interagiert
|
||||
var autoOn = (!cur.inside || idle) && !reduce;
|
||||
auto.heat += ((autoOn ? 0.8 : 0) - auto.heat) * (1 - Math.exp(-dt * 1.2));
|
||||
var ax = asp * (0.7 + 0.16 * Math.sin(time * 0.21)) + 0.05 * Math.sin(time * 0.53);
|
||||
var ay = 0.66 + 0.16 * Math.sin(time * 0.29 + 1.3);
|
||||
if (autoOn && now - lastEmit > 140) {
|
||||
emit(ax, ay, 0.3 * auto.heat, 0.09);
|
||||
lastEmit = now;
|
||||
}
|
||||
|
||||
// Abkühlen & Ausbreiten
|
||||
for (var i = 0; i < trail.length; i++) {
|
||||
trail[i].i *= Math.exp(-dt * 0.85);
|
||||
trail[i].r += dt * 0.032;
|
||||
}
|
||||
|
||||
heatData[0] = cur.x; heatData[1] = cur.y; heatData[2] = cur.heat * 0.8; heatData[3] = 0.095;
|
||||
heatData[4] = ax; heatData[5] = ay; heatData[6] = auto.heat * 0.7; heatData[7] = 0.13;
|
||||
for (var j = 0; j < MAX_HEAT - 2; j++) {
|
||||
var o = (j + 2) * 4, tp = trail[j];
|
||||
if (tp) { heatData[o] = tp.x; heatData[o + 1] = tp.y; heatData[o + 2] = tp.i; heatData[o + 3] = tp.r; }
|
||||
else { heatData[o + 2] = 0; heatData[o + 3] = 0.1; }
|
||||
}
|
||||
|
||||
gl.uniform2f(uRes, width, height);
|
||||
gl.uniform1f(uTime, time);
|
||||
gl.uniform1f(uIntro, intro);
|
||||
gl.uniform4f(uCalm, calmRect[0], calmRect[1], calmRect[2], calmRect[3]);
|
||||
gl.uniform4fv(uHeat, heatData);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
|
||||
frame++;
|
||||
if (frame % 4 === 0) readTemperature();
|
||||
if (probe && cur.inside) {
|
||||
probe.style.transform = 'translate3d(' + cur.clientX.toFixed(1) + 'px,' + cur.clientY.toFixed(1) + 'px,0)';
|
||||
}
|
||||
|
||||
raf = w.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running || !visible || document.hidden) return;
|
||||
running = true;
|
||||
last = 0;
|
||||
raf = w.requestAnimationFrame(step);
|
||||
}
|
||||
function stop() {
|
||||
running = false;
|
||||
if (raf) w.cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
}
|
||||
|
||||
resize();
|
||||
if ('ResizeObserver' in w) {
|
||||
var ro = new ResizeObserver(function () { resize(); });
|
||||
ro.observe(canvas);
|
||||
if (calmEl) ro.observe(calmEl);
|
||||
} else {
|
||||
w.addEventListener('resize', resize);
|
||||
}
|
||||
if (d().fonts && d().fonts.ready) d().fonts.ready.then(function () { resize(); });
|
||||
|
||||
if ('IntersectionObserver' in w) {
|
||||
new IntersectionObserver(function (entries) {
|
||||
visible = entries[0].isIntersecting;
|
||||
if (visible) start(); else stop();
|
||||
}, { threshold: 0 }).observe(hero);
|
||||
}
|
||||
document.addEventListener('visibilitychange', function () { if (document.hidden) stop(); else start(); });
|
||||
canvas.addEventListener('webglcontextlost', function (e) { e.preventDefault(); stop(); root.classList.add('no-webgl'); });
|
||||
|
||||
start();
|
||||
|
||||
return {
|
||||
heatUp: function () { introTarget = 1; },
|
||||
measure: function () { rect = canvas.getBoundingClientRect(); measureCalm(); },
|
||||
start: start,
|
||||
stop: stop
|
||||
};
|
||||
}
|
||||
|
||||
function d() { return document; }
|
||||
|
||||
w.OlzThermal = { init: init };
|
||||
})(window);
|
||||
Reference in new issue
Block a user