Kostenrechner mit Spanne
Fläche, Ausstattung und Optionen einstellen, die Kosten stehen sofort als ehrliche Spanne mit Aufschlüsselung da und gehen mit einem Klick als Anfrage an den Betrieb.
Sanitär- und Fliesenbetriebe: Fläche, drei Ausstattungsstufen und Optionen wie bodengleiche Dusche; Zeichnung als Grundriss.
Ein Rechner schätzt die Kosten eines Vorhabens, etwa einer Badsanierung oder einer Dachdämmung. Schieberegler für Fläche, Stufen für Ausstattung und Optionen als Häkchen. Das Ergebnis steht live als Spanne von bis, nicht als scheinbar genauer Einzelpreis. Darunter eine Aufschlüsselung nach Posten, der Hinweis „unverbindliche Schätzung“ mit dem, was nicht enthalten ist, und ein Knopf, der die Auswahl an ein Anfrageformular übergibt.
Die erste Frage jedes Kunden ist „Was kostet das ungefähr?“. Eine ehrliche Spanne beantwortet sie, ohne ein Angebot vorwegzunehmen. Wer schon gerechnet hat, fragt gezielter an, und der Betrieb bekommt Fläche, Ausstattung und Wünsche gleich mitgeliefert.
Wann einsetzen
Handwerk mit wiederkehrenden Leistungen (Bad, Dach, Fassade, Boden, Malerarbeiten), Paket- oder Ratenrechner bei SaaS und Kursanbietern, überall dort, wo Preise von wenigen Größen abhängen.
Wann nicht
Wenn der Betrieb keine belastbaren Erfahrungswerte hat oder jeder Auftrag ganz anders ist. Dann besser Ab-Preise und Beispielprojekte nennen. Nie als verstecktes Lead-Formular, das erst nach der Kontaktangabe ein Ergebnis zeigt.
Varianten
- Badsanierung: Fläche, Ausstattungsstufe, Optionen (bodengleiche Dusche, Wand-WC, Fußbodenheizung)
- Dachdämmung: Dachfläche, Verfahren (zwischen den Sparren, auf den Sparren, oberste Geschossdecke), Dämmstoff, Dachfenster
- Ratenrechner: Betrag und Laufzeit mit Monatsrate (Finanzierung, Kurse)
- Paketrechner: Anzahl Nutzer, Laufzeit, Zusatzmodule (SaaS)
Umsetzung
Ein <form> mit input type=range (Fläche) samt Plus/Minus-Knöpfen, Radiokarten (Stufe) und Checkboxen (Optionen). Jede Änderung rechnet alle Posten als [min, max] neu, rundet auf 100 € und gibt mit Intl.NumberFormat('de-DE') aus. Die Spanne steht in <output>, eine gedrosselte aria-live-Zeile sagt nur den Endwert an. Aufschlüsselung als Liste mit Farbbalken (flex-grow nach Mitte des Postens). Der Knopf „Diese Auswahl anfragen“ schreibt eine lesbare Zusammenfassung in ein verstecktes Feld und zeigt sie über dem Formular mit „Auswahl ändern“; zurück geht ohne Datenverlust. Abhängige Optionen im Script (z. B. Gerüst fest bei Außendämmung). Preisbasis, Stand, Mehrwertsteuer-Hinweis und Förderhinweise liefert der Betrieb; Formulierungen zu Preisangaben vom Kunden prüfen lassen. Im Einsatz die Auswahl auch in die URL schreiben, damit man sie teilen kann.
Robust umsetzen
Telefon. Ergebnis unter den Eingaben; eine feste Leiste unten zeigt die Spanne und „Anfragen“, solange das Ergebnis nicht im Bild ist. Regler mit großem Griff und Plus/Minus-Knöpfen, Formular einspaltig.
Ohne Bewegung. Zahlen und Balken springen ohne Zählen auf den neuen Wert. Zustandswechsel ohne Versatz.
Barrierefrei. Echte Formularelemente mit <label>, Regler mit aria-valuetext („8 Quadratmeter“). Ergebnis in <output> mit aria-live="polite", gedrosselt, damit Screenreader nicht bei jedem Schritt sprechen. Spanne immer als Text, nicht nur als Balken. Signalfarbe Orange nur mit dunkler Schrift (Kontrast über 4,5 : 1).
Code
<!DOCTYPE html>
<!--
Kostenrechner mit Spanne
Fundus · Sektionen & Funktionen · https://cecodesign.de/lab/fundus/sektionen/kostenrechner-konfigurator/
Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kostenrechner mit Spanne · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Special Gothic'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/special-gothic:vf@latest/latin-standard-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-stretch: 75% 125%; font-display: swap; }
@font-face { font-family: 'Atkinson Hyperlegible Next'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/atkinson-hyperlegible-next:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 200 800; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
:root {
/* Rezept Handwerk: Materialflächen, dunkle Schrift, ein Signalton nur für Handlungen */
--grund: #efedea; --flaeche: #e4e0d9; --zement: #d3cdc2; --holz: #c3b4a5; --salbei: #bdc1a9; --blau: #97a2bc; --ocker: #dfd6d0;
--text: #1a1a1a; --leise: #5b5750; --meta: #646059; --linie: #cfc9be;
--tafel: #1d1c1a; --tafel-text: #efedea; --tafel-leise: rgba(239, 237, 234, .62); --tafel-linie: rgba(239, 237, 234, .14);
--signal: #fb6100; --signal-tief: #e25700;
--gut: #3f6b3a; --fehler: #b3261e;
--kurve: cubic-bezier(0.4, 0, 0.2, 1); /* Holzrausch / Tailwind-Standard */
--zu: cubic-bezier(0.4, 0, 1, 1); /* Schließen: Accelerate */
--ui: 150ms; --zahl: 280ms;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font-family: 'Atkinson Hyperlegible Next', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
button, input, select { font: inherit; color: inherit; }
.ziffern { font-variant-numeric: tabular-nums lining-nums; }
.gewerk { font: 600 13px/1.1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; text-transform: uppercase; }
.mono { font: 500 10px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; }
svg[hidden] { display: none; }
.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* Kopf */
.kopf { display: flex; align-items: center; gap: 18px; padding: 14px clamp(16px, 3vw, 36px); border-bottom: 1px solid var(--linie); }
.marke { display: flex; align-items: baseline; gap: 10px; margin-right: auto; }
.marke b { font: 700 21px/1 'Special Gothic', sans-serif; letter-spacing: -.01em; text-transform: uppercase; }
.marke span { color: var(--leise); }
.umschalter { display: flex; border: 1px solid var(--linie); background: var(--grund); }
.umschalter button { background: none; border: 0; padding: 8px 12px; cursor: pointer; color: var(--leise); transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.umschalter button + button { border-left: 1px solid var(--linie); }
.umschalter button[aria-pressed="true"] { background: var(--text); color: var(--grund); }
.tel { display: inline-flex; align-items: center; gap: 8px; text-decoration: none; color: var(--text); font-weight: 700; font-size: 15px; padding: 9px 14px; border: 1.5px solid var(--text); transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.tel:hover { background: var(--text); color: var(--grund); }
:focus-visible { outline: 2px solid var(--text); outline-offset: 3px; }
/* Bühne */
.buehne { display: grid; grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr); gap: clamp(20px, 3vw, 44px); padding: clamp(18px, 2.6vw, 34px) clamp(16px, 3vw, 36px) 40px; max-width: 1240px; margin: 0 auto; align-items: start; }
.eingaben { display: grid; gap: 22px; min-width: 0; }
.titel .gewerk { color: var(--meta); display: flex; gap: 10px; align-items: center; }
.titel .gewerk::before { content: ''; width: 18px; height: 2px; background: var(--text); }
.titel h1 { font: 700 clamp(2rem, 1.3rem + 2.6vw, 3.1rem)/1.04 'Special Gothic', sans-serif; letter-spacing: -.01em; margin-top: 10px; text-wrap: balance; }
.titel p { font-size: 17px; line-height: 1.5; color: var(--leise); max-width: 50ch; margin-top: 10px; text-wrap: pretty; }
fieldset { border: 0; min-width: 0; }
legend, .feldkopf { display: flex; justify-content: space-between; align-items: baseline; width: 100%; gap: 12px; margin-bottom: 10px; }
legend .gewerk, .feldkopf .gewerk { color: var(--text); }
legend small, .feldkopf small { font-size: 13px; color: var(--meta); }
/* Fläche: Regler + Zeichnung */
.flaeche { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: center; background: var(--flaeche); padding: 16px 18px; }
.zeichnung { aspect-ratio: 300 / 210; width: 100%; display: block; }
.zeichnung svg { width: 100%; height: 100%; display: block; overflow: visible; }
.zeichnung svg[hidden] { display: none; }
.regler-wert { font: 700 46px/1 'Special Gothic', sans-serif; letter-spacing: -.02em; display: flex; align-items: baseline; gap: 6px; }
.regler-wert small { font: 600 18px/1 'Special Gothic', sans-serif; font-stretch: 75%; color: var(--leise); letter-spacing: .02em; }
.regler-zeile { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.stufe { flex: none; width: 36px; height: 36px; border: 1.5px solid var(--text); background: transparent; font-size: 20px; line-height: 1; cursor: pointer; display: grid; place-items: center; transition: background-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.stufe:hover { background: var(--text); color: var(--grund); }
input[type="range"] { -webkit-appearance: none; appearance: none; flex: 1; min-width: 0; height: 36px; background: transparent; cursor: pointer; --p: 30%; }
input[type="range"]::-webkit-slider-runnable-track { height: 4px; background: linear-gradient(90deg, var(--text) var(--p), var(--zement) var(--p)); }
input[type="range"]::-moz-range-track { height: 4px; background: var(--zement); }
input[type="range"]::-moz-range-progress { height: 4px; background: var(--text); }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 26px; height: 26px; margin-top: -11px; border-radius: 50%; background: var(--grund); border: 2px solid var(--text); box-shadow: 0 2px 0 rgba(0,0,0,.12); transition: scale var(--ui) var(--kurve); }
input[type="range"]::-moz-range-thumb { width: 22px; height: 22px; border-radius: 50%; background: var(--grund); border: 2px solid var(--text); }
input[type="range"]:active::-webkit-slider-thumb, .zieht input[type="range"]::-webkit-slider-thumb { scale: 1.12; }
input[type="range"]:focus-visible { outline-offset: 6px; }
.skala { display: flex; justify-content: space-between; color: var(--meta); font-size: 12px; margin: 2px 46px 0; }
/* Stufen-Karten */
.karten { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.karte { position: relative; display: grid; gap: 4px; padding: 13px 14px 14px; background: var(--grund); border: 1.5px solid var(--linie); cursor: pointer; transition: background-color var(--ui) var(--kurve), border-color var(--ui) var(--kurve), color var(--ui) var(--kurve); }
.karte input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.karte b { font: 700 17px/1.1 'Special Gothic', sans-serif; }
.karte span { font-size: 13.5px; line-height: 1.35; color: var(--leise); transition: color var(--ui) var(--kurve); }
.karte em { font-style: normal; font-size: 12px; color: var(--meta); margin-top: 2px; }
.karte:hover { border-color: var(--text); }
.karte:has(input:checked) { background: var(--text); border-color: var(--text); color: var(--grund); }
.karte:has(input:checked) span, .karte:has(input:checked) em { color: rgba(239, 237, 234, .72); }
.karte:has(input:focus-visible) { outline: 2px solid var(--text); outline-offset: 3px; }
.karte:has(input:disabled) { opacity: .45; cursor: not-allowed; }
/* Optionen */
.optionen { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.option { position: relative; display: grid; grid-template-columns: 22px 1fr; gap: 2px 10px; align-items: start; padding: 12px 13px; border: 1.5px solid var(--linie); cursor: pointer; transition: border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.option input { position: absolute; opacity: 0; width: 22px; height: 22px; margin: 0; cursor: pointer; }
.option .box { width: 22px; height: 22px; border: 1.5px solid var(--text); display: grid; place-items: center; transition: background-color var(--ui) var(--kurve); grid-row: span 3; }
.option .box svg { width: 14px; height: 14px; stroke: var(--grund); stroke-width: 2.4; fill: none; stroke-dasharray: 20; stroke-dashoffset: 20; transition: stroke-dashoffset var(--ui) var(--kurve); }
.option b { font-weight: 700; font-size: 15px; line-height: 1.3; }
.option small { font-size: 13px; line-height: 1.35; color: var(--meta); grid-column: 2; }
.option .preis { grid-column: 2; font: 600 13px/1.2 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; color: var(--text); margin-top: 3px; }
.option:hover { border-color: var(--text); }
.option:has(input:checked) { border-color: var(--text); background: var(--flaeche); }
.option:has(input:checked) .box { background: var(--text); }
.option:has(input:checked) .box svg { stroke-dashoffset: 0; }
.option:has(input:focus-visible) { outline: 2px solid var(--text); outline-offset: 3px; }
.option.zahl { grid-template-columns: 1fr auto; align-items: center; cursor: default; }
.option.zahl:has(output:not([data-n="0"])) { border-color: var(--text); background: var(--flaeche); }
.zaehler { display: flex; align-items: center; gap: 4px; }
.zaehler .stufe { width: 32px; height: 32px; font-size: 18px; }
.zaehler output { min-width: 24px; text-align: center; font: 700 18px/1 'Special Gothic', sans-serif; }
.option.fest { cursor: default; border-style: dashed; }
.option.fest .box { background: var(--zement); border-color: var(--zement); }
.option.fest .box svg { stroke: var(--text); stroke-dashoffset: 0; }
.werte { color: var(--meta); display: flex; flex-wrap: wrap; gap: 4px 14px; padding-top: 4px; }
.werte b { color: var(--text); font-weight: 500; }
/* Ergebnis-Tafel */
.tafel { position: sticky; top: 16px; background: var(--tafel); color: var(--tafel-text); padding: clamp(18px, 2.2vw, 28px); display: grid; gap: 18px; min-width: 0; }
.tafel-kopf { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.tafel-kopf .gewerk { color: var(--tafel-leise); }
.marker { font: 600 12px/1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .05em; text-transform: uppercase; padding: 6px 8px 5px; border: 1px solid var(--tafel-linie); color: var(--tafel-leise); }
.spanne { font: 700 clamp(34px, 3.7vw, 50px)/1 'Special Gothic', sans-serif; letter-spacing: -.02em; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 .25em; }
.spanne .bis { color: var(--tafel-leise); font-weight: 400; }
.spanne .eur { font-size: .56em; color: var(--tafel-leise); font-weight: 600; }
.unter { font-size: 14px; color: var(--tafel-leise); margin-top: 10px; display: flex; flex-wrap: wrap; gap: 2px 14px; }
.unter b { color: var(--tafel-text); font-weight: 600; }
.skalenband { position: relative; height: 34px; margin-top: 2px; }
.skalenband .schiene { position: absolute; left: 0; right: 0; top: 10px; height: 8px; background: repeating-linear-gradient(90deg, var(--tafel-linie) 0 1px, transparent 1px calc(100% / 9)); border-bottom: 1px solid var(--tafel-linie); }
.skalenband .bereich { position: absolute; top: 6px; height: 16px; left: 20%; width: 20%; background: var(--holz); transition: left var(--zahl) var(--kurve), width var(--zahl) var(--kurve); }
.skalenband .mitte { position: absolute; top: 2px; height: 24px; width: 2px; background: var(--tafel-text); left: 30%; transition: left var(--zahl) var(--kurve); }
.skalenband .marken { position: absolute; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-between; color: var(--tafel-leise); font-size: 11px; }
.stapel { display: flex; height: 10px; gap: 2px; }
.stapel i { display: block; flex: 1 1 0; min-width: 2px; transition: flex-grow var(--zahl) var(--kurve), opacity var(--zahl) var(--kurve); }
.posten { list-style: none; display: grid; }
.posten li { display: grid; grid-template-columns: 10px 1fr auto; gap: 10px; align-items: baseline; padding: 7px 0; border-bottom: 1px solid var(--tafel-linie); font-size: 14.5px; transition: opacity var(--zahl) var(--kurve); }
.posten li:first-child { border-top: 1px solid var(--tafel-linie); }
.posten li i { width: 10px; height: 10px; align-self: center; }
.posten li span { color: var(--tafel-text); }
.posten li span small { color: var(--tafel-leise); font-size: 12.5px; margin-left: 6px; }
.posten li b { font-weight: 600; text-align: right; }
.posten li.aus :is(span, b, small) { color: var(--tafel-leise); } /* gedämpft über Farbe statt Deckkraft (lesbar) */
.posten li.aus i { opacity: .38; }
.posten li.neu { animation: neu 400ms var(--kurve); }
@keyframes neu { from { opacity: 0; translate: 0 8px; } }
.hinweis { font-size: 13.5px; line-height: 1.45; color: var(--tafel-leise); border-left: 2px solid var(--holz); padding: 2px 0 2px 12px; }
.hinweis b { color: var(--tafel-text); font-weight: 700; }
.cta { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
.cta .knopf { white-space: nowrap; padding-inline: 18px; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: 12px; padding: 17px 20px; border: 0; cursor: pointer; background: var(--signal); color: var(--text); font: 700 16px/1 'Atkinson Hyperlegible Next', sans-serif; text-decoration: none; transition: background-color var(--ui) var(--kurve), scale var(--ui) var(--kurve); }
.knopf:hover { background: var(--signal-tief); }
.knopf:active, .knopf.gedrueckt { scale: .97; }
.knopf:focus-visible { outline: 2px solid var(--tafel-text); outline-offset: 3px; }
.knopf.zweit { background: transparent; color: var(--tafel-text); border: 1.5px solid var(--tafel-linie); }
.knopf.zweit:hover { border-color: var(--tafel-text); background: transparent; }
/* Zustände der Tafel: Schätzung → Anfrage → Gesendet */
.ansicht { display: grid; gap: 18px; }
.ansicht[hidden] { display: none; }
.ansicht.rein { animation: rein 280ms var(--kurve); }
@keyframes rein { from { opacity: 0; translate: 0 8px; } }
.zurueck { justify-self: start; background: none; border: 0; color: var(--tafel-leise); cursor: pointer; display: inline-flex; gap: 8px; align-items: center; font-size: 14px; padding: 4px 0; }
.zurueck:hover { color: var(--tafel-text); }
.auswahl { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; font-size: 14.5px; padding: 14px 16px; background: rgba(239, 237, 234, .06); border: 1px solid var(--tafel-linie); }
.auswahl dt { color: var(--tafel-leise); }
.auswahl dd { color: var(--tafel-text); }
.anfrage h2, .gesendet h2 { font: 700 26px/1.08 'Special Gothic', sans-serif; letter-spacing: -.01em; }
.felder { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.feld { display: grid; gap: 6px; position: relative; }
.feld.breit { grid-column: 1 / -1; }
.feld label { font: 600 12.5px/1.1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .05em; text-transform: uppercase; color: var(--tafel-leise); }
.feld input, .feld select { width: 100%; font-size: 17px; padding: 12px 13px; background: rgba(239, 237, 234, .07); color: var(--tafel-text); border: 1.5px solid var(--tafel-linie); border-radius: 0; outline: none; transition: border-color var(--ui) var(--kurve), background-color var(--ui) var(--kurve); }
.feld select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--tafel-leise) 50%), linear-gradient(-45deg, transparent 50%, var(--tafel-leise) 50%); background-position: calc(100% - 18px) 52%, calc(100% - 13px) 52%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 34px; }
.feld select option { color: var(--text); }
.feld input:focus-visible, .feld select:focus-visible, .feld.fokus input { border-color: var(--tafel-text); background-color: rgba(239, 237, 234, .1); }
.feld input:user-invalid { border-color: #ff8a6b; }
.feld .fehler { font-size: 13px; color: #ffb39e; display: none; }
.feld:has(input:user-invalid) .fehler { display: block; }
.optional { text-transform: none; letter-spacing: 0; font-stretch: 100%; font-weight: 400; }
.datei { position: relative; display: flex; align-items: center; gap: 12px; padding: 8px; border: 1.5px dashed var(--tafel-linie); transition: border-color var(--ui) var(--kurve); }
.datei input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; padding: 0; }
.datei:hover, .datei:has(input:focus-visible) { border-color: var(--tafel-text); }
.datei:has(input:focus-visible) { outline: 2px solid var(--tafel-text); outline-offset: 2px; }
.datei-knopf { flex: none; font-weight: 700; font-size: 14px; background: var(--tafel-text); color: var(--text); padding: 9px 12px; }
.datei-name { font-size: 13.5px; color: var(--tafel-leise); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.datenschutz { font-size: 13px; line-height: 1.45; color: var(--tafel-leise); }
.datenschutz a { color: var(--tafel-text); }
.gesendet .haken { width: 54px; height: 54px; border-radius: 50%; background: var(--salbei); display: grid; place-items: center; }
.gesendet .haken svg { width: 26px; height: 26px; stroke: var(--text); stroke-width: 2.6; fill: none; stroke-dasharray: 30; stroke-dashoffset: 30; animation: zeichnen 400ms var(--kurve) 120ms forwards; }
@keyframes zeichnen { to { stroke-dashoffset: 0; } }
.gesendet p { font-size: 16px; line-height: 1.5; color: var(--tafel-leise); }
.gesendet p b { color: var(--tafel-text); }
.gesendet p b.ziffern { white-space: nowrap; }
.nummer { font: 600 13px/1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .06em; text-transform: uppercase; color: var(--tafel-leise); }
/* Leiste unten (nur schmal) */
.leiste { display: none; }
/* Gedachter Zeiger der Vorführung */
.zeiger { position: fixed; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border-radius: 50%; border: 2px solid var(--text); background: rgba(251, 97, 0, .28); pointer-events: none; z-index: 50; opacity: 0; transition: translate 600ms var(--kurve), opacity 200ms var(--kurve), scale var(--ui) var(--kurve); }
.vorfuehrung .zeiger.an { opacity: 1; }
.zeiger.klick { scale: .7; }
/* Zeichnung */
.wand { fill: none; stroke: var(--text); stroke-width: 5; stroke-linejoin: miter; }
.boden { fill: url(#fliesen); }
.mass { font: 600 12px/1 'Special Gothic', sans-serif; font-stretch: 75%; letter-spacing: .04em; fill: var(--leise); }
.masslinie { stroke: var(--meta); stroke-width: 1; }
.objekt { fill: var(--grund); stroke: var(--text); stroke-width: 1.6; }
.heizung { fill: none; stroke: #a8896b; stroke-width: 1.3; opacity: 0; transition: opacity var(--zahl) var(--kurve); }
.mit-heizung .heizung { opacity: 1; }
.zeichnung g, .zeichnung rect, .zeichnung path { transition: opacity var(--zahl) var(--kurve); }
.dach-schicht { transition: opacity var(--zahl) var(--kurve); }
/* Reduzierte Bewegung */
.reduziert *, .reduziert *::before, .reduziert *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-delay: 0s !important; }
.reduziert .zeiger { transition: opacity 150ms linear !important; }
@media (hover: none) {
.tel:hover, .stufe:hover { background: transparent; color: inherit; }
.karte:hover, .option:hover { border-color: var(--linie); }
input[type="range"]::-webkit-slider-thumb { width: 32px; height: 32px; margin-top: -14px; }
.stufe { width: 44px; height: 44px; }
}
@media (max-width: 900px) {
.buehne { grid-template-columns: 1fr; padding-bottom: 110px; }
.tafel { position: relative; top: 0; }
.leiste { position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; display: flex; align-items: center; gap: 12px; padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); background: var(--tafel); color: var(--tafel-text); transition: translate 280ms var(--kurve); }
.leiste.weg { translate: 0 110%; transition-timing-function: var(--zu); transition-duration: 200ms; }
.leiste div { display: grid; gap: 2px; margin-right: auto; }
.leiste small { font-size: 12px; color: var(--tafel-leise); }
.leiste b { font: 700 21px/1 'Special Gothic', sans-serif; letter-spacing: -.01em; }
.leiste .knopf { padding: 14px 16px; font-size: 15px; }
}
@media (max-width: 640px) {
.kopf { flex-wrap: wrap; gap: 10px 12px; }
.marke { flex-direction: column; gap: 4px; }
.tel span { display: none; }
.umschalter { order: 3; width: 100%; }
.umschalter button { flex: 1; }
.flaeche { grid-template-columns: 1fr; padding: 14px; }
.zeichnung { max-width: 300px; justify-self: center; }
.karten { grid-template-columns: 1fr; }
.karte { grid-template-columns: auto 1fr; column-gap: 12px; }
.karte em { grid-column: 2; }
.karte span { grid-column: 1 / -1; }
.optionen { grid-template-columns: 1fr; }
.felder { grid-template-columns: 1fr; }
.cta { grid-template-columns: 1fr; }
.skala { margin: 2px 54px 0; }
}
/* Vorschau 960 × 720: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .titel p, .vorschau .werte, .vorschau .tel span { display: none; }
.vorschau .buehne { padding-top: 16px; gap: 28px; }
.vorschau .eingaben { gap: 16px; }
.vorschau .titel h1 { font-size: 36px; margin-top: 6px; }
.vorschau .tafel { gap: 14px; padding: 20px 22px; }
.vorschau .posten li { padding: 5px 0; font-size: 13.5px; }
.vorschau .hinweis { font-size: 12.5px; }
.vorschau .unter { margin-top: 6px; }
.vorschau .felder { gap: 8px; }
.vorschau .feld input, .vorschau .feld select { padding: 9px 12px; font-size: 15px; }
.vorschau .anfrage { gap: 12px; }
.vorschau .auswahl { padding: 10px 14px; font-size: 13.5px; }
.vorschau .flaeche { padding: 12px 16px; }
.vorschau .zeichnung { max-height: 176px; }
.vorschau .regler-wert { font-size: 40px; }
.vorschau .karte em, .vorschau .option small { display: none; }
.vorschau .karte { padding: 10px 12px; }
.vorschau .option { padding: 10px 12px; }
.vorschau .knopf { padding-block: 15px; font-size: 15px; }
.vorschau .spanne { font-size: 42px; }
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf">
<p class="marke"><b>Heckmann</b><span class="gewerk">Bad · Dach · Ausbau · Siegen</span></p>
<div class="umschalter mono" role="group" aria-label="Rechner wählen" data-umschalter>
<button type="button" data-v="bad" aria-pressed="true">Badsanierung</button>
<button type="button" data-v="dach" aria-pressed="false">Dachdämmung</button>
</div>
<a class="tel" href="tel:+4927123904417"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 5a2 2 0 0 1 2-2"/></svg><span class="ziffern">0271 2390 4417</span><b class="vh">Anrufen</b></a>
</header>
<main class="buehne">
<form class="eingaben" data-rechner aria-labelledby="titel" onsubmit="return false">
<div class="titel">
<p class="gewerk" data-eyebrow>Kostenrechner Badsanierung</p>
<h1 id="titel" data-titel>Was kostet Ihr neues Bad?</h1>
<p data-vorspann>Drei Angaben, eine ehrliche Spanne. Den festen Preis nennen wir nach dem Termin bei Ihnen vor Ort.</p>
</div>
<div class="flaeche">
<div>
<div class="feldkopf"><label class="gewerk" for="f-flaeche" data-flaeche-label>Badfläche</label><small data-flaeche-hinweis>mit dem Zollstock gemessen</small></div>
<p class="regler-wert ziffern" aria-hidden="true"><span data-flaeche-zahl>8,0</span><small>m²</small></p>
<div class="regler-zeile">
<button type="button" class="stufe" data-schritt="-1" aria-label="Fläche verkleinern">−</button>
<input type="range" id="f-flaeche" name="flaeche" min="3" max="16" step="0.5" value="8" data-flaeche>
<button type="button" class="stufe" data-schritt="1" aria-label="Fläche vergrößern">+</button>
</div>
<p class="skala ziffern" aria-hidden="true"><span data-skala-min>3 m²</span><span data-skala-max>16 m²</span></p>
</div>
<figure class="zeichnung" aria-hidden="true">
<svg viewBox="0 0 300 210" data-svg-bad>
<defs>
<pattern id="fliesen" width="15.6" height="15.6" patternUnits="userSpaceOnUse"><rect width="15.6" height="15.6" fill="#f6f4f0"/><path d="M15.6 0V15.6H0" fill="none" stroke="#d3cdc2" stroke-width=".8"/></pattern>
</defs>
<g data-raum>
<rect class="boden" data-boden x="20" y="16" width="200" height="150"/>
<path class="heizung" data-heizung d=""/>
<g data-dusche>
<rect class="objekt" data-wanne x="0" y="0" width="44" height="88" rx="10"/>
<g data-bodengleich opacity="0"><rect x="0" y="0" width="52" height="52" fill="none" stroke="#1a1a1a" stroke-width="1.2" stroke-dasharray="3 3"/><line x1="10" y1="44" x2="42" y2="44" stroke="#1a1a1a" stroke-width="2"/></g>
</g>
<g data-wt><rect class="objekt" x="0" y="0" width="34" height="20" rx="3"/><ellipse cx="17" cy="11" rx="10" ry="6" fill="none" stroke="#1a1a1a" stroke-width="1.2"/></g>
<g data-wc><rect class="objekt" data-spuelkasten x="5" y="0" width="22" height="8" rx="1.5"/><path class="objekt" d="M4 8h24v8c0 9-5 16-12 16S4 25 4 16Z"/><rect data-vorwand x="0" y="-1" width="32" height="7" fill="#c3b4a5" opacity="0"/></g>
<g data-griff opacity="0"><line x1="0" y1="0" x2="0" y2="26" stroke="#1a1a1a" stroke-width="3" stroke-linecap="round"/></g>
<path class="wand" data-wand d=""/>
<path data-tuer d="" fill="none" stroke="#7a756c" stroke-width="1" stroke-dasharray="2 3"/>
</g>
<g data-masse>
<line class="masslinie" data-mass-b x1="0" y1="0" x2="0" y2="0"/>
<text class="mass ziffern" data-mass-b-t text-anchor="middle">2,40 m</text>
<line class="masslinie" data-mass-t x1="0" y1="0" x2="0" y2="0"/>
<text class="mass ziffern" data-mass-t-t text-anchor="middle">3,33 m</text>
</g>
</svg>
<svg viewBox="0 0 300 210" data-svg-dach hidden>
<defs>
<pattern id="mineral" width="12" height="8" patternUnits="userSpaceOnUse"><rect width="12" height="8" fill="#e9e1c9"/><path d="M0 4q3-4 6 0t6 0" fill="none" stroke="#b8a77c" stroke-width=".9"/></pattern>
<pattern id="holzfaser" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#c9b49a"/><path d="M0 2h6M0 5h6" stroke="#9e8466" stroke-width=".6"/></pattern>
<pattern id="zellulose" width="6" height="6" patternUnits="userSpaceOnUse"><rect width="6" height="6" fill="#c8ccb6"/><circle cx="2" cy="2" r=".8" fill="#8d927a"/><circle cx="5" cy="4.5" r=".7" fill="#8d927a"/></pattern>
</defs>
<g data-haus></g>
<g data-masse-dach>
<text class="mass ziffern" data-dach-t x="150" y="204" text-anchor="middle">120 m² Dachfläche</text>
</g>
</svg>
</figure>
</div>
<fieldset data-stufen>
<legend><span class="gewerk" data-stufen-titel>Ausstattung</span><small data-stufen-hinweis>bestimmt Fliesen, Armaturen, Licht</small></legend>
<div class="karten" data-karten></div>
</fieldset>
<fieldset data-material hidden>
<legend><span class="gewerk">Dämmstoff</span><small>alle drei mit gleicher Dämmwirkung gerechnet</small></legend>
<div class="karten" data-materialien></div>
</fieldset>
<fieldset>
<legend><span class="gewerk">Optionen</span><small>Preise je Option als Spanne</small></legend>
<div class="optionen" data-optionen></div>
</fieldset>
<p class="werte mono" data-werte><span>Kurve <b>cubic-bezier(0.4, 0, 0.2, 1)</b></span><span>Zahlen <b>280 ms</b></span><span>Farbe <b>150 ms</b></span><span>Rundung <b>100 €</b></span><span>Ausgabe <b>Intl.NumberFormat de-DE</b></span></p>
</form>
<aside class="tafel" aria-labelledby="t-titel" data-tafel>
<!-- 1 Schätzung -->
<section class="ansicht" data-ansicht="schaetzung">
<div class="tafel-kopf"><h2 class="gewerk" id="t-titel">Ihre Schätzung</h2><span class="marker">Unverbindlich</span></div>
<div>
<output class="spanne ziffern" for="f-flaeche" data-spanne aria-hidden="true"><span data-min>11.700</span><span class="bis">–</span><span data-max>19.600</span><span class="eur">€</span></output>
<p class="unter ziffern"><span>Mitte ca. <b data-mitte>15.700 €</b></span><span data-je>1.460–2.450 € je m²</span><span>inkl. 19 % MwSt.</span></p>
<p class="vh" aria-live="polite" data-ansage></p>
</div>
<div class="skalenband" aria-hidden="true">
<div class="schiene"></div><div class="bereich" data-bereich></div><div class="mitte" data-mittestrich></div>
<div class="marken ziffern"><span>0</span><span data-skalen-mitte>22.500</span><span data-skalen-max>45.000 €</span></div>
</div>
<div>
<div class="stapel" data-stapel aria-hidden="true"></div>
<ul class="posten ziffern" data-posten aria-label="Aufschlüsselung"></ul>
</div>
<p class="hinweis" data-hinweis></p>
<div class="cta">
<button type="button" class="knopf" data-zur-anfrage>Diese Auswahl anfragen <span aria-hidden="true">→</span></button>
<a class="knopf zweit" href="tel:+4927123904417">Anrufen</a>
</div>
</section>
<!-- 2 Anfrage mit übergebener Auswahl -->
<section class="ansicht anfrage" data-ansicht="anfrage" hidden>
<button type="button" class="zurueck" data-zurueck><span aria-hidden="true">←</span> Zurück zur Schätzung</button>
<h2 tabindex="-1" data-anfrage-titel>Termin vor Ort anfragen</h2>
<dl class="auswahl ziffern" data-auswahl></dl>
<form class="felder" data-anfrage novalidate>
<input type="hidden" name="auswahl" data-auswahl-feld>
<div class="feld"><label for="a-name">Name</label><input id="a-name" name="name" required autocomplete="name" placeholder="Petra Krämer" aria-describedby="e-name"><p class="fehler" id="e-name">Wie dürfen wir Sie ansprechen?</p></div>
<div class="feld"><label for="a-kontakt">Telefon oder E-Mail</label><input id="a-kontakt" name="kontakt" required pattern="\s*(\+?[0-9 \-]{6,}|\S+@\S+\.\S{2,})\s*" autocomplete="tel" placeholder="0271 …" aria-describedby="e-kontakt"><p class="fehler" id="e-kontakt">Eine Nummer oder E-Mail, damit wir Sie erreichen.</p></div>
<div class="feld"><label for="a-plz">PLZ der Baustelle</label><input id="a-plz" name="plz" required inputmode="numeric" pattern="\d{5}" maxlength="5" autocomplete="postal-code" placeholder="57072" aria-describedby="e-plz"><p class="fehler" id="e-plz">Fünf Ziffern, z. B. 57072.</p></div>
<div class="feld"><label for="a-zeit">Wunschzeitraum</label><select id="a-zeit" name="zeitraum"><option>So bald wie möglich</option><option selected>In 1 bis 3 Monaten</option><option>In 3 bis 6 Monaten</option><option>Noch offen</option></select></div>
<div class="feld breit"><label for="a-foto">Foto vom Bestand <span class="optional">(optional)</span></label><div class="datei"><input id="a-foto" name="foto" type="file" accept="image/*"><span class="datei-knopf" aria-hidden="true">Foto wählen</span><span class="datei-name" data-dateiname aria-hidden="true">Keine Datei gewählt · hilft bei der Einschätzung</span></div></div>
<p class="datenschutz breit feld">Wir nutzen Ihre Angaben nur für diese Anfrage und löschen sie, wenn kein Auftrag zustande kommt. <a href="#datenschutz">Datenschutz</a></p>
<div class="feld breit"><button type="submit" class="knopf" data-senden>Anfrage senden <span aria-hidden="true">→</span></button></div>
</form>
</section>
<!-- 3 Gesendet -->
<section class="ansicht gesendet" data-ansicht="gesendet" hidden>
<div class="haken"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5 10 17l9-10"/></svg></div>
<h2 tabindex="-1" data-gesendet-titel>Danke, Ihre Anfrage ist da.</h2>
<p data-gesendet-text></p>
<p class="nummer ziffern" data-nummer></p>
<button type="button" class="knopf zweit" data-neu>Neue Schätzung</button>
</section>
</aside>
</main>
<div class="leiste" data-leiste aria-hidden="true">
<div><small>Ihre Schätzung, unverbindlich</small><b class="ziffern" data-leiste-spanne>11.700–19.600 €</b></div>
<button type="button" class="knopf" data-leiste-knopf tabindex="-1">Anfragen</button>
</div>
<div class="zeiger" data-zeiger aria-hidden="true"></div>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
const EUR = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 });
const M2 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const rund = (x, r = 100) => Math.round(x / r) * r;
// ------------------------------------------------------------------
// Preisbasis: Beispielwerte der Demo (brutto). Im Einsatz liefert der Betrieb
// seine Werte mit Stand und Quelle.
// ------------------------------------------------------------------
const FARBEN = ['#c3b4a5', '#bdc1a9', '#d3cdc2', '#97a2bc', '#dfd6d0', '#8f8a7e', '#e9e1c9'];
const RECHNER = {
bad: {
eyebrow: 'Kostenrechner Badsanierung', titel: 'Was kostet Ihr neues Bad?',
vorspann: 'Drei Angaben, eine ehrliche Spanne. Den festen Preis nennen wir nach dem Termin bei Ihnen vor Ort.',
flaeche: { label: 'Badfläche', hinweis: 'mit dem Zollstock gemessen', min: 3, max: 16, step: .5, start: 8, einheit: 'Quadratmeter' },
stufenTitel: 'Ausstattung', stufenHinweis: 'bestimmt Fliesen, Armaturen, Licht',
stufen: [
{ id: 'standard', name: 'Standard', text: 'Solide Markenware, Fliesen bis 40 € je m²', f: .68 },
{ id: 'komfort', name: 'Komfort', text: 'Großformat-Fliesen, Unterputz-Armaturen', f: 1, start: true },
{ id: 'premium', name: 'Premium', text: 'Naturstein, Design-Armaturen, Lichtplanung', f: 1.55 }
],
optionen: [
{ id: 'dusche', name: 'Bodengleiche Dusche', text: 'statt Wanne, mit Rinne', min: 1500, max: 2700 },
{ id: 'wandwc', name: 'Wand-WC mit Vorwand', text: 'Spülkasten unsichtbar', min: 900, max: 1500 },
{ id: 'heizung', name: 'Fußbodenheizung', text: 'elektrisch, unter den Fliesen', min: 110, max: 170, jeM2: true },
{ id: 'griffe', name: 'Barrierearm', text: 'Haltegriffe, Klappsitz, Rutschhemmung', min: 600, max: 1100 }
],
skala: 45000, hinweis: '<b>Unverbindliche Schätzung.</b> Beispielwerte aus Aufträgen im Siegerland 2025/26, inkl. Material, Lohn und Entsorgung. Nicht enthalten: Grundriss ändern, Schäden an Leitungen oder Estrich, neue Fenster.',
rechne(e) {
const A = e.flaeche, f = e.stufe.f, o = e.optionen;
return [
{ name: 'Rückbau & Entsorgung', min: 600 + A * 60, max: 900 + A * 95 },
{ name: 'Sanitär-Installation', min: (1800 + A * 40) * (.7 + .3 * f), max: (3000 + A * 70) * (.7 + .3 * f) },
{ name: 'Abdichtung & Fliesen', zusatz: M2.format(A * 3.2) + ' m²', min: A * 3.2 * 140 * f, max: A * 3.2 * 230 * f },
{ name: 'Objekte & Armaturen', min: 3000 * f, max: 5200 * f },
{ name: 'Elektro & Licht', min: 900 * (.8 + .2 * f), max: 1700 * (.8 + .2 * f) },
{ name: 'Trockenbau & Maler', min: 500 + A * 60, max: 800 + A * 100 },
{ name: 'Optionen', zusatz: o.length ? o.length + ' gewählt' : 'keine', min: o.reduce((s, x) => s + (x.jeM2 ? x.min * A : x.min), 0), max: o.reduce((s, x) => s + (x.jeM2 ? x.max * A : x.max), 0) }
];
}
},
dach: {
eyebrow: 'Kostenrechner Dachdämmung', titel: 'Was kostet die Dämmung Ihres Dachs?',
vorspann: 'Fläche, Verfahren, Dämmstoff. Die Spanne gilt für ein Steildach mit Ziegeldeckung im Siegerland.',
flaeche: { label: 'Dachfläche', hinweis: 'beide Seiten zusammen', min: 40, max: 200, step: 5, start: 120, einheit: 'Quadratmeter' },
stufenTitel: 'Verfahren', stufenHinweis: 'wo die Dämmung liegt',
stufen: [
{ id: 'zwischen', name: 'Zwischen den Sparren', text: 'Von innen, das Dach bleibt dicht', start: true },
{ id: 'auf', name: 'Auf den Sparren', text: 'Von außen, mit neuer Eindeckung' },
{ id: 'decke', name: 'Oberste Geschossdecke', text: 'Dachboden bleibt unbeheizt' }
],
materialien: [
{ id: 'mineral', name: 'Mineralwolle', text: 'günstig, nicht brennbar', f: 1, start: true },
{ id: 'holz', name: 'Holzfaser', text: 'guter Hitzeschutz im Sommer', f: 1.45 },
{ id: 'zell', name: 'Zellulose', text: 'eingeblasen, nicht auf den Sparren', f: 1.12, nichtAuf: true }
],
optionen: [
{ id: 'fenster', name: 'Dachfenster tauschen', text: 'mit Eindeckrahmen', min: 1100, max: 1900, zaehler: true },
{ id: 'beratung', name: 'Energieberatung einplanen', text: 'Förderung möglich, Höhe prüft die Beratung', min: 1300, max: 2200 },
{ id: 'geruest', name: 'Gerüst', text: 'bei Dämmung von außen immer dabei', fest: 'auf' }
],
skala: 90000, hinweis: '<b>Unverbindliche Schätzung.</b> Beispielwerte aus Aufträgen im Siegerland 2025/26, inkl. Material, Lohn und Entsorgung, ohne Förderung. Nicht enthalten: Schäden an Sparren, Statik, Dachrinnen und Kamin.',
rechne(e) {
const A = e.flaeche, v = e.stufe.id, m = e.material, fe = e.zahl.fenster || 0, o = e.optionen;
const mf = m.f;
const zeilen = [];
if (v === 'zwischen') {
zeilen.push({ name: 'Baustelle & Schutz', min: 400, max: 700 });
zeilen.push({ name: 'Dämmstoff', zusatz: m.name, min: A * 18 * mf, max: A * 28 * mf });
zeilen.push({ name: 'Dämmen (Lohn)', min: A * 45, max: A * 75 });
zeilen.push({ name: 'Dampfbremse & Verkleidung', min: A * 40, max: A * 65 });
} else if (v === 'auf') {
zeilen.push({ name: 'Gerüst & Baustelle', min: 900 + A * 8, max: 1500 + A * 12 });
zeilen.push({ name: 'Dämmstoff', zusatz: m.name, min: A * 32 * mf, max: A * 46 * mf });
zeilen.push({ name: 'Dämmen (Lohn)', min: A * 60, max: A * 95 });
zeilen.push({ name: 'Neue Eindeckung', min: A * 90, max: A * 150 });
zeilen.push({ name: 'Alte Ziegel entsorgen', min: A * 12, max: A * 20 });
} else {
zeilen.push({ name: 'Baustelle & Schutz', min: 300, max: 500 });
zeilen.push({ name: 'Dämmstoff', zusatz: m.name, min: A * .55 * 14 * mf, max: A * .55 * 22 * mf });
zeilen.push({ name: 'Dämmen (Lohn)', zusatz: M2.format(A * .55) + ' m² Decke', min: A * .55 * 18, max: A * .55 * 30 });
zeilen.push({ name: 'Begehbarer Belag', min: A * .55 * 20, max: A * .55 * 35 });
}
zeilen.push({ name: 'Dachfenster', zusatz: fe ? fe + ' Stück' : 'keine', min: fe * 1100, max: fe * 1900, leer: !fe });
const b = o.find(x => x.id === 'beratung');
zeilen.push({ name: 'Energieberatung', min: b ? 1300 : 0, max: b ? 2200 : 0, leer: !b });
return zeilen;
}
}
};
// ------------------------------------------------------------------
// Elemente
// ------------------------------------------------------------------
const rechner = $('[data-rechner]');
const regler = $('[data-flaeche]');
const elKarten = $('[data-karten]'), elMat = $('[data-materialien]'), elOpt = $('[data-optionen]');
const elPosten = $('[data-posten]'), elStapel = $('[data-stapel]');
const ansichten = Object.fromEntries($$('[data-ansicht]').map(a => [a.dataset.ansicht, a]));
const anfrage = $('[data-anfrage]');
const leiste = $('[data-leiste]');
let v = 'bad', R = RECHNER.bad;
let zahl = { fenster: 0 };
let angezeigt = { min: 0, max: 0, mitte: 0 };
function karte(name, s, checked, typ = 'radio') {
return `<label class="karte"><input type="${typ}" name="${name}" value="${s.id}"${checked ? ' checked' : ''}><b>${s.name}</b><span>${s.text}</span></label>`;
}
const HAKEN = '<svg viewBox="0 0 14 14" aria-hidden="true"><path d="M2.5 7.3 5.6 10.2 11.5 3.8"/></svg>';
function option(o) {
const preis = `+ ${EUR.format(o.min || 0)}–${EUR.format(o.max || 0)} €` + (o.jeM2 ? ' je m²' : o.zaehler ? ' je Fenster' : '');
if (o.zaehler) return `<div class="option zahl"><div><b id="o-${o.id}">${o.name}</b><br><small>${o.text}</small><br><span class="preis ziffern">${preis}</span></div>
<div class="zaehler" role="group" aria-labelledby="o-${o.id}"><button type="button" class="stufe" data-zaehler="${o.id}" data-d="-1" aria-label="Ein Fenster weniger">−</button><output data-n="0" data-zaehler-wert="${o.id}" aria-live="polite">0</output><button type="button" class="stufe" data-zaehler="${o.id}" data-d="1" aria-label="Ein Fenster mehr">+</button></div></div>`;
if (o.fest) return `<div class="option fest" data-fest="${o.fest}"><span class="box">${HAKEN}</span><b>${o.name}</b><small data-fest-text>${o.text}</small></div>`;
return `<label class="option"><input type="checkbox" name="option" value="${o.id}"><span class="box">${HAKEN}</span><b>${o.name}</b><small>${o.text}</small><span class="preis ziffern">${preis}</span></label>`;
}
function aufbauen() {
R = RECHNER[v];
$('[data-eyebrow]').textContent = R.eyebrow;
$('[data-titel]').textContent = R.titel;
$('[data-vorspann]').textContent = R.vorspann;
$('[data-flaeche-label]').textContent = R.flaeche.label;
$('[data-flaeche-hinweis]').textContent = R.flaeche.hinweis;
Object.assign(regler, { min: R.flaeche.min, max: R.flaeche.max, step: R.flaeche.step });
regler.value = R.flaeche.start;
$('[data-skala-min]').textContent = R.flaeche.min + ' m²';
$('[data-skala-max]').textContent = R.flaeche.max + ' m²';
$('[data-stufen-titel]').textContent = R.stufenTitel;
$('[data-stufen-hinweis]').textContent = R.stufenHinweis;
elKarten.innerHTML = R.stufen.map(s => karte('stufe', s, s.start)).join('');
$('[data-material]').hidden = !R.materialien;
elMat.innerHTML = (R.materialien || []).map(s => karte('material', s, s.start)).join('');
elOpt.innerHTML = R.optionen.map(option).join('');
$('[data-hinweis]').innerHTML = R.hinweis;
// SVG-Elemente kennen die Eigenschaft .hidden nicht: Attribut setzen
$('[data-svg-bad]').toggleAttribute('hidden', v !== 'bad');
$('[data-svg-dach]').toggleAttribute('hidden', v !== 'dach');
zahl = { fenster: 0 };
$('[data-skalen-max]').textContent = EUR.format(R.skala) + ' €';
$('[data-skalen-mitte]').textContent = EUR.format(R.skala / 2);
elPosten.innerHTML = ''; elStapel.innerHTML = '';
rechnen(true);
}
function eingaben() {
const fd = new FormData(rechner);
const stufe = R.stufen.find(s => s.id === fd.get('stufe')) || R.stufen[0];
const material = R.materialien ? (R.materialien.find(s => s.id === fd.get('material')) || R.materialien[0]) : null;
const optionen = fd.getAll('option').map(id => R.optionen.find(o => o.id === id));
return { flaeche: +regler.value, stufe, material, optionen, zahl };
}
// Abhängigkeiten der Dach-Variante: Zellulose nicht auf den Sparren, Gerüst bei „auf“ fest
function regeln(e) {
if (v !== 'dach') return;
const auf = e.stufe.id === 'auf';
const zell = elMat.querySelector('input[value="zell"]');
zell.disabled = auf;
if (auf && zell.checked) { elMat.querySelector('input[value="holz"]').checked = true; e.material = R.materialien[1]; }
const fest = elOpt.querySelector('[data-fest]');
fest.style.display = auf ? '' : 'none';
}
let ansageZeit;
function rechnen(sofort) {
const e = eingaben();
regeln(e);
const zeilen = R.rechne(e).map(z => ({ ...z, min: rund(z.min), max: rund(z.max) }));
const min = zeilen.reduce((s, z) => s + z.min, 0), max = zeilen.reduce((s, z) => s + z.max, 0);
const mitte = rund((min + max) / 2);
// Regler
const p = (regler.value - regler.min) / (regler.max - regler.min) * 100;
regler.style.setProperty('--p', p + '%');
const a = +regler.value;
$('[data-flaeche-zahl]').textContent = v === 'bad' ? M2.format(a) : EUR.format(a);
regler.setAttribute('aria-valuetext', `${M2.format(a).replace(',0', '')} ${R.flaeche.einheit}`);
// Zahlen zählen kurz (280 ms), reduziert sofort
zaehlen({ min, max, mitte }, sofort);
$('[data-je]').textContent = `${EUR.format(rund(min / a, 10))}–${EUR.format(rund(max / a, 10))} € je m²`;
$('[data-bereich]').style.left = (min / R.skala * 100) + '%';
$('[data-bereich]').style.width = ((max - min) / R.skala * 100) + '%';
$('[data-mittestrich]').style.left = (mitte / R.skala * 100) + '%';
$('[data-leiste-spanne]').textContent = `${EUR.format(min)}–${EUR.format(max)} €`;
// Aufschlüsselung: Zeilen bleiben stehen, Werte wechseln
const alt = elPosten.children.length === zeilen.length;
if (!alt) {
elPosten.innerHTML = zeilen.map((z, i) => `<li><i style="background:${FARBEN[i % FARBEN.length]}"></i><span></span><b></b></li>`).join('');
elStapel.innerHTML = zeilen.map((z, i) => `<i style="background:${FARBEN[i % FARBEN.length]}"></i>`).join('');
}
zeilen.forEach((z, i) => {
const li = elPosten.children[i];
li.querySelector('span').innerHTML = z.name + (z.zusatz ? ` <small>${z.zusatz}</small>` : '');
li.querySelector('b').textContent = z.max ? `${EUR.format(z.min)}–${EUR.format(z.max)} €` : '–';
const war = li.classList.contains('aus');
li.classList.toggle('aus', !z.max);
if (war && z.max && alt && !reduziert()) { li.classList.remove('neu'); void li.offsetWidth; li.classList.add('neu'); }
elStapel.children[i].style.flexGrow = (z.min + z.max) / 2;
elStapel.children[i].style.opacity = z.max ? 1 : 0;
});
// Zeichnung
if (v === 'bad') zeichneBad(e); else zeichneDach(e);
// Ansage gedrosselt: nur der Endwert, nicht jeder Schritt
clearTimeout(ansageZeit);
ansageZeit = setTimeout(() => { $('[data-ansage]').textContent = `Geschätzt ${EUR.format(min)} bis ${EUR.format(max)} Euro.`; }, 700);
letzte = { e, min, max, mitte };
}
let letzte = null;
// Zahlen-Tween mit derselben Kurve wie CSS
function bezier(x1, y1, x2, y2) {
const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx, cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by;
const X = u => ((ax * u + bx) * u + cx) * u, Y = u => ((ay * u + by) * u + cy) * u, dX = u => (3 * ax * u + 2 * bx) * u + cx;
return t => { let u = t; for (let i = 0; i < 8; i++) { const d = dX(u); if (Math.abs(d) < 1e-6) break; u -= (X(u) - t) / d; } return Y(Math.min(1, Math.max(0, u))); };
}
const KURVE = bezier(.4, 0, .2, 1);
let tween = 0;
function zaehlen(ziel, sofort) {
cancelAnimationFrame(tween);
const von = { ...angezeigt }, t0 = performance.now(), D = 280;
const setzen = w => {
$('[data-min]').textContent = EUR.format(rund(w.min));
$('[data-max]').textContent = EUR.format(rund(w.max));
$('[data-mitte]').textContent = EUR.format(rund(w.mitte)) + ' €';
};
if (sofort || reduziert() || !von.max) { angezeigt = { ...ziel }; setzen(ziel); return; }
const schritt = now => {
const k = KURVE(Math.min(1, (now - t0) / D));
angezeigt = { min: von.min + (ziel.min - von.min) * k, max: von.max + (ziel.max - von.max) * k, mitte: von.mitte + (ziel.mitte - von.mitte) * k };
setzen(angezeigt);
if (k < 1) tween = requestAnimationFrame(schritt); else angezeigt = { ...ziel };
};
tween = requestAnimationFrame(schritt);
}
// ------------------------------------------------------------------
// Grundriss (Bad): Raum wächst im festen Maßstab, Objekte folgen
// ------------------------------------------------------------------
const S = 48, X0 = 26, Y0 = 10; // 48 px je Meter
function zeichneBad(e) {
const A = e.flaeche, b = Math.sqrt(A * 1.38), t = A / b;
const w = b * S, h = t * S, x = X0 + (236 - w) / 2, y = Y0 + (166 - h) / 2;
const q = s => document.querySelector(`[data-svg-bad] [data-${s}]`);
const ids = new Set(e.optionen.map(o => o.id));
const boden = q('boden'); boden.setAttribute('x', x); boden.setAttribute('y', y); boden.setAttribute('width', w); boden.setAttribute('height', h);
// Tür unten links: Wand mit Lücke
const tb = 0.8 * S;
q('wand').setAttribute('d', `M${x + 14 + tb} ${y + h} H${x + w} V${y} H${x} V${y + h} H${x + 14}`);
q('tuer').setAttribute('d', `M${x + 14} ${y + h} v${-tb} a${tb} ${tb} 0 0 1 ${tb} ${tb}`);
// Dusche/Wanne rechts oben
const dusche = ids.has('dusche');
const wanne = q('wanne'), bg = q('bodengleich');
const ww = Math.min(.75 * S, w * .3), wh = Math.min(1.7 * S, h - 10);
wanne.setAttribute('width', ww); wanne.setAttribute('height', wh);
q('dusche').setAttribute('transform', `translate(${x + w - (dusche ? Math.min(1.0 * S, w * .38) : ww) - 3} ${y + 3})`);
const dg = Math.min(1.0 * S, w * .38, h * .7);
bg.setAttribute('transform', `scale(${dg / 52})`);
wanne.style.opacity = dusche ? 0 : 1; bg.setAttribute('opacity', dusche ? 1 : 0);
// Waschtisch oben links, WC oben Mitte
q('wt').setAttribute('transform', `translate(${x + 10} ${y + 3})`);
const wcx = x + 10 + 34 + Math.max(8, (w - (dusche ? dg : ww) - 34 - 32 - 20) / 2);
q('wc').setAttribute('transform', `translate(${wcx} ${y + 3})`);
q('spuelkasten').style.opacity = ids.has('wandwc') ? 0 : 1;
q('vorwand').setAttribute('opacity', ids.has('wandwc') ? 1 : 0);
q('griff').setAttribute('opacity', ids.has('griffe') ? 1 : 0);
q('griff').setAttribute('transform', `translate(${wcx + 38} ${y + 6})`);
// Fußbodenheizung: Mäander im freien Boden
const svg = document.querySelector('[data-svg-bad]');
svg.classList.toggle('mit-heizung', ids.has('heizung'));
let d = '', yy = y + h * .42, x1 = x + 12, x2 = x + w - 12, links = true;
d = `M${x1} ${yy}`;
while (yy < y + h - 12) { d += ` H${links ? x2 : x1} v8`; yy += 8; links = !links; }
q('heizung').setAttribute('d', d);
// Maße
const mb = q('mass-b'), mt = q('mass-t');
mb.setAttribute('x1', x); mb.setAttribute('x2', x + w); mb.setAttribute('y1', y + h + 12); mb.setAttribute('y2', y + h + 12);
const tb2 = q('mass-b-t'); tb2.setAttribute('x', x + w / 2); tb2.setAttribute('y', y + h + 26); tb2.textContent = M2.format(b).replace('.', ',') + ' m';
mt.setAttribute('x1', x + w + 12); mt.setAttribute('x2', x + w + 12); mt.setAttribute('y1', y); mt.setAttribute('y2', y + h);
const tt = q('mass-t-t'); tt.setAttribute('x', x + w + 18); tt.setAttribute('y', y + h / 2 + 4); tt.setAttribute('text-anchor', 'start'); tt.textContent = M2.format(t) + ' m';
}
// ------------------------------------------------------------------
// Dachschnitt: Hausbreite folgt der Fläche, Dämmschicht dem Verfahren
// ------------------------------------------------------------------
function zeichneDach(e) {
const A = e.flaeche, k = (A - 40) / 160;
const halb = 70 + k * 62, cx = 150, first = 26, traufe = 120, boden = 186;
const L = cx - halb, Rr = cx + halb;
const v2 = e.stufe.id, mat = { mineral: 'mineral', holz: 'holzfaser', zell: 'zellulose' }[e.material.id];
const fe = e.zahl.fenster || 0;
// Dachneigung
const dx = halb, dy = traufe - first, len = Math.hypot(dx, dy), nx = dy / len, ny = dx / len; // Normale nach außen (links)
const dicke = 13;
const seite = (s, off1, off2) => { // Band parallel zur Dachfläche, s = -1 links / +1 rechts
const ax = cx, ay = first, bx = cx + s * halb, by = traufe;
const ox1 = -s * nx * 0 + s * nx * off1, oy1 = -ny * off1, ox2 = s * nx * off2, oy2 = -ny * off2;
return `M${ax + ox1} ${ay + oy1} L${bx + ox1} ${by + oy1} L${bx + ox2} ${by + oy2} L${ax + ox2} ${ay + oy2} Z`;
};
let schicht = '';
if (v2 === 'zwischen') schicht = seite(-1, 0, -dicke) + seite(1, 0, -dicke);
if (v2 === 'auf') schicht = seite(-1, 3, 3 + dicke) + seite(1, 3, 3 + dicke);
const decke = v2 === 'decke' ? `M${L + 10} ${traufe - 1} H${Rr - 10} v-12 H${L + 10} Z` : '';
// Dachfenster als kurze Striche auf der rechten Seite
let fenster = '';
for (let i = 0; i < fe; i++) {
const t0 = .24 + i * .17, t1 = t0 + .1;
const p0 = [cx + halb * t0, first + dy * t0], p1 = [cx + halb * t1, first + dy * t1];
fenster += `<line x1="${p0[0] + nx * 6}" y1="${p0[1] - ny * 6}" x2="${p1[0] + nx * 6}" y2="${p1[1] - ny * 6}" stroke="#97a2bc" stroke-width="5"/>`;
}
const eindeckung = v2 === 'auf' ? '#fb6100' : '#1a1a1a';
document.querySelector('[data-haus]').innerHTML = `
<rect x="${L + 10}" y="${traufe}" width="${2 * halb - 20}" height="${boden - traufe}" fill="#e4e0d9" stroke="#1a1a1a" stroke-width="2"/>
<rect x="${cx - 14}" y="${boden - 40}" width="28" height="40" fill="#d3cdc2" stroke="#1a1a1a" stroke-width="1.5"/>
<rect x="${L + 26}" y="${traufe + 18}" width="26" height="22" fill="#efedea" stroke="#1a1a1a" stroke-width="1.5"/>
<rect x="${Rr - 52}" y="${traufe + 18}" width="26" height="22" fill="#efedea" stroke="#1a1a1a" stroke-width="1.5"/>
<path d="M${L - 8} ${traufe + 6} L${cx} ${first - 4} L${Rr + 8} ${traufe + 6}" fill="none" stroke="#1a1a1a" stroke-width="1.5"/>
<path class="dach-schicht" d="${schicht}${decke}" fill="url(#${mat})" stroke="#1a1a1a" stroke-width="1"/>
<path d="M${L} ${traufe} L${cx} ${first} L${Rr} ${traufe}" fill="none" stroke="#1a1a1a" stroke-width="2.4"/>
<path d="M${L - 8 + (v2 === 'auf' ? -3 : 0)} ${traufe + 6 - (v2 === 'auf' ? dicke + 4 : 0)} L${cx} ${first - 4 - (v2 === 'auf' ? dicke + 4 : 0)} L${Rr + 8 + (v2 === 'auf' ? 3 : 0)} ${traufe + 6 - (v2 === 'auf' ? dicke + 4 : 0)}" fill="none" stroke="${eindeckung}" stroke-width="3" stroke-dasharray="${v2 === 'auf' ? '6 2' : '0'}" opacity="${v2 === 'auf' ? 1 : 0}"/>
${fenster}
<line x1="${L - 20}" y1="${boden}" x2="${Rr + 20}" y2="${boden}" stroke="#1a1a1a" stroke-width="2"/>`;
document.querySelector('[data-dach-t]').textContent = `${EUR.format(A)} m² Dachfläche · ${e.material.name}`;
}
// ------------------------------------------------------------------
// Ereignisse
// ------------------------------------------------------------------
rechner.addEventListener('input', () => rechnen());
rechner.addEventListener('change', () => rechnen());
$$('[data-schritt]').forEach(b => b.addEventListener('click', () => {
regler.value = +regler.value + +b.dataset.schritt * +regler.step * (v === 'dach' ? 2 : 1);
rechnen();
}));
elOpt.addEventListener('click', ev => {
const b = ev.target.closest('[data-zaehler]'); if (!b) return;
const id = b.dataset.zaehler; zahl[id] = Math.max(0, Math.min(4, (zahl[id] || 0) + +b.dataset.d));
const o = elOpt.querySelector(`[data-zaehler-wert="${id}"]`); o.textContent = zahl[id]; o.dataset.n = zahl[id];
rechnen();
});
// Übergabe: Auswahl wird Zusammenfassung + verstecktes Feld des Formulars
function zusammenfassung() {
const { e, min, max } = letzte;
const zeilen = [[R.flaeche.label, (v === 'bad' ? M2.format(e.flaeche) : EUR.format(e.flaeche)) + ' m²'], [R.stufenTitel, e.stufe.name]];
if (e.material) zeilen.push(['Dämmstoff', e.material.name]);
const opt = e.optionen.map(o => o.name);
if (e.zahl.fenster) opt.push(`${e.zahl.fenster} Dachfenster`);
zeilen.push(['Optionen', opt.length ? opt.join(', ') : 'keine']);
zeilen.push(['Schätzung', `${EUR.format(min)}–${EUR.format(max)} €`]);
return zeilen;
}
function zeige(name, fokus = true) {
Object.entries(ansichten).forEach(([n, a]) => { a.hidden = n !== name; a.classList.toggle('rein', n === name && !reduziert()); });
if (fokus && !html.classList.contains('vorfuehrung')) {
const ziel = name === 'anfrage' ? $('[data-anfrage-titel]') : name === 'gesendet' ? $('[data-gesendet-titel]') : $('[data-zur-anfrage]');
ziel.focus({ preventScroll: true });
if (innerWidth <= 900) $('[data-tafel]').scrollIntoView({ behavior: reduziert() ? 'auto' : 'smooth', block: 'start' });
}
sichtLeiste();
}
function zurAnfrage() {
const z = zusammenfassung();
$('[data-auswahl]').innerHTML = z.map(([a, b]) => `<dt>${a}</dt><dd>${b}</dd>`).join('') + `<dt></dt><dd><button type="button" class="zurueck" data-zurueck style="padding:0;text-decoration:underline">Auswahl ändern</button></dd>`;
$('[data-auswahl-feld]').value = `${R.eyebrow}: ` + z.map(([a, b]) => `${a} ${b}`).join(' · ');
$('[data-anfrage-titel]').textContent = v === 'bad' ? 'Termin vor Ort anfragen' : 'Dach-Check vor Ort anfragen';
zeige('anfrage');
}
$('[data-zur-anfrage]').addEventListener('click', zurAnfrage);
$('[data-leiste-knopf]').addEventListener('click', zurAnfrage);
document.addEventListener('click', ev => { if (ev.target.closest('[data-zurueck]')) zeige('schaetzung'); });
anfrage.addEventListener('submit', ev => {
ev.preventDefault();
const falsch = [...anfrage.querySelectorAll('input[required]')].filter(i => !i.validity.valid);
if (falsch.length) { if (!html.classList.contains('vorfuehrung')) falsch[0].focus(); return; }
const name = anfrage.elements.name.value.trim();
const nr = `${v === 'bad' ? 'BS' : 'DD'}-1101-${String(7 + Math.floor(Math.random() * 40)).padStart(2, '0')}`;
$('[data-gesendet-titel]').textContent = `Danke, ${name.split(/\s+/)[0]}. Ihre Anfrage ist da.`;
$('[data-gesendet-text]').innerHTML = `<b>Jörg Heckmann</b> ruft Sie innerhalb von zwei Werktagen an und vereinbart einen Termin vor Ort. Ihre Auswahl und die Schätzung von <b class="ziffern">${EUR.format(letzte.min)}–${EUR.format(letzte.max)} €</b> liegen ihm vor.`;
$('[data-nummer]').textContent = 'Anfrage ' + nr;
zeige('gesendet');
});
const datei = anfrage.elements.foto, dateiName = $('[data-dateiname]'), DATEI_LEER = dateiName.textContent;
datei.addEventListener('change', () => { const f = datei.files[0]; dateiName.textContent = f ? `${f.name} · ${(f.size / 1048576).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB` : DATEI_LEER; });
anfrage.addEventListener('reset', () => setTimeout(() => { dateiName.textContent = DATEI_LEER; }));
$('[data-neu]').addEventListener('click', () => { anfrage.reset(); zeige('schaetzung'); });
// Mobile Leiste: nur zeigen, wenn die Tafel nicht im Bild ist
let tafelSichtbar = false;
new IntersectionObserver(es => { tafelSichtbar = es[0].isIntersecting; sichtLeiste(); }, { threshold: .15 }).observe($('[data-tafel]'));
function sichtLeiste() { leiste.classList.toggle('weg', tafelSichtbar || ansichten.schaetzung.hidden); }
// Varianten (?v=bad|dach)
const schalter = $$('[data-umschalter] [data-v]');
function variante(neu, url = true) {
v = RECHNER[neu] ? neu : 'bad';
schalter.forEach(b => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
anfrage.reset(); zeige('schaetzung', false);
aufbauen();
if (url) { const q = new URLSearchParams(location.search); q.set('v', v); history.replaceState(null, '', location.pathname + '?' + q.toString() + location.hash); }
}
schalter.forEach(b => b.addEventListener('click', () => { const lief = laeuft; neustart(); variante(b.dataset.v); if (lief) vorfuehren(); }));
variante(new URLSearchParams(location.search).get('v') || 'bad', false);
// ------------------------------------------------------------------
// Vorführung: gedachter Zeiger stellt ein, die Spanne zählt, Übergabe an die Anfrage
// ------------------------------------------------------------------
const zeiger = $('[data-zeiger]');
let uhr = [], laeuft = false;
const nach = (ms, f) => uhr.push(setTimeout(f, ms));
function zeigeAuf(el, dx = .5, dy = .5) {
if (!el) return;
const r = el.getBoundingClientRect();
zeiger.style.translate = `${r.left + r.width * dx}px ${r.top + r.height * dy}px`;
zeiger.classList.add('an');
}
function klick(el, dx, dy) { zeigeAuf(el, dx, dy); nach(520, () => { zeiger.classList.add('klick'); el.click(); nach(150, () => zeiger.classList.remove('klick')); }); }
function ziehen(t, von, bis, ms) {
const n = 14;
for (let i = 0; i <= n; i++) nach(t + i * ms / n, () => {
const wert = von + (bis - von) * KURVE(i / n);
regler.value = wert; rechnen();
const p = (regler.value - regler.min) / (regler.max - regler.min);
zeigeAuf(regler, .02 + p * .96, .5);
});
}
function tippen(t, input, text, ms = 55) {
[...text].forEach((z, k) => nach(t + k * ms, () => { input.value += z; input.dispatchEvent(new Event('input', { bubbles: true })); }));
return t + text.length * ms;
}
function runde() {
uhr.forEach(clearTimeout); uhr = [];
anfrage.reset(); variante(v, false);
const W = v === 'bad' ? { von: 6, bis: 11 } : { von: 80, bis: 150 };
regler.value = W.von; rechnen(true);
let t = 400;
nach(t, () => zeigeAuf(regler, (W.von - regler.min) / (regler.max - regler.min), .5));
ziehen(t + 600, W.von, W.bis, 1500); t += 2500;
if (v === 'bad') {
nach(t, () => klick(elKarten.children[2])); t += 1300;
nach(t, () => klick(elOpt.children[0], .1, .4)); t += 1200;
nach(t, () => klick(elOpt.children[2], .1, .4)); t += 1500;
} else {
nach(t, () => klick(elKarten.children[1])); t += 1300;
nach(t, () => klick(elMat.children[1])); t += 1200;
nach(t, () => klick(elOpt.querySelector('[data-d="1"]'))); t += 900;
nach(t, () => klick(elOpt.querySelector('[data-d="1"]'))); t += 1500;
}
nach(t, () => klick($('[data-zur-anfrage]'), .3, .5)); t += 1100;
nach(t, () => zeigeAuf(anfrage.elements.name, .3)); t = tippen(t + 300, anfrage.elements.name, 'Petra Krämer');
nach(t + 100, () => zeigeAuf(anfrage.elements.kontakt, .3)); t = tippen(t + 400, anfrage.elements.kontakt, '0271 55 31 08', 50);
nach(t + 100, () => zeigeAuf(anfrage.elements.plz, .3)); t = tippen(t + 400, anfrage.elements.plz, '57074', 80);
nach(t + 300, () => klick($('[data-senden]'), .3, .5)); t += 1000;
nach(t + 2800, () => { zeiger.classList.remove('an'); });
nach(t + 3300, runde);
}
function vorfuehren() { pause(); html.classList.add('vorfuehrung'); laeuft = true; runde(); }
function pause() { uhr.forEach(clearTimeout); uhr = []; laeuft = false; }
function weiter() { if (!laeuft && html.classList.contains('vorfuehrung')) { laeuft = true; runde(); } }
function neustart() { pause(); html.classList.remove('vorfuehrung'); zeiger.classList.remove('an'); anfrage.reset(); zeige('schaetzung', false); aufbauen(); }
})();
</script>
</body>
</html>