diff --git a/death-clock-core.js b/death-clock-core.js index c31d0f3..04da8e6 100644 --- a/death-clock-core.js +++ b/death-clock-core.js @@ -362,6 +362,32 @@ function getRateAtDate(date) { return RATE_SCHEDULE[0].ratePerSec; } +// Fractional annual growth applied to the token rate beyond BASE_DATE_ISO. +// Sourced from the RATE_SCHEDULE: the rate roughly doubled every 12–18 months +// between 2023 and 2026 (~100 % → 30 % CAGR as growth moderates post-AGI ramp). +// This conservative 30 % figure is used for forward projections and the live counter. +const RATE_GROWTH_PER_YEAR = 0.30; + +/** + * Return the dynamic (ever-growing) global AI inference rate for a given date. + * For historical dates at or before BASE_DATE_ISO the result is identical to + * getRateAtDate(). For future dates beyond BASE_DATE_ISO the rate is projected + * forward using continuous exponential growth at RATE_GROWTH_PER_YEAR. + * + * @param {Date} [date] - defaults to now + * @returns {number} tokens per second (always a positive integer) + */ +function getDynamicRate(date) { + const d = (date instanceof Date && !isNaN(date.getTime())) ? date : new Date(); + const baseMs = new Date(BASE_DATE_ISO).getTime(); + if (d.getTime() <= baseMs) { + return getRateAtDate(d); + } + const SECS_PER_YEAR = 365.25 * 24 * 3600; + const elapsedYears = (d.getTime() - baseMs) / 1000 / SECS_PER_YEAR; + return Math.round(TOKENS_PER_SECOND * Math.pow(1 + RATE_GROWTH_PER_YEAR, elapsedYears)); +} + /** * Calculate the collective daily environmental impact if a fraction of global users * consistently applies a token-saving tip. @@ -733,6 +759,8 @@ const DeathClockCore = { getTimeDelta, milestoneProgress, getRateAtDate, + RATE_GROWTH_PER_YEAR, + getDynamicRate, calculateTipImpact, generateEquivalences, calculatePersonalFootprint, diff --git a/src/js/00-state.js b/src/js/00-state.js index 7d8c187..f1c6ba0 100644 --- a/src/js/00-state.js +++ b/src/js/00-state.js @@ -21,6 +21,8 @@ getTimeDelta, milestoneProgress, getRateAtDate, + RATE_GROWTH_PER_YEAR, + getDynamicRate, calculateTipImpact, generateEquivalences, calculatePersonalFootprint, @@ -70,8 +72,12 @@ // ---- Helpers --------------------------------------------- function getCurrentTokens() { - const elapsed = (Date.now() - BASE_DATE_MS) / 1000; - return BASE_TOKENS + TOKENS_PER_SECOND * elapsed; + const elapsed = (Date.now() - BASE_DATE_MS) / 1000; // seconds since BASE_DATE + // Integrate the exponentially-growing rate: tokens = BASE_TOKENS + R0/k * (e^(k*t) - 1) + // where k = ln(1 + RATE_GROWTH_PER_YEAR) / SECS_PER_YEAR is the continuous growth rate constant. + const SECS_PER_YEAR = 365.25 * 24 * 3600; + const continuousGrowthRate = Math.log(1 + RATE_GROWTH_PER_YEAR) / SECS_PER_YEAR; + return BASE_TOKENS + (TOKENS_PER_SECOND / continuousGrowthRate) * (Math.exp(continuousGrowthRate * elapsed) - 1); } function numFmt(n) { diff --git a/src/js/02-counter.js b/src/js/02-counter.js index ad0b643..dbcc1c0 100644 --- a/src/js/02-counter.js +++ b/src/js/02-counter.js @@ -1,8 +1,42 @@ // ---- Counter updater ------------------------------------- + // Pre-computed reversed schedule for rate-event label lookup (avoids + // cloning and reversing on every animation frame in updateCounters). + const REVERSED_RATE_SCHEDULE = [...RATE_SCHEDULE].reverse(); + + let _lastTokenPop = 0; + + function spawnTokenPop(ratePerSec) { + const totalEl = document.getElementById('totalCounter'); + if (!totalEl) return; + const container = totalEl.closest('.counter-box'); + if (!container) return; + const el = document.createElement('div'); + el.className = 'token-pop'; + el.setAttribute('aria-hidden', 'true'); + // Slight random horizontal spread so successive pops don't overlap perfectly. + // 42–58 % keeps the pop centred over the number while adding visible variety. + const POP_LEFT_BASE = 42; // leftmost starting position (%) + const POP_LEFT_SPREAD = 16; // random spread width (%) + el.style.left = (POP_LEFT_BASE + Math.random() * POP_LEFT_SPREAD) + '%'; + el.textContent = '+' + formatTokenCountShort(ratePerSec); + container.appendChild(el); + // Clean up the element when the animation ends or is cancelled. + // A fallback timeout handles cases where neither event fires (e.g. hidden tab). + const POP_ANIM_MS = 1500; // matches animation duration in CSS + const POP_CLEANUP_BUFFER_MS = 200; + let removed = false; + const removeEl = () => { + if (!removed) { removed = true; clearTimeout(fallback); el.remove(); } + }; + el.addEventListener('animationend', removeEl, { once: true }); + el.addEventListener('animationcancel', removeEl, { once: true }); + const fallback = setTimeout(removeEl, POP_ANIM_MS + POP_CLEANUP_BUFFER_MS); + } + function updateCounters() { const now = Date.now(); const tokens = getCurrentTokens(); - const currentRate = getRateAtDate(new Date(now)); + const currentRate = getDynamicRate(new Date(now)); // Use firstArrivalTime so the counter accumulates across return visits const sessionTokens = Math.round((now - firstArrivalTime) / 1000 * currentRate); const elapsed = Math.floor((now - firstArrivalTime) / 1000); @@ -23,11 +57,22 @@ } if (rateEl) rateEl.textContent = formatTokenCount(currentRate); if (rateEventEl) { - // Show the event that triggered this rate step - const rateEntry = [...RATE_SCHEDULE].reverse().find( - (r) => now >= new Date(r.date).getTime() - ); - if (rateEntry) rateEventEl.textContent = rateEntry.event + ' · tokens/sec'; + // Beyond BASE_DATE the rate is growing — reflect that in the subtitle + const baseMs = new Date(BASE_DATE_ISO).getTime(); + if (now > baseMs) { + rateEventEl.textContent = 'and growing · tokens/sec'; + } else { + const rateEntry = REVERSED_RATE_SCHEDULE.find( + (r) => now >= new Date(r.date).getTime() + ); + if (rateEntry) rateEventEl.textContent = rateEntry.event + ' · tokens/sec'; + } + } + + // Spawn a floating "+N" pop on the total counter once per second + if (now - _lastTokenPop >= 1000) { + _lastTokenPop = now; + spawnTokenPop(currentRate); } // Impact stats diff --git a/styles/counter-milestones.css b/styles/counter-milestones.css index be397f8..451b4c7 100644 --- a/styles/counter-milestones.css +++ b/styles/counter-milestones.css @@ -15,7 +15,8 @@ padding: 1.5rem; text-align: center; position: relative; - overflow: hidden; + /* overflow: visible — required so .token-pop elements can float above the box */ + overflow: visible; transition: box-shadow 0.3s; } @@ -56,6 +57,31 @@ margin-top: 0.5rem; } +/* ---- Token pop (+N float-up animation on total counter) ---- + .counter-box uses overflow: visible (see below) so that these pops + can float above the box boundary, giving the impression that tokens + are streaming out of the counter into the page. */ +.token-pop { + position: absolute; + bottom: 55%; + font-family: "Orbitron", monospace; + font-size: 0.8rem; + font-weight: 700; + color: var(--accent); + text-shadow: 0 0 8px var(--accent-glow); + pointer-events: none; + white-space: nowrap; + transform: translateX(-50%); + animation: token-pop-float 1.5s ease-out forwards; + z-index: 20; +} + +@keyframes token-pop-float { + 0% { opacity: 0; transform: translateX(-50%) translateY(0); } + 15% { opacity: 1; } + 100% { opacity: 0; transform: translateX(-50%) translateY(-56px); } +} + /* ---- Impact Stats Strip ---- */ .impact-strip { display: grid; diff --git a/tests/death-clock.test.js b/tests/death-clock.test.js index 3ab889e..573f609 100644 --- a/tests/death-clock.test.js +++ b/tests/death-clock.test.js @@ -37,6 +37,8 @@ const { BASE_TOKENS, TOKENS_PER_SECOND, getSimulatedViewerCount, + getDynamicRate, + RATE_GROWTH_PER_YEAR, } = core; // ============================================================ @@ -573,6 +575,66 @@ describe('getRateAtDate', () => { }); }); +// ============================================================ +// getDynamicRate +// ============================================================ +describe('getDynamicRate', () => { + const BASE_DATE = new Date(core.BASE_DATE_ISO); + + test('returns TOKENS_PER_SECOND exactly at BASE_DATE_ISO', () => { + expect(getDynamicRate(BASE_DATE)).toBe(TOKENS_PER_SECOND); + }); + + test('returns more than TOKENS_PER_SECOND for a date one year after BASE_DATE', () => { + const oneYearLater = new Date(BASE_DATE.getTime() + 365.25 * 24 * 3600 * 1000); + expect(getDynamicRate(oneYearLater)).toBeGreaterThan(TOKENS_PER_SECOND); + }); + + test('grows by roughly RATE_GROWTH_PER_YEAR after one year', () => { + const oneYearLater = new Date(BASE_DATE.getTime() + 365.25 * 24 * 3600 * 1000); + const rate = getDynamicRate(oneYearLater); + const expected = TOKENS_PER_SECOND * (1 + RATE_GROWTH_PER_YEAR); + // Allow ±1 % tolerance for rounding + expect(rate).toBeGreaterThanOrEqual(expected * 0.99); + expect(rate).toBeLessThanOrEqual(expected * 1.01); + }); + + test('matches getRateAtDate for a historical date well before BASE_DATE', () => { + const historicalDate = new Date('2024-01-01'); + expect(getDynamicRate(historicalDate)).toBe(getRateAtDate(historicalDate)); + }); + + test('returns a positive number for any date', () => { + expect(getDynamicRate(new Date('2020-01-01'))).toBeGreaterThan(0); + expect(getDynamicRate(new Date('2028-01-01'))).toBeGreaterThan(0); + }); + + test('falls back to current time when no date is provided', () => { + const rate = getDynamicRate(); + expect(typeof rate).toBe('number'); + expect(rate).toBeGreaterThan(0); + }); + + test('falls back gracefully for an invalid date', () => { + const rate = getDynamicRate(new Date('not-a-date')); + expect(typeof rate).toBe('number'); + expect(rate).toBeGreaterThan(0); + }); + + test('rate at BASE_DATE equals rate two years later times inverse growth factor', () => { + const twoYearsLater = new Date(BASE_DATE.getTime() + 2 * 365.25 * 24 * 3600 * 1000); + const rateLater = getDynamicRate(twoYearsLater); + const expectedFactor = Math.pow(1 + RATE_GROWTH_PER_YEAR, 2); + expect(rateLater / TOKENS_PER_SECOND).toBeCloseTo(expectedFactor, 1); + }); + + test('RATE_GROWTH_PER_YEAR is a positive number less than 1', () => { + expect(typeof RATE_GROWTH_PER_YEAR).toBe('number'); + expect(RATE_GROWTH_PER_YEAR).toBeGreaterThan(0); + expect(RATE_GROWTH_PER_YEAR).toBeLessThan(1); + }); +}); + // ============================================================ // RATE_SCHEDULE sanity checks // ============================================================