Rollende Ziffern (Zahlen-Ticker / Odometer)
Kennzahlen eines Gestaltungsateliers rollen beim Jahreswechsel Stelle für Stelle in die neue Zahl, die Richtung folgt dem Trend.
Eine Zahl wird in Ziffern zerlegt. Jede Ziffer ist eine senkrechte Spalte 0–9 hinter einer Maske. Ändert sich der Wert, dreht jede Spalte zur Zielziffer. Die Richtung folgt dem Trend: steigt der Wert, rollt es nach oben. Neue oder entfallende Stellen und Tausenderpunkte blenden ein und aus, die Breite wächst oder schrumpft mit. Die Maske ist oben und unten weich ausgeblendet, so rollen die Ziffern aus dem Nichts.
Die Zahl wird zum Ereignis, ohne kitschiges Hochzählen. Die mechanische Anmutung wirkt präzise: Man sieht, dass das System rechnet. Mit tabular-nums bleibt die Zahl dabei ruhig und typografisch sauber.
Wann einsetzen
Kennzahlen-Sektionen („120 Projekte“), Preisumschalter monatlich/jährlich, Warenkorbsummen, Zähler, Dashboards, Preloader-Prozent, Countdowns.
Wann nicht
Wenn die Zahl sofort exakt gelesen werden muss (Formulare, Tabellen, Finanzhandel). Bei Dutzenden Zahlen gleichzeitig. Bei Live-Werten, die sich ständig ändern: Das erzeugt Unruhe.
Varianten
- Odometer-Spalten mit translateY, Richtung nach Trend (NumberFlow, Motion AnimateNumber, Osmo „Number Odometer“)
- Durchlaufen: untere Stellen laufen sichtbar durch die Zwischenwerte (NumberFlow continuous)
- Klassisches Hochzählen vom alten zum neuen Wert per Tween, formatiert mit Intl.NumberFormat
- Nur CSS: @property --num mit syntax '<integer>' und counter(), nur ganze Zahlen
- Preloader 0–100 % in großer Display-Typo
- Scramble-Zahlen: Zufallsziffern, die einrasten
- Mikroanimation bei Wertänderungen (Preisumschalter, Like-Zähler)
Umsetzung
Pro Ziffer eine Zelle mit Streifen: <span class='stelle'><span class='streifen'>0…9</span></span>. .stelle { height: 1lh; overflow: hidden; mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent) }. .streifen { transform: translateY(calc(var(--d) * -1lh)); transition: transform 750ms cubic-bezier(0, 0, 0.58, 1) }. Stellen nach Stellenwert von rechts verschlüsseln, damit Tausenderpunkte und neue Stellen sauber ein- und ausblenden (Opacity 350 ms, Breite 750 ms). Stellen mit transition-delay staffeln, Einer zuerst (30–60 ms). Für den Überlauf 9 → 0 in Trendrichtung den Streifen mehrfach wiederholen (z. B. 5 × 0–9) und vor jedem Lauf unsichtbar um 10 Zellen in den mittleren Block zurücksetzen. Trend = Math.sign(neu − alt). font-variant-numeric: tabular-nums verhindert Breitenzucken; Laufweite nicht in der Zelle setzen (overflow schneidet die Glyphe), sondern als Rand. Formatierung mit Intl.NumberFormat. Nur-CSS-Zähler: @property --num { syntax: '<integer>'; initial-value: 0; inherits: false } .c { counter-reset: n var(--num); transition: --num 1.2s } .c::after { content: counter(n) }. Nachkommastellen dann separat.
Robust umsetzen
Telefon. Unverändert. Hauptzahl in vw-Größe so wählen, dass die längste Zahl mit allen Stellen auf 390 px passt.
Ohne Bewegung. Zielwert sofort zeigen, höchstens 100 ms überblenden. NumberFlow beachtet die Einstellung automatisch (respectMotionPreference).
Barrierefrei. Ziffernspalten aria-hidden. Den echten Wert einmal zugänglich machen (aria-label oder visuell versteckter Text). Bei Wertänderungen aria-live='polite' nur mit dem Endwert ansagen, nie mit Zwischenwerten.
Code
<!DOCTYPE html>
<!--
Rollende Ziffern (Zahlen-Ticker / Odometer)
Fundus · Schrift-Animationen · https://cecodesign.de/lab/fundus/schrift-animationen/zahlen-odometer/
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>Rollende Ziffern (Zahlen-Ticker / Odometer) · 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: 'Instrument Sans'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-sans:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 400 700; font-display: swap; }
@font-face { font-family: 'Instrument Serif'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/instrument-serif@latest/latin-400-italic.woff2') format('woff2'); font-weight: 400; font-style: italic; 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 {
--papier: #eeebe4; --tinte: #151413; --leise: rgba(21, 20, 19, .64); --linie: rgba(21, 20, 19, .15);
--plus: #1f6b52; --minus: #a8432a;
--kurve: cubic-bezier(0, 0, 0.58, 1); /* ease-out, gemessen bei NumberFlow */
--drehen: 750ms; /* Ziffern-Spin und Breite */
--blenden: 350ms; /* Opacity neuer/entfallender Stellen */
--zh: 1.1em; /* Zeilenhöhe einer Ziffernzelle = Weg je Ziffer */
}
* { box-sizing: border-box; margin: 0; }
html { background: var(--papier); color: var(--tinte); font-family: 'Instrument Sans', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
.mono { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.buehne { min-height: 100svh; display: grid; grid-template-rows: auto auto 1fr auto auto; gap: clamp(18px, 3vh, 32px); padding: clamp(20px, 4vw, 48px); }
.kopf { display: flex; justify-content: space-between; align-items: center; gap: 12px 16px; flex-wrap: wrap; }
.marke b { color: var(--tinte); font-weight: 500; }
.modi { display: flex; gap: 4px; flex-wrap: wrap; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; color: var(--leise); background: none; border: 1px solid var(--linie); border-radius: 999px; height: 30px; padding: 0 12px; cursor: pointer; transition: color .15s, background .15s, border-color .15s; }
.modi button:hover { color: var(--tinte); border-color: rgba(21, 20, 19, .32); }
.modi button[aria-pressed='true'] { background: var(--tinte); color: var(--papier); border-color: var(--tinte); }
button:focus-visible { outline: 2px solid var(--tinte); outline-offset: 3px; }
/* Jahre: Reiter mit gleitendem Strich */
.jahre { position: relative; display: flex; gap: clamp(18px, 3vw, 40px); border-bottom: 1px solid var(--linie); }
.jahre button { font: 500 clamp(17px, 1.6vw, 21px)/1 'Instrument Sans', sans-serif; font-variant-numeric: tabular-nums; letter-spacing: -.01em; background: none; border: 0; padding: 0 0 14px; color: var(--leise); cursor: pointer; transition: color .15s; }
.jahre button:hover, .jahre button[aria-selected='true'] { color: var(--tinte); }
.jahre .strich { position: absolute; left: 0; bottom: -1px; height: 2px; width: 10px; background: var(--tinte); transform-origin: 0 0; transition: translate var(--blenden) var(--kurve), width var(--blenden) var(--kurve); }
/* Hauptzahl und Projektbild */
.haupt { display: grid; grid-template-columns: 1fr minmax(180px, 25%); gap: clamp(20px, 4vw, 56px); align-items: end; min-height: 0; }
.gross { display: grid; gap: 10px; align-self: end; }
.zahl.held { font-size: clamp(76px, min(21vw, 36vh), 320px); font-weight: 500; letter-spacing: -.045em; margin-left: -.06em; }
.unter { display: flex; align-items: baseline; gap: 10px 18px; flex-wrap: wrap; }
.unter .satz { font-size: clamp(17px, 1.8vw, 24px); letter-spacing: -.01em; }
.unter em { font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; font-size: 1.12em; }
.trend { display: inline-flex; align-items: center; gap: 6px; font: 500 13px/1 'Geist Mono', monospace; letter-spacing: .02em; color: var(--plus); padding: 6px 10px 6px 8px; border: 1px solid currentColor; border-radius: 999px; transition: color var(--blenden) var(--kurve); }
.trend.runter { color: var(--minus); }
.trend svg { width: 10px; height: 10px; transition: rotate var(--drehen) var(--kurve); }
.trend.runter svg { rotate: 180deg; }
.trend .zahl { --zh: 1.25em; }
.projekt { display: grid; gap: 10px; align-self: stretch; align-content: end; }
.rahmen { position: relative; aspect-ratio: 4 / 5; border-radius: 4px; overflow: hidden; background: #d9d5cc; max-height: 46vh; width: 100%; justify-self: end; }
.rahmen::after { content: ''; position: absolute; inset: 0; border: 1px solid var(--linie); border-radius: inherit; pointer-events: none; }
.rahmen img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0; scale: 1.06; transition: opacity var(--blenden) var(--kurve), scale var(--drehen) var(--kurve); }
.rahmen img.an { opacity: 1; scale: 1; }
.projekt .titel { display: flex; justify-content: space-between; gap: 8px; }
.projekt .titel b { color: var(--tinte); font-weight: 500; }
/* Kennzahlen */
.kennzahlen { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--linie); }
.kennzahl { display: grid; gap: 8px; padding: clamp(14px, 2vh, 22px) clamp(12px, 2vw, 24px) 0; border-left: 1px solid var(--linie); }
.kennzahl:first-child { border-left: 0; padding-left: 0; }
.kennzahl .wert { display: flex; align-items: flex-start; font-size: clamp(34px, 4.6vw, 64px); font-weight: 500; letter-spacing: -.035em; }
.kennzahl .einheit { font-size: .46em; letter-spacing: -.01em; margin: .22em 0 0 .1em; color: var(--leise); }
.werte { display: flex; flex-wrap: wrap; gap: 6px 18px; }
.werte b { color: var(--tinte); font-weight: 500; }
/* ── Das Zählwerk ─────────────────────────────── */
.zahl { display: inline-flex; align-items: flex-start; white-space: nowrap; line-height: var(--zh); font-variant-numeric: tabular-nums; }
.zahl .text { display: inline-block; }
.stelle, .zeichen { display: inline-block; height: var(--zh); overflow: hidden; vertical-align: top; transition: width var(--drehen) var(--kurve), opacity var(--blenden) var(--kurve); transition-delay: var(--verz, 0ms); }
/* Weiche Maskenränder: Ziffern rollen aus dem Nichts */
.stelle { mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent); }
.streifen { display: block; transition: transform var(--drehen) var(--kurve); transition-delay: var(--verz, 0ms); will-change: transform; }
.streifen span { display: block; height: var(--zh); text-align: center; }
/* Laufweite nicht in der Zelle (sonst schneidet overflow die Glyphe), sondern als Rand */
.zahl > .stelle, .zahl > .zeichen { letter-spacing: 0; margin-right: var(--spur, 0em); }
.zahl.held { --spur: -.04em; }
.kennzahl .zahl { --spur: -.025em; }
.stelle.weg, .zeichen.weg, .stelle.neu, .zeichen.neu { opacity: 0; }
/* Reduzierte Bewegung: Wert sofort ersetzen, nur kurz überblenden (JS) */
.reduziert .streifen, .reduziert .stelle, .reduziert .zeichen, .reduziert .rahmen img, .reduziert .trend svg, .reduziert .jahre .strich { transition: none !important; }
.reduziert .rahmen img { scale: 1; }
/* Telefon */
@media (max-width: 700px) {
.buehne { grid-template-rows: auto auto auto auto auto; }
.haupt { grid-template-columns: 1fr; align-items: start; }
.zahl.held { font-size: 26vw; }
.projekt { grid-template-columns: 76px 1fr; align-items: center; gap: 14px; }
.rahmen { max-height: none; }
.projekt .titel { flex-direction: column; }
.kennzahlen { grid-template-columns: 1fr 1fr; }
.kennzahl:nth-child(3) { grid-column: 1 / -1; border-left: 0; padding-left: 0; border-top: 1px solid var(--linie); margin-top: 16px; }
}
@media (hover: none) { .modi button, .jahre button { -webkit-tap-highlight-color: transparent; } }
/* Vorschau in der Galerie: nur die Bühne */
.vorschau body { overflow: hidden; }
.vorschau .buehne { height: 100svh; }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<section class="buehne" aria-labelledby="ueberschrift">
<div class="kopf">
<p class="mono marke" id="ueberschrift"><b>Atelier Kranich</b> · Gestaltung · Jahresbilanz</p>
<div class="modi mono" role="group" aria-label="Variante">
<button type="button" data-modus="odometer" aria-pressed="true">Odometer</button>
<button type="button" data-modus="durchlauf" aria-pressed="false">Durchlaufen</button>
<button type="button" data-modus="zaehlen" aria-pressed="false">Hochzählen</button>
</div>
</div>
<div class="jahre" role="tablist" aria-label="Jahr">
<button type="button" role="tab" data-jahr="2024" aria-selected="false">2024</button>
<button type="button" role="tab" data-jahr="2025" aria-selected="false">2025</button>
<button type="button" role="tab" data-jahr="2026" aria-selected="true">2026</button>
<span class="strich" data-strich aria-hidden="true"></span>
</div>
<div class="haupt">
<div class="gross">
<p class="mono">Stunden am Entwurf</p>
<p><span class="zahl held" data-zahl="stunden" data-stellen="0"></span></p>
<div class="unter">
<p class="satz">Zwölf Köpfe, ein Tisch, <em>sehr viel Papier.</em></p>
<p class="trend" data-trend><svg viewBox="0 0 10 10" aria-hidden="true"><path d="M5 1 9 7H1Z" fill="currentColor"/></svg><span class="zahl" data-zahl="trend" data-stellen="0" data-vorzeichen></span><span> ggü. Vorjahr</span></p>
</div>
</div>
<figure class="projekt">
<div class="rahmen" data-rahmen>
<img src="bilder/marke-krume-hoch.jpg" alt="" data-bild="2024">
<img src="bilder/flakon-hoch.jpg" alt="" data-bild="2025">
<img src="bilder/typo-plakat-hoch.jpg" alt="" class="an" data-bild="2026">
</div>
<figcaption class="mono titel"><span>Projekt des Jahres</span><b data-projekt>Werkschau Licht</b></figcaption>
</figure>
</div>
<div class="kennzahlen">
<div class="kennzahl"><p class="mono">Projekte abgeschlossen</p><p class="wert"><span class="zahl" data-zahl="projekte" data-stellen="0"></span></p></div>
<div class="kennzahl"><p class="mono">Neue Marken</p><p class="wert"><span class="zahl" data-zahl="marken" data-stellen="0"></span></p></div>
<div class="kennzahl"><p class="mono">Stammkunden</p><p class="wert"><span class="zahl" data-zahl="stamm" data-stellen="1"></span><span class="einheit">%</span></p></div>
</div>
<p class="werte mono" data-werte></p>
<p class="sr" aria-live="polite" data-ansage></p>
</section>
<script>
(() => {
const html = document.documentElement;
const reduziert = () => html.classList.contains('reduziert');
// Werte aus NumberFlow (gemessen): 750 ms Spin, 350 ms Opacity, ease-out
const DREHEN = 750, BLENDEN = 350, VERSATZ = 45, ZAEHLEN = 1200;
const KURVE = [0, 0, 0.58, 1];
const JAHRE = {
2024: { stunden: 8640, projekte: 38, marken: 9, stamm: 71.5, projekt: 'Krume' },
2025: { stunden: 11215, projekte: 46, marken: 14, stamm: 78.0, projekt: 'Ocker 04' },
2026: { stunden: 9870, projekte: 52, marken: 12, stamm: 83.5, projekt: 'Werkschau Licht' },
};
const MODI = {
odometer: 'Jede Stelle dreht zur Zielziffer, Richtung folgt dem Trend',
durchlauf: 'Untere Stellen laufen durch die Zwischenwerte',
zaehlen: 'Klassisch: Text zählt vom alten zum neuen Wert',
};
// ease-out als echte Bézierkurve, damit Hochzählen dieselbe Kurve nutzt wie CSS
function bezier([x1, y1, x2, y2]) {
const A = (a, b) => 1 - 3 * b + 3 * a, B = (a, b) => 3 * b - 6 * a, C = a => 3 * a;
const f = (t, a, b) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t;
const df = (t, a, b) => 3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
return x => { let t = x; for (let i = 0; i < 8; i++) { const d = df(t, x1, x2); if (Math.abs(d) < 1e-6) break; t -= (f(t, x1, x2) - x) / d; } return f(Math.min(1, Math.max(0, t)), y1, y2); };
}
const ease = bezier(KURVE);
// ── Zählwerk: eine Zahl, Stellen nach Stellenwert (von rechts) verschlüsselt ──
const ZELLEN = 50, MITTE = 20; // Streifen 5 × 0–9, Ruhelage im mittleren Block
class Zaehlwerk {
constructor(el) {
this.el = el;
this.nk = +el.dataset.stellen || 0;
this.fmt = new Intl.NumberFormat('de-DE', { minimumFractionDigits: this.nk, maximumFractionDigits: this.nk, signDisplay: el.hasAttribute('data-vorzeichen') ? 'exceptZero' : 'auto' });
this.teile = new Map(); // Schlüssel → { el, typ, ziffer, pos }
this.wert = 0; this.modus = 'odometer';
el.setAttribute('aria-hidden', 'true');
}
tokens(v) {
// "11.215" → [{k:'s3',z:'.'}, …]; Ziffern zählen von rechts, Zeichen nach Anzahl Ziffern rechts davon
const s = this.fmt.format(v), out = []; let n = 0;
for (let i = s.length - 1; i >= 0; i--) {
const z = s[i] === '-' ? '−' : s[i]; // echtes Minuszeichen
if (/\d/.test(z)) out.unshift({ k: 'd' + n, typ: 'stelle', z: +z, platz: n++ });
else out.unshift({ k: 'z' + n + (z === '+' || z === '−' ? 'v' : ''), typ: 'zeichen', z });
}
return out;
}
leeren() { this.el.textContent = ''; this.teile.clear(); }
setzen(v, modus, sofort) {
const alt = this.wert; this.wert = v;
if (modus !== this.modus) { this.leeren(); this.modus = modus; }
if (modus === 'zaehlen') return this.zaehlen(alt, v, sofort);
this.rollen(alt, v, modus, sofort);
}
// Variante „Hochzählen“: Textwert per rAF, Intl.NumberFormat hält Trenner stabil
zaehlen(a, b, sofort) {
cancelAnimationFrame(this.uhr);
let t = this.el.querySelector('.text');
if (!t) { t = document.createElement('span'); t.className = 'text'; this.el.append(t); }
if (sofort) { t.textContent = this.fmt.format(b).replace('-', '−'); return; }
const s = performance.now(), f = 10 ** this.nk;
const schritt = jetzt => {
const p = Math.min(1, (jetzt - s) / ZAEHLEN);
t.textContent = this.fmt.format(Math.round((a + (b - a) * ease(p)) * f) / f).replace('-', '−');
if (p < 1) this.uhr = requestAnimationFrame(schritt);
};
this.uhr = requestAnimationFrame(schritt);
}
rollen(a, b, modus, sofort) {
const trend = Math.sign(b - a);
const f = 10 ** this.nk, ai = Math.round(Math.abs(a) * f), bi = Math.round(Math.abs(b) * f);
const neu = this.tokens(b), schluessel = new Set(neu.map(t => t.k));
// Entfallende Teile: Ziffer rollt auf 0, Breite und Opacity gehen weg
this.teile.forEach((t, k) => {
if (schluessel.has(k) || t.weg) return;
t.weg = true;
if (t.typ === 'stelle') this.drehen(t, 0, trend || -1, null, sofort);
this.breite(t.el, false, sofort);
t.timer = setTimeout(() => { t.el.remove(); this.teile.delete(k); }, sofort ? 0 : DREHEN + 400);
});
// Neue und bleibende Teile, von rechts nach links einsortieren
let danach = null;
for (let i = neu.length - 1; i >= 0; i--) {
const tok = neu[i];
let t = this.teile.get(tok.k);
if (t && t.weg) { clearTimeout(t.timer); t.weg = false; this.breite(t.el, true, sofort); }
if (!t) {
t = this.anlegen(tok);
this.el.insertBefore(t.el, danach);
this.teile.set(tok.k, t);
this.breite(t.el, true, sofort);
}
const verz = sofort ? 0 : (tok.platz ?? 0) * VERSATZ; // Einer zuerst
t.el.style.setProperty('--verz', verz + 'ms');
if (tok.typ === 'stelle') {
// Durchlaufen: so viele Schritte wie sich der Präfix ab dieser Stelle ändert
let schritte = null;
if (modus === 'durchlauf' && trend) {
const d = Math.abs(Math.floor(bi / 10 ** tok.platz) - Math.floor(ai / 10 ** tok.platz));
schritte = d > 20 ? 10 + (d % 10) : d;
}
this.drehen(t, tok.z, trend, schritte, sofort);
} else if (t.el.textContent !== tok.z) {
t.el.textContent = tok.z;
if (!sofort) t.el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: BLENDEN, easing: `cubic-bezier(${KURVE})` });
}
danach = t.el;
}
if (sofort && reduziert() && a !== b) this.el.animate([{ opacity: 0.25 }, { opacity: 1 }], { duration: 100, easing: 'linear' });
}
anlegen(tok) {
const el = document.createElement('span');
el.className = tok.typ;
if (tok.typ === 'zeichen') { el.textContent = tok.z; return { el, typ: 'zeichen' }; }
const s = document.createElement('span'); s.className = 'streifen';
s.innerHTML = Array.from({ length: ZELLEN }, (_, i) => `<span>${i % 10}</span>`).join('');
el.append(s);
const t = { el, s, typ: 'stelle', ziffer: 0, pos: MITTE };
this.lage(t, MITTE, false);
return t;
}
lage(t, pos, weich) {
t.s.style.transition = weich ? '' : 'none';
t.s.style.transform = `translateY(calc(var(--zh) * ${(-pos).toFixed(3)}))`;
if (!weich) { t.s.offsetHeight; t.s.style.transition = ''; }
}
// Eine Stelle drehen. Vorher auf den mittleren Block zurücksetzen: der Streifen
// ist periodisch, der Sprung um 10 Zellen ist unsichtbar, auch mitten im Lauf.
drehen(t, ziel, trend, schritte, sofort) {
if (sofort) { t.ziffer = ziel; t.pos = MITTE + ziel; return this.lage(t, t.pos, false); }
const zelle = t.s.firstElementChild.getBoundingClientRect().height || 1;
const jetzt = -new DOMMatrixReadOnly(getComputedStyle(t.s).transform).m42 / zelle;
const k = Math.round((MITTE + 4.5 - jetzt) / 10) * 10;
if (k) { this.lage(t, jetzt + k, false); t.pos += k; }
const richtung = trend || Math.sign(ziel - t.ziffer) || 1;
let n = ((ziel - t.ziffer) * richtung % 10 + 10) % 10; // kürzester Weg in Trendrichtung
if (schritte != null && schritte >= 10) n += 10; // Durchlaufen: eine volle Runde mehr
t.pos += n * richtung;
t.ziffer = ziel;
this.lage(t, t.pos, true);
}
// Breite per Transition (gemessen in px), Opacity 350 ms
breite(el, rein, sofort) {
if (sofort) { el.classList.remove('neu', 'weg'); el.style.width = rein ? '' : '0px'; return; }
if (rein) {
el.style.width = ''; el.classList.remove('weg');
const w = el.scrollWidth;
el.classList.add('neu'); el.style.width = '0px'; el.offsetWidth;
el.classList.remove('neu'); el.style.width = w + 'px';
clearTimeout(el._frei); el._frei = setTimeout(() => { el.style.width = ''; }, DREHEN + 600);
} else {
clearTimeout(el._frei);
el.style.width = el.offsetWidth + 'px'; el.offsetWidth;
el.classList.add('weg'); el.style.width = '0px';
}
}
}
const werke = {};
document.querySelectorAll('[data-zahl]').forEach(el => { werke[el.dataset.zahl] = new Zaehlwerk(el); });
const jahrKnoepfe = [...document.querySelectorAll('[data-jahr]')];
const strich = document.querySelector('[data-strich]');
const trendEl = document.querySelector('[data-trend]');
const werteEl = document.querySelector('[data-werte]');
const ansage = document.querySelector('[data-ansage]');
let modus = 'odometer', jahr = 2026;
function zeigeWerte() {
werteEl.innerHTML = reduziert()
? '<span>Reduzierte Bewegung: <b>Wert sofort, Überblenden 100 ms</b></span>'
: modus === 'zaehlen'
? `<span>Kurve <b>ease-out (0, 0, 0.58, 1)</b></span><span>Dauer <b>${ZAEHLEN} ms</b></span><span>Format <b>Intl.NumberFormat de-DE</b></span>`
: `<span>Kurve <b>ease-out (0, 0, 0.58, 1)</b></span><span>Drehen <b>${DREHEN} ms</b></span><span>Ein/aus <b>${BLENDEN} ms</b></span><span>Versatz <b>${VERSATZ} ms, Einer zuerst</b></span><span>Weg <b>1 Zeile je Ziffer</b></span>${modus === 'durchlauf' ? '<span>Plus <b>1 Runde ab 10 Schritten</b></span>' : ''}`;
}
function strichSetzen() {
const b = jahrKnoepfe.find(k => +k.dataset.jahr === jahr);
strich.style.width = b.offsetWidth + 'px';
strich.style.translate = b.offsetLeft + 'px 0';
}
function anzeigen(neuesJahr, sofort = reduziert()) {
const vor = JAHRE[jahr], d = JAHRE[neuesJahr];
jahr = neuesJahr;
jahrKnoepfe.forEach(k => k.setAttribute('aria-selected', String(+k.dataset.jahr === jahr)));
strichSetzen();
const vj = JAHRE[jahr - 1];
const diff = vj ? d.stunden - vj.stunden : 1325; // 2023: 7.315 Stunden
trendEl.classList.toggle('runter', diff < 0);
werke.stunden.setzen(d.stunden, modus, sofort);
werke.trend.setzen(diff, modus, sofort);
werke.projekte.setzen(d.projekte, modus, sofort);
werke.marken.setzen(d.marken, modus, sofort);
werke.stamm.setzen(d.stamm, modus, sofort);
document.querySelectorAll('[data-bild]').forEach(i => i.classList.toggle('an', +i.dataset.bild === jahr));
document.querySelector('[data-projekt]').textContent = d.projekt;
const f = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 });
ansage.textContent = `${jahr}: ${f.format(d.stunden)} Stunden am Entwurf, ${d.projekte} Projekte, ${d.marken} neue Marken, ${f.format(d.stamm)} Prozent Stammkunden.`;
}
function setzeModus(m) {
modus = m;
document.querySelectorAll('[data-modus]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.modus === m)));
zeigeWerte();
}
// Beim ersten Erscheinen von 0 auf den Wert rollen
function vonNull() {
Object.values(werke).forEach(w => { w.leeren(); w.wert = 0; w.modus = modus; w.setzen(0, modus, true); });
void document.body.offsetHeight;
anzeigen(jahr);
}
jahrKnoepfe.forEach(k => k.addEventListener('click', () => { pause(); if (+k.dataset.jahr !== jahr) anzeigen(+k.dataset.jahr); }));
document.querySelector('.modi').addEventListener('click', e => {
const b = e.target.closest('[data-modus]'); if (!b) return;
pause(); setzeModus(b.dataset.modus); vonNull();
});
addEventListener('resize', strichSetzen);
new MutationObserver(() => { zeigeWerte(); }).observe(html, { attributes: true, attributeFilter: ['class'] });
// Vorführung: Jahre im Wechsel, nach jeder Runde die nächste Variante
let uhr = null;
const folge = [2024, 2025, 2026, 2025], modiFolge = ['odometer', 'durchlauf', 'zaehlen'];
function vorfuehren() {
pause();
setzeModus('odometer'); jahr = 2026; vonNull();
let i = 0, runde = 0;
const takt = (ms) => {
uhr = setTimeout(() => {
if (i === folge.length) { i = 0; runde++; setzeModus(modiFolge[runde % modiFolge.length]); }
anzeigen(folge[i++]);
takt(2600);
}, ms);
};
takt(2200);
}
function pause() { clearTimeout(uhr); uhr = null; }
function neustart() { pause(); setzeModus(modus); vonNull(); }
setzeModus('odometer');
Object.values(werke).forEach(w => w.setzen(0, 'odometer', true));
document.fonts.ready.then(() => { strichSetzen(); requestAnimationFrame(() => anzeigen(jahr)); });
})();
</script>
</body>
</html>