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:
Claude committed 2026-10-03 16:49:05 +00:00
commit 82dc9599cc
34 files changed
+5377

No files matched your search

+233
View File
@@ -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&nbsp;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);
+232
View File
@@ -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] + '&nbsp;%</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);
+121
View File
@@ -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);
+504
View File
@@ -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);
+402
View File
@@ -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);