/** * `x-figure`: a figure counts to its value — from zero when it first appears, and from what * it said before when the server changes it. * * The server renders the real value, so without JavaScript, in a test or in a screenshot the * figure is simply right. Only a figure with exactly one number in it moves — "39.6", * "1,204 km", "CHF 12.50" — and a time, a date or a range is left alone rather than counted * as if it were one quantity. The format is the target's: its decimals and its thousands * separator, so a column of counting figures keeps its width under `tabular-nums`. * * The curve decelerates and never overshoots, unlike the spatial springs, because a figure * that passes its value is briefly a lie. The duration is the spatial-slow token's, which * reduced motion sets to zero — and then the figure is written once, as it is. * * A re-render that leaves the text the same never reaches the observer, so a Livewire round * trip does not replay it; one that changes it counts from the figure on screen, even * mid-count. The observer ignores the directive's own writes by comparing against the last * thing it wrote. */ const FIGURE = /[-−]?\d{1,3}(?:[,'’]\d{3})+(?:\.\d+)?|[-−]?\d+(?:\.\d+)?/g const readFigure = (text) => { const matches = [...text.matchAll(FIGURE)] if (matches.length !== 1) { return null } const [raw] = matches[0] return { value: Number(raw.replace(/[,'’]/g, '').replace('−', '-')), decimals: raw.includes('.') ? raw.split('.')[1].length : 0, separator: raw.match(/\d([,'’])\d{3}/)?.[1] ?? '', minus: raw.startsWith('−') ? '−' : '-', before: text.slice(0, matches[0].index), after: text.slice(matches[0].index + raw.length), } } const writeFigure = (figure, value) => { let [whole, fraction] = Math.abs(value).toFixed(figure.decimals).split('.') if (figure.separator) { whole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, figure.separator) } const sign = value < 0 && Number(Math.abs(value).toFixed(figure.decimals)) !== 0 ? figure.minus : '' return figure.before + sign + whole + (fraction ? `.${fraction}` : '') + figure.after } document.addEventListener('alpine:init', () => { window.Alpine.directive('figure', (el, _, { cleanup }) => { const text = () => (el.childNodes.length === 1 && el.firstChild.nodeType === Node.TEXT_NODE ? el.firstChild : null) // In milliseconds, whichever unit arrives: a minifier turns `600ms` into `.6s`, and a // bare parseFloat would read that as 0.6ms — an instant count. const duration = () => { const token = getComputedStyle(document.documentElement).getPropertyValue('--md-sys-motion-spatial-slow-duration').trim() const value = parseFloat(token) || 0 return token.endsWith('ms') ? value : value * 1000 } let written = text()?.data ?? null let frame = null const count = (from, target) => { cancelAnimationFrame(frame) const node = text() const length = duration() if (!node || !target || length === 0 || from === target.value) { return } const started = performance.now() const step = (now) => { const progress = Math.min(1, (now - started) / length) const eased = 1 - Math.pow(1 - progress, 4) written = progress === 1 ? writeFigure(target, target.value) : writeFigure(target, from + (target.value - from) * eased) node.data = written if (progress < 1) { frame = requestAnimationFrame(step) } } written = writeFigure(target, from) node.data = written frame = requestAnimationFrame(step) } const observer = new MutationObserver(() => { const node = text() if (!node || node.data === written) { return } const target = readFigure(node.data) const shown = written === null ? null : readFigure(written) written = node.data count(shown?.value ?? 0, target) }) observer.observe(el, { characterData: true, childList: true, subtree: true }) count(0, written === null ? null : readFigure(written)) cleanup(() => { cancelAnimationFrame(frame) observer.disconnect() }) }) })