diff --git a/README.md b/README.md index 542849a..78bae72 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ which is copied in automatically). Tip: jump straight onto a coaster with 13 coasters, each themed by its story: NVDA, AAPL, MSFT, AMZN, TSLA, META, NFLX, GME (meme), COIN & BTC-USD (crypto), PTON & ZM (boom-bust rust), SPY (scenic index). +Plus TOKENS — a demo ride built from a *non-stock* series (a startup's daily Claude +token usage), because the engine now takes any time series (see below). - **Altitude = log price.** A 7,000x run reads as cave → plains → foothills → alpine → cloud layer → stratosphere → outer space. @@ -36,6 +38,42 @@ GME (meme), COIN & BTC-USD (crypto), PTON & ZM (boom-bust rust), SPY (scenic ind - **Signs of the times**: year markers, station platforms, a minimap chart HUD showing where you are in history. +## Ride any time series + +The coaster engine is data-agnostic: a ride is just a JSON time series. Two ways in: + +1. **Drop a file in `data-pipeline/series/`** and run `npm run data:series` — it's + copied to `public/data/`, events get attached to the nearest points, and it + appears in the station menu (that's how the TOKENS demo ride is built, from + `data-pipeline/series/TOKENS.json`). +2. **Point the app at a URL**: `?data=` fetches a series JSON and boards it + directly (e.g. `?data=data/TOKENS.json&go=1`). Add `&ride=` to load it but + start a different ride. + +The generic format: + +```jsonc +{ + "id": "TOKENS", // short ride id (menu card / HUD / terrain seed) + "name": "Claude Token Usage", + "tagline": "Optional menu blurb", + "theme": "crypto", // optional, one of src/themes.js (default: classic) + "scale": "log", // optional: "log" | "linear"; auto-detected if omitted + "unit": { "prefix": "", "suffix": " tok" }, // optional value formatting + "points": [ // the only required bits: 2+ points with values + { "date": "2026-01-01", "value": 1500000 }, // date optional; "label" also accepted + { "date": "2026-01-02", "value": 1730000 } + ], + "headlines": [{ "date": "2026-01-02", "title": "...", "sentiment": "pos", "importance": 2 }], + "milestones": [{ "date": "2026-01-02", "label": "1M A DAY" }] +} +``` + +Altitude is the (log- or linear-) scaled value; drawdowns still rain embers, new +highs still get confetti, and the all-time low still carves the lava trench — +whatever the series measures. The legacy stock format (`points[].close`, +`currency`) is still accepted; everything is normalized in `src/series.js`. + ## Controls | Input | Action | diff --git a/data-pipeline/build-data.mjs b/data-pipeline/build-data.mjs index 50aa9ce..1809afa 100644 --- a/data-pipeline/build-data.mjs +++ b/data-pipeline/build-data.mjs @@ -4,13 +4,16 @@ * * 1. Fetches full price history for each configured ticker from Yahoo Finance. * 2. Merges curated headlines/milestones from data-pipeline/headlines/.json. - * 3. Writes ride-ready JSON to public/data/.json plus an index manifest. + * 3. Copies custom time-series rides from data-pipeline/series/*.json + * (generic format: { id, name, points: [{ date?, label?, value }], ... }). + * 4. Writes ride-ready JSON to public/data/.json plus an index manifest. * - * Run: npm run data + * Run: npm run data (everything) + * npm run data -- --series-only (skip Yahoo, rebuild custom series + index) * Re-run any time; it overwrites the output files. Cached raw responses live in * data-pipeline/.cache so repeated runs don't hammer the API (delete to refresh). */ -import { mkdir, readFile, writeFile, access } from 'node:fs/promises'; +import { mkdir, readFile, writeFile, access, readdir } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -18,6 +21,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); const OUT_DIR = path.join(ROOT, 'public', 'data'); const HEADLINE_DIR = path.join(__dirname, 'headlines'); +const SERIES_DIR = path.join(__dirname, 'series'); const CACHE_DIR = path.join(__dirname, '.cache'); const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'; @@ -142,78 +146,140 @@ function attachEvents(points, events) { return out; } +const valOf = p => p.value ?? p.close; + function buildStats(points) { let min = Infinity, max = -Infinity, minI = 0, maxI = 0; for (let i = 0; i < points.length; i++) { - const c = points[i].close; + const c = valOf(points[i]); if (c < min) { min = c; minI = i; } if (c > max) { max = c; maxI = i; } } - const first = points[0].close, last = points[points.length - 1].close; + const first = valOf(points[0]), last = valOf(points[points.length - 1]); // Max drawdown let peak = -Infinity, mdd = 0, mddI = 0; for (let i = 0; i < points.length; i++) { - peak = Math.max(peak, points[i].close); - const dd = 1 - points[i].close / peak; + peak = Math.max(peak, valOf(points[i])); + if (peak <= 0) continue; + const dd = 1 - valOf(points[i]) / peak; if (dd > mdd) { mdd = dd; mddI = i; } } return { min, max, minIndex: minI, maxIndex: maxI, first, last, - totalReturn: last / first - 1, - multiple: max / min, + totalReturn: first > 0 ? last / first - 1 : null, + multiple: min > 0 ? max / min : null, maxDrawdown: mdd, maxDrawdownIndex: mddI, }; } -async function main() { - await mkdir(OUT_DIR, { recursive: true }); - await mkdir(CACHE_DIR, { recursive: true }); - await mkdir(HEADLINE_DIR, { recursive: true }); - - const index = []; - for (const cfg of TICKERS) { - process.stdout.write(`Fetching ${cfg.symbol}... `); +/** + * Custom time-series rides: any JSON in data-pipeline/series/ is passed + * through with events attached to point indexes, stats computed, and gets a + * slot in the index. Points need { value } and ideally { date }. + */ +async function buildCustomSeries() { + if (!(await exists(SERIES_DIR))) return []; + const files = (await readdir(SERIES_DIR)).filter(f => f.endsWith('.json')).sort(); + const ids = []; + for (const f of files) { try { - const raw = await fetchChart(cfg); - const { points, meta } = extractPoints(raw, cfg.symbol); - if (points.length < (cfg.minPoints ?? 24)) throw new Error(`only ${points.length} points`); - const stats = buildStats(points); - const curated = await loadHeadlines(cfg.symbol); + const json = JSON.parse(await readFile(path.join(SERIES_DIR, f), 'utf8')); + const id = json.id ?? json.symbol ?? path.basename(f, '.json'); + const points = (json.points ?? []).map(p => ({ + ...p, + ...(p.t == null && p.date ? { t: Math.floor(Date.parse(p.date) / 1000) } : {}), + })); + if (points.length < 2) throw new Error('needs at least 2 points'); + const hasT = points.every(p => Number.isFinite(p.t)); + const keepIndexed = evs => (evs ?? []).filter(e => e.pointIndex != null); const ride = { - symbol: cfg.symbol, - name: curated?.companyName || cfg.name, - currency: meta.currency || 'USD', - interval: cfg.interval, - theme: curated?.theme ?? null, - tagline: curated?.tagline ?? null, - stats, + ...json, + id, points, - headlines: attachEvents(points, curated?.headlines), - milestones: attachEvents(points, curated?.milestones), + stats: buildStats(points), + headlines: hasT ? attachEvents(points, json.headlines) : keepIndexed(json.headlines), + milestones: hasT ? attachEvents(points, json.milestones) : keepIndexed(json.milestones), }; - await writeFile(path.join(OUT_DIR, `${cfg.symbol}.json`), JSON.stringify(ride)); - index.push({ - symbol: cfg.symbol, - name: ride.name, - tagline: ride.tagline, - theme: ride.theme, - points: points.length, - start: points[0].date, - end: points[points.length - 1].date, - totalReturn: stats.totalReturn, - multiple: stats.multiple, - maxDrawdown: stats.maxDrawdown, - headlines: ride.headlines.length, - }); - console.log(`ok (${points.length} pts, ${points[0].date} → ${points[points.length - 1].date}, ${ride.headlines.length} headlines)`); + await writeFile(path.join(OUT_DIR, `${id}.json`), JSON.stringify(ride)); + ids.push(id); + console.log(`Series ${id}: ok (${points.length} pts, ${ride.headlines.length} headlines)`); } catch (e) { - console.log(`FAILED: ${e.message}`); + console.log(`Series ${f}: FAILED: ${e.message}`); } - await new Promise(r => setTimeout(r, 400)); // be polite to the API + } + return ids; +} + +/** Regenerate index.json from whatever ride files exist, in menu order. */ +async function writeIndex(order) { + const index = []; + for (const id of order) { + const file = path.join(OUT_DIR, `${id}.json`); + if (!(await exists(file))) continue; + const ride = JSON.parse(await readFile(file, 'utf8')); + const points = ride.points ?? []; + const stats = ride.stats ?? buildStats(points); + index.push({ + symbol: ride.id ?? ride.symbol, + name: ride.name, + tagline: ride.tagline ?? null, + theme: ride.theme ?? null, + points: points.length, + start: points[0]?.date ?? points[0]?.label ?? null, + end: points[points.length - 1]?.date ?? points[points.length - 1]?.label ?? null, + totalReturn: stats.totalReturn ?? null, + multiple: stats.multiple ?? null, + maxDrawdown: stats.maxDrawdown, + headlines: ride.headlines?.length ?? 0, + }); } await writeFile(path.join(OUT_DIR, 'index.json'), JSON.stringify(index, null, 2)); - console.log(`\nWrote ${index.length} rides + index.json to public/data/`); + return index.length; +} + +async function main() { + const seriesOnly = process.argv.includes('--series-only'); + await mkdir(OUT_DIR, { recursive: true }); + await mkdir(CACHE_DIR, { recursive: true }); + await mkdir(HEADLINE_DIR, { recursive: true }); + await mkdir(SERIES_DIR, { recursive: true }); + + if (!seriesOnly) { + for (const cfg of TICKERS) { + process.stdout.write(`Fetching ${cfg.symbol}... `); + try { + const raw = await fetchChart(cfg); + const { points, meta } = extractPoints(raw, cfg.symbol); + if (points.length < (cfg.minPoints ?? 24)) throw new Error(`only ${points.length} points`); + const stats = buildStats(points); + const curated = await loadHeadlines(cfg.symbol); + const ride = { + symbol: cfg.symbol, + name: curated?.companyName || cfg.name, + currency: meta.currency || 'USD', + interval: cfg.interval, + theme: curated?.theme ?? null, + tagline: curated?.tagline ?? null, + stats, + points, + headlines: attachEvents(points, curated?.headlines), + milestones: attachEvents(points, curated?.milestones), + }; + await writeFile(path.join(OUT_DIR, `${cfg.symbol}.json`), JSON.stringify(ride)); + console.log(`ok (${points.length} pts, ${points[0].date} → ${points[points.length - 1].date}, ${ride.headlines.length} headlines)`); + } catch (e) { + console.log(`FAILED: ${e.message}`); + } + await new Promise(r => setTimeout(r, 400)); // be polite to the API + } + } + + const seriesIds = await buildCustomSeries(); + const tickerIds = TICKERS.map(t => t.symbol); + const order = [...tickerIds, ...seriesIds.filter(id => !tickerIds.includes(id))]; + const count = await writeIndex(order); + console.log(`\nWrote index.json with ${count} rides to public/data/`); } main().catch(e => { console.error(e); process.exit(1); }); diff --git a/data-pipeline/series/TOKENS.json b/data-pipeline/series/TOKENS.json new file mode 100644 index 0000000..c71e97a --- /dev/null +++ b/data-pipeline/series/TOKENS.json @@ -0,0 +1,1585 @@ +{ + "id": "TOKENS", + "name": "Claude Token Usage", + "theme": "crypto", + "tagline": "One startup's Claude bill, ridden day by day: agent-mode liftoff, one brutal outage, and the caching dividend.", + "unit": { + "suffix": " tok" + }, + "points": [ + { + "date": "2025-07-01", + "value": 1435653 + }, + { + "date": "2025-07-02", + "value": 1448498 + }, + { + "date": "2025-07-03", + "value": 1612605 + }, + { + "date": "2025-07-04", + "value": 1584196 + }, + { + "date": "2025-07-05", + "value": 895333 + }, + { + "date": "2025-07-06", + "value": 895235 + }, + { + "date": "2025-07-07", + "value": 1592784 + }, + { + "date": "2025-07-08", + "value": 1611160 + }, + { + "date": "2025-07-09", + "value": 1622868 + }, + { + "date": "2025-07-10", + "value": 1635751 + }, + { + "date": "2025-07-11", + "value": 1679469 + }, + { + "date": "2025-07-12", + "value": 909919 + }, + { + "date": "2025-07-13", + "value": 958025 + }, + { + "date": "2025-07-14", + "value": 1648884 + }, + { + "date": "2025-07-15", + "value": 1609973 + }, + { + "date": "2025-07-16", + "value": 1785244 + }, + { + "date": "2025-07-17", + "value": 1735516 + }, + { + "date": "2025-07-18", + "value": 1849867 + }, + { + "date": "2025-07-19", + "value": 1033420 + }, + { + "date": "2025-07-20", + "value": 952638 + }, + { + "date": "2025-07-21", + "value": 1687870 + }, + { + "date": "2025-07-22", + "value": 1704148 + }, + { + "date": "2025-07-23", + "value": 1787874 + }, + { + "date": "2025-07-24", + "value": 1755220 + }, + { + "date": "2025-07-25", + "value": 1804821 + }, + { + "date": "2025-07-26", + "value": 1109934 + }, + { + "date": "2025-07-27", + "value": 1117954 + }, + { + "date": "2025-07-28", + "value": 1782255 + }, + { + "date": "2025-07-29", + "value": 1775764 + }, + { + "date": "2025-07-30", + "value": 1958103 + }, + { + "date": "2025-07-31", + "value": 1822709 + }, + { + "date": "2025-08-01", + "value": 2011379 + }, + { + "date": "2025-08-02", + "value": 1077515 + }, + { + "date": "2025-08-03", + "value": 1195958 + }, + { + "date": "2025-08-04", + "value": 1834744 + }, + { + "date": "2025-08-05", + "value": 2018889 + }, + { + "date": "2025-08-06", + "value": 1993704 + }, + { + "date": "2025-08-07", + "value": 1993716 + }, + { + "date": "2025-08-08", + "value": 1972378 + }, + { + "date": "2025-08-09", + "value": 1102872 + }, + { + "date": "2025-08-10", + "value": 1137157 + }, + { + "date": "2025-08-11", + "value": 2013211 + }, + { + "date": "2025-08-12", + "value": 2053940 + }, + { + "date": "2025-08-13", + "value": 2153651 + }, + { + "date": "2025-08-14", + "value": 2075749 + }, + { + "date": "2025-08-15", + "value": 2188707 + }, + { + "date": "2025-08-16", + "value": 1253860 + }, + { + "date": "2025-08-17", + "value": 1269551 + }, + { + "date": "2025-08-18", + "value": 2343870 + }, + { + "date": "2025-08-19", + "value": 2397652 + }, + { + "date": "2025-08-20", + "value": 2318906 + }, + { + "date": "2025-08-21", + "value": 2104339 + }, + { + "date": "2025-08-22", + "value": 2163614 + }, + { + "date": "2025-08-23", + "value": 1286253 + }, + { + "date": "2025-08-24", + "value": 1267885 + }, + { + "date": "2025-08-25", + "value": 2204951 + }, + { + "date": "2025-08-26", + "value": 2271454 + }, + { + "date": "2025-08-27", + "value": 2315954 + }, + { + "date": "2025-08-28", + "value": 2296166 + }, + { + "date": "2025-08-29", + "value": 2286462 + }, + { + "date": "2025-08-30", + "value": 1337162 + }, + { + "date": "2025-08-31", + "value": 1479122 + }, + { + "date": "2025-09-01", + "value": 2493289 + }, + { + "date": "2025-09-02", + "value": 2507525 + }, + { + "date": "2025-09-03", + "value": 2557551 + }, + { + "date": "2025-09-04", + "value": 2566527 + }, + { + "date": "2025-09-05", + "value": 2375490 + }, + { + "date": "2025-09-06", + "value": 1418288 + }, + { + "date": "2025-09-07", + "value": 1450976 + }, + { + "date": "2025-09-08", + "value": 2760063 + }, + { + "date": "2025-09-09", + "value": 2524577 + }, + { + "date": "2025-09-10", + "value": 2569498 + }, + { + "date": "2025-09-11", + "value": 2694557 + }, + { + "date": "2025-09-12", + "value": 2750365 + }, + { + "date": "2025-09-13", + "value": 1606513 + }, + { + "date": "2025-09-14", + "value": 1657453 + }, + { + "date": "2025-09-15", + "value": 2927567 + }, + { + "date": "2025-09-16", + "value": 2631830 + }, + { + "date": "2025-09-17", + "value": 2980691 + }, + { + "date": "2025-09-18", + "value": 2903016 + }, + { + "date": "2025-09-19", + "value": 2774915 + }, + { + "date": "2025-09-20", + "value": 1644272 + }, + { + "date": "2025-09-21", + "value": 1760404 + }, + { + "date": "2025-09-22", + "value": 3086557 + }, + { + "date": "2025-09-23", + "value": 3173777 + }, + { + "date": "2025-09-24", + "value": 3044670 + }, + { + "date": "2025-09-25", + "value": 3039894 + }, + { + "date": "2025-09-26", + "value": 3017119 + }, + { + "date": "2025-09-27", + "value": 1724509 + }, + { + "date": "2025-09-28", + "value": 1643959 + }, + { + "date": "2025-09-29", + "value": 3036127 + }, + { + "date": "2025-09-30", + "value": 3189247 + }, + { + "date": "2025-10-01", + "value": 3953449 + }, + { + "date": "2025-10-02", + "value": 4307105 + }, + { + "date": "2025-10-03", + "value": 5357667 + }, + { + "date": "2025-10-04", + "value": 3448775 + }, + { + "date": "2025-10-05", + "value": 4101730 + }, + { + "date": "2025-10-06", + "value": 8853883 + }, + { + "date": "2025-10-07", + "value": 11090559 + }, + { + "date": "2025-10-08", + "value": 11438585 + }, + { + "date": "2025-10-09", + "value": 10124034 + }, + { + "date": "2025-10-10", + "value": 10235159 + }, + { + "date": "2025-10-11", + "value": 6505759 + }, + { + "date": "2025-10-12", + "value": 6796151 + }, + { + "date": "2025-10-13", + "value": 11691133 + }, + { + "date": "2025-10-14", + "value": 12140161 + }, + { + "date": "2025-10-15", + "value": 11727311 + }, + { + "date": "2025-10-16", + "value": 12563121 + }, + { + "date": "2025-10-17", + "value": 12294918 + }, + { + "date": "2025-10-18", + "value": 7181922 + }, + { + "date": "2025-10-19", + "value": 7612246 + }, + { + "date": "2025-10-20", + "value": 12151535 + }, + { + "date": "2025-10-21", + "value": 13655050 + }, + { + "date": "2025-10-22", + "value": 12437852 + }, + { + "date": "2025-10-23", + "value": 12780858 + }, + { + "date": "2025-10-24", + "value": 13636513 + }, + { + "date": "2025-10-25", + "value": 8389083 + }, + { + "date": "2025-10-26", + "value": 8409135 + }, + { + "date": "2025-10-27", + "value": 14686143 + }, + { + "date": "2025-10-28", + "value": 13931229 + }, + { + "date": "2025-10-29", + "value": 15421301 + }, + { + "date": "2025-10-30", + "value": 15118773 + }, + { + "date": "2025-10-31", + "value": 16117130 + }, + { + "date": "2025-11-01", + "value": 8743424 + }, + { + "date": "2025-11-02", + "value": 9108701 + }, + { + "date": "2025-11-03", + "value": 16001877 + }, + { + "date": "2025-11-04", + "value": 17461005 + }, + { + "date": "2025-11-05", + "value": 15682286 + }, + { + "date": "2025-11-06", + "value": 16214209 + }, + { + "date": "2025-11-07", + "value": 32493304 + }, + { + "date": "2025-11-08", + "value": 10490014 + }, + { + "date": "2025-11-09", + "value": 10982622 + }, + { + "date": "2025-11-10", + "value": 17796864 + }, + { + "date": "2025-11-11", + "value": 19417716 + }, + { + "date": "2025-11-12", + "value": 18274615 + }, + { + "date": "2025-11-13", + "value": 18613943 + }, + { + "date": "2025-11-14", + "value": 20522692 + }, + { + "date": "2025-11-15", + "value": 11922614 + }, + { + "date": "2025-11-16", + "value": 11308851 + }, + { + "date": "2025-11-17", + "value": 18888233 + }, + { + "date": "2025-11-18", + "value": 20584933 + }, + { + "date": "2025-11-19", + "value": 19582323 + }, + { + "date": "2025-11-20", + "value": 20506553 + }, + { + "date": "2025-11-21", + "value": 22229663 + }, + { + "date": "2025-11-22", + "value": 13010973 + }, + { + "date": "2025-11-23", + "value": 12889289 + }, + { + "date": "2025-11-24", + "value": 21118257 + }, + { + "date": "2025-11-25", + "value": 23284232 + }, + { + "date": "2025-11-26", + "value": 22639092 + }, + { + "date": "2025-11-27", + "value": 21910453 + }, + { + "date": "2025-11-28", + "value": 25273390 + }, + { + "date": "2025-11-29", + "value": 13234740 + }, + { + "date": "2025-11-30", + "value": 15044601 + }, + { + "date": "2025-12-01", + "value": 24423654 + }, + { + "date": "2025-12-02", + "value": 27091637 + }, + { + "date": "2025-12-03", + "value": 24427153 + }, + { + "date": "2025-12-04", + "value": 25304972 + }, + { + "date": "2025-12-05", + "value": 25729097 + }, + { + "date": "2025-12-06", + "value": 16211775 + }, + { + "date": "2025-12-07", + "value": 15730994 + }, + { + "date": "2025-12-08", + "value": 29725924 + }, + { + "date": "2025-12-09", + "value": 27703590 + }, + { + "date": "2025-12-10", + "value": 30686202 + }, + { + "date": "2025-12-11", + "value": 31232995 + }, + { + "date": "2025-12-12", + "value": 28804601 + }, + { + "date": "2025-12-13", + "value": 16670601 + }, + { + "date": "2025-12-14", + "value": 18775041 + }, + { + "date": "2025-12-15", + "value": 33034021 + }, + { + "date": "2025-12-16", + "value": 33047064 + }, + { + "date": "2025-12-17", + "value": 33198351 + }, + { + "date": "2025-12-18", + "value": 33108179 + }, + { + "date": "2025-12-19", + "value": 34490732 + }, + { + "date": "2025-12-20", + "value": 17059932 + }, + { + "date": "2025-12-21", + "value": 18240333 + }, + { + "date": "2025-12-22", + "value": 29795388 + }, + { + "date": "2025-12-23", + "value": 24832320 + }, + { + "date": "2025-12-24", + "value": 23911675 + }, + { + "date": "2025-12-25", + "value": 21400308 + }, + { + "date": "2025-12-26", + "value": 21736511 + }, + { + "date": "2025-12-27", + "value": 11051315 + }, + { + "date": "2025-12-28", + "value": 12399554 + }, + { + "date": "2025-12-29", + "value": 21153054 + }, + { + "date": "2025-12-30", + "value": 23442717 + }, + { + "date": "2025-12-31", + "value": 21712890 + }, + { + "date": "2026-01-01", + "value": 22811440 + }, + { + "date": "2026-01-02", + "value": 21314689 + }, + { + "date": "2026-01-03", + "value": 14480137 + }, + { + "date": "2026-01-04", + "value": 13248222 + }, + { + "date": "2026-01-05", + "value": 24283415 + }, + { + "date": "2026-01-06", + "value": 24420829 + }, + { + "date": "2026-01-07", + "value": 26194854 + }, + { + "date": "2026-01-08", + "value": 24152773 + }, + { + "date": "2026-01-09", + "value": 25564563 + }, + { + "date": "2026-01-10", + "value": 14913467 + }, + { + "date": "2026-01-11", + "value": 15549043 + }, + { + "date": "2026-01-12", + "value": 32241808 + }, + { + "date": "2026-01-13", + "value": 38436973 + }, + { + "date": "2026-01-14", + "value": 40440498 + }, + { + "date": "2026-01-15", + "value": 39530530 + }, + { + "date": "2026-01-16", + "value": 40686751 + }, + { + "date": "2026-01-17", + "value": 25765492 + }, + { + "date": "2026-01-18", + "value": 24694088 + }, + { + "date": "2026-01-19", + "value": 45690949 + }, + { + "date": "2026-01-20", + "value": 41707976 + }, + { + "date": "2026-01-21", + "value": 43490553 + }, + { + "date": "2026-01-22", + "value": 46097816 + }, + { + "date": "2026-01-23", + "value": 43500943 + }, + { + "date": "2026-01-24", + "value": 26536265 + }, + { + "date": "2026-01-25", + "value": 26886499 + }, + { + "date": "2026-01-26", + "value": 49108920 + }, + { + "date": "2026-01-27", + "value": 46085042 + }, + { + "date": "2026-01-28", + "value": 46563023 + }, + { + "date": "2026-01-29", + "value": 50382978 + }, + { + "date": "2026-01-30", + "value": 52407172 + }, + { + "date": "2026-01-31", + "value": 29235724 + }, + { + "date": "2026-02-01", + "value": 30039853 + }, + { + "date": "2026-02-02", + "value": 52894731 + }, + { + "date": "2026-02-03", + "value": 48702156 + }, + { + "date": "2026-02-04", + "value": 50043191 + }, + { + "date": "2026-02-05", + "value": 54881201 + }, + { + "date": "2026-02-06", + "value": 52453879 + }, + { + "date": "2026-02-07", + "value": 30052365 + }, + { + "date": "2026-02-08", + "value": 33355479 + }, + { + "date": "2026-02-09", + "value": 55194566 + }, + { + "date": "2026-02-10", + "value": 2744864 + }, + { + "date": "2026-02-11", + "value": 2775057 + }, + { + "date": "2026-02-12", + "value": 57004272 + }, + { + "date": "2026-02-13", + "value": 58092519 + }, + { + "date": "2026-02-14", + "value": 35344604 + }, + { + "date": "2026-02-15", + "value": 33077394 + }, + { + "date": "2026-02-16", + "value": 57355449 + }, + { + "date": "2026-02-17", + "value": 63472375 + }, + { + "date": "2026-02-18", + "value": 57222806 + }, + { + "date": "2026-02-19", + "value": 62945431 + }, + { + "date": "2026-02-20", + "value": 62688361 + }, + { + "date": "2026-02-21", + "value": 35341484 + }, + { + "date": "2026-02-22", + "value": 36254614 + }, + { + "date": "2026-02-23", + "value": 66548845 + }, + { + "date": "2026-02-24", + "value": 59376819 + }, + { + "date": "2026-02-25", + "value": 60731622 + }, + { + "date": "2026-02-26", + "value": 69450577 + }, + { + "date": "2026-02-27", + "value": 71321080 + }, + { + "date": "2026-02-28", + "value": 39427293 + }, + { + "date": "2026-03-01", + "value": 41885367 + }, + { + "date": "2026-03-02", + "value": 67366025 + }, + { + "date": "2026-03-03", + "value": 74328696 + }, + { + "date": "2026-03-04", + "value": 66073662 + }, + { + "date": "2026-03-05", + "value": 68754938 + }, + { + "date": "2026-03-06", + "value": 66812100 + }, + { + "date": "2026-03-07", + "value": 41705168 + }, + { + "date": "2026-03-08", + "value": 42623584 + }, + { + "date": "2026-03-09", + "value": 72907214 + }, + { + "date": "2026-03-10", + "value": 80275643 + }, + { + "date": "2026-03-11", + "value": 74369253 + }, + { + "date": "2026-03-12", + "value": 79690285 + }, + { + "date": "2026-03-13", + "value": 79574919 + }, + { + "date": "2026-03-14", + "value": 43565537 + }, + { + "date": "2026-03-15", + "value": 44664631 + }, + { + "date": "2026-03-16", + "value": 74446670 + }, + { + "date": "2026-03-17", + "value": 75445551 + }, + { + "date": "2026-03-18", + "value": 75137032 + }, + { + "date": "2026-03-19", + "value": 83077369 + }, + { + "date": "2026-03-20", + "value": 84697106 + }, + { + "date": "2026-03-21", + "value": 48237285 + }, + { + "date": "2026-03-22", + "value": 47686595 + }, + { + "date": "2026-03-23", + "value": 82960393 + }, + { + "date": "2026-03-24", + "value": 83677235 + }, + { + "date": "2026-03-25", + "value": 89583807 + }, + { + "date": "2026-03-26", + "value": 83944196 + }, + { + "date": "2026-03-27", + "value": 94360247 + }, + { + "date": "2026-03-28", + "value": 53946565 + }, + { + "date": "2026-03-29", + "value": 53276748 + }, + { + "date": "2026-03-30", + "value": 86034428 + }, + { + "date": "2026-03-31", + "value": 92930343 + }, + { + "date": "2026-04-01", + "value": 96548207 + }, + { + "date": "2026-04-02", + "value": 147926321 + }, + { + "date": "2026-04-03", + "value": 94046622 + }, + { + "date": "2026-04-04", + "value": 61373333 + }, + { + "date": "2026-04-05", + "value": 56987037 + }, + { + "date": "2026-04-06", + "value": 99909586 + }, + { + "date": "2026-04-07", + "value": 95628311 + }, + { + "date": "2026-04-08", + "value": 102468902 + }, + { + "date": "2026-04-09", + "value": 109416309 + }, + { + "date": "2026-04-10", + "value": 103866779 + }, + { + "date": "2026-04-11", + "value": 58871449 + }, + { + "date": "2026-04-12", + "value": 62963750 + }, + { + "date": "2026-04-13", + "value": 108013157 + }, + { + "date": "2026-04-14", + "value": 115092751 + }, + { + "date": "2026-04-15", + "value": 105625792 + }, + { + "date": "2026-04-16", + "value": 113878537 + }, + { + "date": "2026-04-17", + "value": 110274744 + }, + { + "date": "2026-04-18", + "value": 62631803 + }, + { + "date": "2026-04-19", + "value": 67338252 + }, + { + "date": "2026-04-20", + "value": 122467122 + }, + { + "date": "2026-04-21", + "value": 113322563 + }, + { + "date": "2026-04-22", + "value": 128915685 + }, + { + "date": "2026-04-23", + "value": 124734711 + }, + { + "date": "2026-04-24", + "value": 114673815 + }, + { + "date": "2026-04-25", + "value": 73152260 + }, + { + "date": "2026-04-26", + "value": 75640605 + }, + { + "date": "2026-04-27", + "value": 124079821 + }, + { + "date": "2026-04-28", + "value": 120088456 + }, + { + "date": "2026-04-29", + "value": 119762994 + }, + { + "date": "2026-04-30", + "value": 129714721 + }, + { + "date": "2026-05-01", + "value": 126031740 + }, + { + "date": "2026-05-02", + "value": 80965626 + }, + { + "date": "2026-05-03", + "value": 72338854 + }, + { + "date": "2026-05-04", + "value": 134706071 + }, + { + "date": "2026-05-05", + "value": 127648077 + }, + { + "date": "2026-05-06", + "value": 137811402 + }, + { + "date": "2026-05-07", + "value": 140145987 + }, + { + "date": "2026-05-08", + "value": 138945379 + }, + { + "date": "2026-05-09", + "value": 88047474 + }, + { + "date": "2026-05-10", + "value": 89688824 + }, + { + "date": "2026-05-11", + "value": 153241987 + }, + { + "date": "2026-05-12", + "value": 157383326 + }, + { + "date": "2026-05-13", + "value": 149998007 + }, + { + "date": "2026-05-14", + "value": 148743420 + }, + { + "date": "2026-05-15", + "value": 154224853 + }, + { + "date": "2026-05-16", + "value": 85550108 + }, + { + "date": "2026-05-17", + "value": 86160076 + }, + { + "date": "2026-05-18", + "value": 167199705 + }, + { + "date": "2026-05-19", + "value": 158123410 + }, + { + "date": "2026-05-20", + "value": 159681910 + }, + { + "date": "2026-05-21", + "value": 166028572 + }, + { + "date": "2026-05-22", + "value": 148269755 + }, + { + "date": "2026-05-23", + "value": 84175467 + }, + { + "date": "2026-05-24", + "value": 77451597 + }, + { + "date": "2026-05-25", + "value": 136517112 + }, + { + "date": "2026-05-26", + "value": 126286619 + }, + { + "date": "2026-05-27", + "value": 130825223 + }, + { + "date": "2026-05-28", + "value": 139289095 + }, + { + "date": "2026-05-29", + "value": 123476790 + }, + { + "date": "2026-05-30", + "value": 66573289 + }, + { + "date": "2026-05-31", + "value": 73887181 + }, + { + "date": "2026-06-01", + "value": 119460115 + }, + { + "date": "2026-06-02", + "value": 115251673 + }, + { + "date": "2026-06-03", + "value": 107439521 + }, + { + "date": "2026-06-04", + "value": 101865254 + }, + { + "date": "2026-06-05", + "value": 110088323 + }, + { + "date": "2026-06-06", + "value": 59281547 + }, + { + "date": "2026-06-07", + "value": 52428970 + }, + { + "date": "2026-06-08", + "value": 89706236 + }, + { + "date": "2026-06-09", + "value": 88617801 + }, + { + "date": "2026-06-10", + "value": 99176643 + }, + { + "date": "2026-06-11", + "value": 101752957 + }, + { + "date": "2026-06-12", + "value": 99027329 + }, + { + "date": "2026-06-13", + "value": 54933621 + }, + { + "date": "2026-06-14", + "value": 60077488 + }, + { + "date": "2026-06-15", + "value": 100282096 + }, + { + "date": "2026-06-16", + "value": 100925581 + }, + { + "date": "2026-06-17", + "value": 102186646 + }, + { + "date": "2026-06-18", + "value": 101035208 + }, + { + "date": "2026-06-19", + "value": 98811515 + }, + { + "date": "2026-06-20", + "value": 61494675 + }, + { + "date": "2026-06-21", + "value": 65096806 + }, + { + "date": "2026-06-22", + "value": 100987445 + }, + { + "date": "2026-06-23", + "value": 105484973 + }, + { + "date": "2026-06-24", + "value": 110766026 + }, + { + "date": "2026-06-25", + "value": 101782417 + }, + { + "date": "2026-06-26", + "value": 108481643 + }, + { + "date": "2026-06-27", + "value": 60500980 + }, + { + "date": "2026-06-28", + "value": 62824433 + }, + { + "date": "2026-06-29", + "value": 122150829 + }, + { + "date": "2026-06-30", + "value": 123080848 + }, + { + "date": "2026-07-01", + "value": 112763955 + }, + { + "date": "2026-07-02", + "value": 115685270 + }, + { + "date": "2026-07-03", + "value": 117606258 + }, + { + "date": "2026-07-04", + "value": 66304851 + }, + { + "date": "2026-07-05", + "value": 66347691 + }, + { + "date": "2026-07-06", + "value": 118421769 + }, + { + "date": "2026-07-07", + "value": 118904723 + }, + { + "date": "2026-07-08", + "value": 127664208 + } + ], + "headlines": [ + { + "date": "2025-07-02", + "title": "First Claude API key created — the bill is $12", + "sentiment": "pos", + "importance": 1 + }, + { + "date": "2025-08-15", + "title": "Eval harness lands; prompt experiments triple", + "sentiment": "neutral", + "importance": 1 + }, + { + "date": "2025-10-01", + "title": "AGENT MODE SHIPS — token burn triples overnight", + "sentiment": "pos", + "importance": 3 + }, + { + "date": "2025-11-07", + "title": "Hacker News front page: signups 10x, tokens follow", + "sentiment": "pos", + "importance": 2 + }, + { + "date": "2025-12-24", + "title": "Holiday lull: even the agents take Christmas off", + "sentiment": "neutral", + "importance": 1 + }, + { + "date": "2026-01-12", + "title": "First enterprise contract signed — usage steps up", + "sentiment": "pos", + "importance": 2 + }, + { + "date": "2026-02-10", + "title": "REGION OUTAGE: token usage falls 95% for two days", + "sentiment": "neg", + "importance": 3 + }, + { + "date": "2026-02-13", + "title": "Full recovery — retry queues drain overnight", + "sentiment": "pos", + "importance": 1 + }, + { + "date": "2026-04-02", + "title": "Batch pipeline migrates to Claude — nightly spikes begin", + "sentiment": "neutral", + "importance": 1 + }, + { + "date": "2026-06-01", + "title": "Prompt caching rollout cuts token spend 30%", + "sentiment": "pos", + "importance": 2 + }, + { + "date": "2026-06-20", + "title": "Growth resumes: caching savings reinvested in agents", + "sentiment": "pos", + "importance": 1 + } + ], + "milestones": [ + { + "date": "2025-10-08", + "label": "10M TOKENS A DAY" + }, + { + "date": "2026-01-15", + "label": "50M TOKENS A DAY" + }, + { + "date": "2026-05-10", + "label": "100M TOKENS A DAY" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index f0d5e1d..ac17ef2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "data": "node data-pipeline/build-data.mjs" + "data": "node data-pipeline/build-data.mjs", + "data:series": "node data-pipeline/build-data.mjs --series-only" }, "dependencies": { "three": "^0.165.0" diff --git a/public/data/TOKENS.json b/public/data/TOKENS.json new file mode 100644 index 0000000..7d08fa0 --- /dev/null +++ b/public/data/TOKENS.json @@ -0,0 +1 @@ +{"id":"TOKENS","name":"Claude Token Usage","theme":"crypto","tagline":"One startup's Claude bill, ridden day by day: agent-mode liftoff, one brutal outage, and the caching dividend.","unit":{"suffix":" tok"},"points":[{"date":"2025-07-01","value":1435653,"t":1751328000},{"date":"2025-07-02","value":1448498,"t":1751414400},{"date":"2025-07-03","value":1612605,"t":1751500800},{"date":"2025-07-04","value":1584196,"t":1751587200},{"date":"2025-07-05","value":895333,"t":1751673600},{"date":"2025-07-06","value":895235,"t":1751760000},{"date":"2025-07-07","value":1592784,"t":1751846400},{"date":"2025-07-08","value":1611160,"t":1751932800},{"date":"2025-07-09","value":1622868,"t":1752019200},{"date":"2025-07-10","value":1635751,"t":1752105600},{"date":"2025-07-11","value":1679469,"t":1752192000},{"date":"2025-07-12","value":909919,"t":1752278400},{"date":"2025-07-13","value":958025,"t":1752364800},{"date":"2025-07-14","value":1648884,"t":1752451200},{"date":"2025-07-15","value":1609973,"t":1752537600},{"date":"2025-07-16","value":1785244,"t":1752624000},{"date":"2025-07-17","value":1735516,"t":1752710400},{"date":"2025-07-18","value":1849867,"t":1752796800},{"date":"2025-07-19","value":1033420,"t":1752883200},{"date":"2025-07-20","value":952638,"t":1752969600},{"date":"2025-07-21","value":1687870,"t":1753056000},{"date":"2025-07-22","value":1704148,"t":1753142400},{"date":"2025-07-23","value":1787874,"t":1753228800},{"date":"2025-07-24","value":1755220,"t":1753315200},{"date":"2025-07-25","value":1804821,"t":1753401600},{"date":"2025-07-26","value":1109934,"t":1753488000},{"date":"2025-07-27","value":1117954,"t":1753574400},{"date":"2025-07-28","value":1782255,"t":1753660800},{"date":"2025-07-29","value":1775764,"t":1753747200},{"date":"2025-07-30","value":1958103,"t":1753833600},{"date":"2025-07-31","value":1822709,"t":1753920000},{"date":"2025-08-01","value":2011379,"t":1754006400},{"date":"2025-08-02","value":1077515,"t":1754092800},{"date":"2025-08-03","value":1195958,"t":1754179200},{"date":"2025-08-04","value":1834744,"t":1754265600},{"date":"2025-08-05","value":2018889,"t":1754352000},{"date":"2025-08-06","value":1993704,"t":1754438400},{"date":"2025-08-07","value":1993716,"t":1754524800},{"date":"2025-08-08","value":1972378,"t":1754611200},{"date":"2025-08-09","value":1102872,"t":1754697600},{"date":"2025-08-10","value":1137157,"t":1754784000},{"date":"2025-08-11","value":2013211,"t":1754870400},{"date":"2025-08-12","value":2053940,"t":1754956800},{"date":"2025-08-13","value":2153651,"t":1755043200},{"date":"2025-08-14","value":2075749,"t":1755129600},{"date":"2025-08-15","value":2188707,"t":1755216000},{"date":"2025-08-16","value":1253860,"t":1755302400},{"date":"2025-08-17","value":1269551,"t":1755388800},{"date":"2025-08-18","value":2343870,"t":1755475200},{"date":"2025-08-19","value":2397652,"t":1755561600},{"date":"2025-08-20","value":2318906,"t":1755648000},{"date":"2025-08-21","value":2104339,"t":1755734400},{"date":"2025-08-22","value":2163614,"t":1755820800},{"date":"2025-08-23","value":1286253,"t":1755907200},{"date":"2025-08-24","value":1267885,"t":1755993600},{"date":"2025-08-25","value":2204951,"t":1756080000},{"date":"2025-08-26","value":2271454,"t":1756166400},{"date":"2025-08-27","value":2315954,"t":1756252800},{"date":"2025-08-28","value":2296166,"t":1756339200},{"date":"2025-08-29","value":2286462,"t":1756425600},{"date":"2025-08-30","value":1337162,"t":1756512000},{"date":"2025-08-31","value":1479122,"t":1756598400},{"date":"2025-09-01","value":2493289,"t":1756684800},{"date":"2025-09-02","value":2507525,"t":1756771200},{"date":"2025-09-03","value":2557551,"t":1756857600},{"date":"2025-09-04","value":2566527,"t":1756944000},{"date":"2025-09-05","value":2375490,"t":1757030400},{"date":"2025-09-06","value":1418288,"t":1757116800},{"date":"2025-09-07","value":1450976,"t":1757203200},{"date":"2025-09-08","value":2760063,"t":1757289600},{"date":"2025-09-09","value":2524577,"t":1757376000},{"date":"2025-09-10","value":2569498,"t":1757462400},{"date":"2025-09-11","value":2694557,"t":1757548800},{"date":"2025-09-12","value":2750365,"t":1757635200},{"date":"2025-09-13","value":1606513,"t":1757721600},{"date":"2025-09-14","value":1657453,"t":1757808000},{"date":"2025-09-15","value":2927567,"t":1757894400},{"date":"2025-09-16","value":2631830,"t":1757980800},{"date":"2025-09-17","value":2980691,"t":1758067200},{"date":"2025-09-18","value":2903016,"t":1758153600},{"date":"2025-09-19","value":2774915,"t":1758240000},{"date":"2025-09-20","value":1644272,"t":1758326400},{"date":"2025-09-21","value":1760404,"t":1758412800},{"date":"2025-09-22","value":3086557,"t":1758499200},{"date":"2025-09-23","value":3173777,"t":1758585600},{"date":"2025-09-24","value":3044670,"t":1758672000},{"date":"2025-09-25","value":3039894,"t":1758758400},{"date":"2025-09-26","value":3017119,"t":1758844800},{"date":"2025-09-27","value":1724509,"t":1758931200},{"date":"2025-09-28","value":1643959,"t":1759017600},{"date":"2025-09-29","value":3036127,"t":1759104000},{"date":"2025-09-30","value":3189247,"t":1759190400},{"date":"2025-10-01","value":3953449,"t":1759276800},{"date":"2025-10-02","value":4307105,"t":1759363200},{"date":"2025-10-03","value":5357667,"t":1759449600},{"date":"2025-10-04","value":3448775,"t":1759536000},{"date":"2025-10-05","value":4101730,"t":1759622400},{"date":"2025-10-06","value":8853883,"t":1759708800},{"date":"2025-10-07","value":11090559,"t":1759795200},{"date":"2025-10-08","value":11438585,"t":1759881600},{"date":"2025-10-09","value":10124034,"t":1759968000},{"date":"2025-10-10","value":10235159,"t":1760054400},{"date":"2025-10-11","value":6505759,"t":1760140800},{"date":"2025-10-12","value":6796151,"t":1760227200},{"date":"2025-10-13","value":11691133,"t":1760313600},{"date":"2025-10-14","value":12140161,"t":1760400000},{"date":"2025-10-15","value":11727311,"t":1760486400},{"date":"2025-10-16","value":12563121,"t":1760572800},{"date":"2025-10-17","value":12294918,"t":1760659200},{"date":"2025-10-18","value":7181922,"t":1760745600},{"date":"2025-10-19","value":7612246,"t":1760832000},{"date":"2025-10-20","value":12151535,"t":1760918400},{"date":"2025-10-21","value":13655050,"t":1761004800},{"date":"2025-10-22","value":12437852,"t":1761091200},{"date":"2025-10-23","value":12780858,"t":1761177600},{"date":"2025-10-24","value":13636513,"t":1761264000},{"date":"2025-10-25","value":8389083,"t":1761350400},{"date":"2025-10-26","value":8409135,"t":1761436800},{"date":"2025-10-27","value":14686143,"t":1761523200},{"date":"2025-10-28","value":13931229,"t":1761609600},{"date":"2025-10-29","value":15421301,"t":1761696000},{"date":"2025-10-30","value":15118773,"t":1761782400},{"date":"2025-10-31","value":16117130,"t":1761868800},{"date":"2025-11-01","value":8743424,"t":1761955200},{"date":"2025-11-02","value":9108701,"t":1762041600},{"date":"2025-11-03","value":16001877,"t":1762128000},{"date":"2025-11-04","value":17461005,"t":1762214400},{"date":"2025-11-05","value":15682286,"t":1762300800},{"date":"2025-11-06","value":16214209,"t":1762387200},{"date":"2025-11-07","value":32493304,"t":1762473600},{"date":"2025-11-08","value":10490014,"t":1762560000},{"date":"2025-11-09","value":10982622,"t":1762646400},{"date":"2025-11-10","value":17796864,"t":1762732800},{"date":"2025-11-11","value":19417716,"t":1762819200},{"date":"2025-11-12","value":18274615,"t":1762905600},{"date":"2025-11-13","value":18613943,"t":1762992000},{"date":"2025-11-14","value":20522692,"t":1763078400},{"date":"2025-11-15","value":11922614,"t":1763164800},{"date":"2025-11-16","value":11308851,"t":1763251200},{"date":"2025-11-17","value":18888233,"t":1763337600},{"date":"2025-11-18","value":20584933,"t":1763424000},{"date":"2025-11-19","value":19582323,"t":1763510400},{"date":"2025-11-20","value":20506553,"t":1763596800},{"date":"2025-11-21","value":22229663,"t":1763683200},{"date":"2025-11-22","value":13010973,"t":1763769600},{"date":"2025-11-23","value":12889289,"t":1763856000},{"date":"2025-11-24","value":21118257,"t":1763942400},{"date":"2025-11-25","value":23284232,"t":1764028800},{"date":"2025-11-26","value":22639092,"t":1764115200},{"date":"2025-11-27","value":21910453,"t":1764201600},{"date":"2025-11-28","value":25273390,"t":1764288000},{"date":"2025-11-29","value":13234740,"t":1764374400},{"date":"2025-11-30","value":15044601,"t":1764460800},{"date":"2025-12-01","value":24423654,"t":1764547200},{"date":"2025-12-02","value":27091637,"t":1764633600},{"date":"2025-12-03","value":24427153,"t":1764720000},{"date":"2025-12-04","value":25304972,"t":1764806400},{"date":"2025-12-05","value":25729097,"t":1764892800},{"date":"2025-12-06","value":16211775,"t":1764979200},{"date":"2025-12-07","value":15730994,"t":1765065600},{"date":"2025-12-08","value":29725924,"t":1765152000},{"date":"2025-12-09","value":27703590,"t":1765238400},{"date":"2025-12-10","value":30686202,"t":1765324800},{"date":"2025-12-11","value":31232995,"t":1765411200},{"date":"2025-12-12","value":28804601,"t":1765497600},{"date":"2025-12-13","value":16670601,"t":1765584000},{"date":"2025-12-14","value":18775041,"t":1765670400},{"date":"2025-12-15","value":33034021,"t":1765756800},{"date":"2025-12-16","value":33047064,"t":1765843200},{"date":"2025-12-17","value":33198351,"t":1765929600},{"date":"2025-12-18","value":33108179,"t":1766016000},{"date":"2025-12-19","value":34490732,"t":1766102400},{"date":"2025-12-20","value":17059932,"t":1766188800},{"date":"2025-12-21","value":18240333,"t":1766275200},{"date":"2025-12-22","value":29795388,"t":1766361600},{"date":"2025-12-23","value":24832320,"t":1766448000},{"date":"2025-12-24","value":23911675,"t":1766534400},{"date":"2025-12-25","value":21400308,"t":1766620800},{"date":"2025-12-26","value":21736511,"t":1766707200},{"date":"2025-12-27","value":11051315,"t":1766793600},{"date":"2025-12-28","value":12399554,"t":1766880000},{"date":"2025-12-29","value":21153054,"t":1766966400},{"date":"2025-12-30","value":23442717,"t":1767052800},{"date":"2025-12-31","value":21712890,"t":1767139200},{"date":"2026-01-01","value":22811440,"t":1767225600},{"date":"2026-01-02","value":21314689,"t":1767312000},{"date":"2026-01-03","value":14480137,"t":1767398400},{"date":"2026-01-04","value":13248222,"t":1767484800},{"date":"2026-01-05","value":24283415,"t":1767571200},{"date":"2026-01-06","value":24420829,"t":1767657600},{"date":"2026-01-07","value":26194854,"t":1767744000},{"date":"2026-01-08","value":24152773,"t":1767830400},{"date":"2026-01-09","value":25564563,"t":1767916800},{"date":"2026-01-10","value":14913467,"t":1768003200},{"date":"2026-01-11","value":15549043,"t":1768089600},{"date":"2026-01-12","value":32241808,"t":1768176000},{"date":"2026-01-13","value":38436973,"t":1768262400},{"date":"2026-01-14","value":40440498,"t":1768348800},{"date":"2026-01-15","value":39530530,"t":1768435200},{"date":"2026-01-16","value":40686751,"t":1768521600},{"date":"2026-01-17","value":25765492,"t":1768608000},{"date":"2026-01-18","value":24694088,"t":1768694400},{"date":"2026-01-19","value":45690949,"t":1768780800},{"date":"2026-01-20","value":41707976,"t":1768867200},{"date":"2026-01-21","value":43490553,"t":1768953600},{"date":"2026-01-22","value":46097816,"t":1769040000},{"date":"2026-01-23","value":43500943,"t":1769126400},{"date":"2026-01-24","value":26536265,"t":1769212800},{"date":"2026-01-25","value":26886499,"t":1769299200},{"date":"2026-01-26","value":49108920,"t":1769385600},{"date":"2026-01-27","value":46085042,"t":1769472000},{"date":"2026-01-28","value":46563023,"t":1769558400},{"date":"2026-01-29","value":50382978,"t":1769644800},{"date":"2026-01-30","value":52407172,"t":1769731200},{"date":"2026-01-31","value":29235724,"t":1769817600},{"date":"2026-02-01","value":30039853,"t":1769904000},{"date":"2026-02-02","value":52894731,"t":1769990400},{"date":"2026-02-03","value":48702156,"t":1770076800},{"date":"2026-02-04","value":50043191,"t":1770163200},{"date":"2026-02-05","value":54881201,"t":1770249600},{"date":"2026-02-06","value":52453879,"t":1770336000},{"date":"2026-02-07","value":30052365,"t":1770422400},{"date":"2026-02-08","value":33355479,"t":1770508800},{"date":"2026-02-09","value":55194566,"t":1770595200},{"date":"2026-02-10","value":2744864,"t":1770681600},{"date":"2026-02-11","value":2775057,"t":1770768000},{"date":"2026-02-12","value":57004272,"t":1770854400},{"date":"2026-02-13","value":58092519,"t":1770940800},{"date":"2026-02-14","value":35344604,"t":1771027200},{"date":"2026-02-15","value":33077394,"t":1771113600},{"date":"2026-02-16","value":57355449,"t":1771200000},{"date":"2026-02-17","value":63472375,"t":1771286400},{"date":"2026-02-18","value":57222806,"t":1771372800},{"date":"2026-02-19","value":62945431,"t":1771459200},{"date":"2026-02-20","value":62688361,"t":1771545600},{"date":"2026-02-21","value":35341484,"t":1771632000},{"date":"2026-02-22","value":36254614,"t":1771718400},{"date":"2026-02-23","value":66548845,"t":1771804800},{"date":"2026-02-24","value":59376819,"t":1771891200},{"date":"2026-02-25","value":60731622,"t":1771977600},{"date":"2026-02-26","value":69450577,"t":1772064000},{"date":"2026-02-27","value":71321080,"t":1772150400},{"date":"2026-02-28","value":39427293,"t":1772236800},{"date":"2026-03-01","value":41885367,"t":1772323200},{"date":"2026-03-02","value":67366025,"t":1772409600},{"date":"2026-03-03","value":74328696,"t":1772496000},{"date":"2026-03-04","value":66073662,"t":1772582400},{"date":"2026-03-05","value":68754938,"t":1772668800},{"date":"2026-03-06","value":66812100,"t":1772755200},{"date":"2026-03-07","value":41705168,"t":1772841600},{"date":"2026-03-08","value":42623584,"t":1772928000},{"date":"2026-03-09","value":72907214,"t":1773014400},{"date":"2026-03-10","value":80275643,"t":1773100800},{"date":"2026-03-11","value":74369253,"t":1773187200},{"date":"2026-03-12","value":79690285,"t":1773273600},{"date":"2026-03-13","value":79574919,"t":1773360000},{"date":"2026-03-14","value":43565537,"t":1773446400},{"date":"2026-03-15","value":44664631,"t":1773532800},{"date":"2026-03-16","value":74446670,"t":1773619200},{"date":"2026-03-17","value":75445551,"t":1773705600},{"date":"2026-03-18","value":75137032,"t":1773792000},{"date":"2026-03-19","value":83077369,"t":1773878400},{"date":"2026-03-20","value":84697106,"t":1773964800},{"date":"2026-03-21","value":48237285,"t":1774051200},{"date":"2026-03-22","value":47686595,"t":1774137600},{"date":"2026-03-23","value":82960393,"t":1774224000},{"date":"2026-03-24","value":83677235,"t":1774310400},{"date":"2026-03-25","value":89583807,"t":1774396800},{"date":"2026-03-26","value":83944196,"t":1774483200},{"date":"2026-03-27","value":94360247,"t":1774569600},{"date":"2026-03-28","value":53946565,"t":1774656000},{"date":"2026-03-29","value":53276748,"t":1774742400},{"date":"2026-03-30","value":86034428,"t":1774828800},{"date":"2026-03-31","value":92930343,"t":1774915200},{"date":"2026-04-01","value":96548207,"t":1775001600},{"date":"2026-04-02","value":147926321,"t":1775088000},{"date":"2026-04-03","value":94046622,"t":1775174400},{"date":"2026-04-04","value":61373333,"t":1775260800},{"date":"2026-04-05","value":56987037,"t":1775347200},{"date":"2026-04-06","value":99909586,"t":1775433600},{"date":"2026-04-07","value":95628311,"t":1775520000},{"date":"2026-04-08","value":102468902,"t":1775606400},{"date":"2026-04-09","value":109416309,"t":1775692800},{"date":"2026-04-10","value":103866779,"t":1775779200},{"date":"2026-04-11","value":58871449,"t":1775865600},{"date":"2026-04-12","value":62963750,"t":1775952000},{"date":"2026-04-13","value":108013157,"t":1776038400},{"date":"2026-04-14","value":115092751,"t":1776124800},{"date":"2026-04-15","value":105625792,"t":1776211200},{"date":"2026-04-16","value":113878537,"t":1776297600},{"date":"2026-04-17","value":110274744,"t":1776384000},{"date":"2026-04-18","value":62631803,"t":1776470400},{"date":"2026-04-19","value":67338252,"t":1776556800},{"date":"2026-04-20","value":122467122,"t":1776643200},{"date":"2026-04-21","value":113322563,"t":1776729600},{"date":"2026-04-22","value":128915685,"t":1776816000},{"date":"2026-04-23","value":124734711,"t":1776902400},{"date":"2026-04-24","value":114673815,"t":1776988800},{"date":"2026-04-25","value":73152260,"t":1777075200},{"date":"2026-04-26","value":75640605,"t":1777161600},{"date":"2026-04-27","value":124079821,"t":1777248000},{"date":"2026-04-28","value":120088456,"t":1777334400},{"date":"2026-04-29","value":119762994,"t":1777420800},{"date":"2026-04-30","value":129714721,"t":1777507200},{"date":"2026-05-01","value":126031740,"t":1777593600},{"date":"2026-05-02","value":80965626,"t":1777680000},{"date":"2026-05-03","value":72338854,"t":1777766400},{"date":"2026-05-04","value":134706071,"t":1777852800},{"date":"2026-05-05","value":127648077,"t":1777939200},{"date":"2026-05-06","value":137811402,"t":1778025600},{"date":"2026-05-07","value":140145987,"t":1778112000},{"date":"2026-05-08","value":138945379,"t":1778198400},{"date":"2026-05-09","value":88047474,"t":1778284800},{"date":"2026-05-10","value":89688824,"t":1778371200},{"date":"2026-05-11","value":153241987,"t":1778457600},{"date":"2026-05-12","value":157383326,"t":1778544000},{"date":"2026-05-13","value":149998007,"t":1778630400},{"date":"2026-05-14","value":148743420,"t":1778716800},{"date":"2026-05-15","value":154224853,"t":1778803200},{"date":"2026-05-16","value":85550108,"t":1778889600},{"date":"2026-05-17","value":86160076,"t":1778976000},{"date":"2026-05-18","value":167199705,"t":1779062400},{"date":"2026-05-19","value":158123410,"t":1779148800},{"date":"2026-05-20","value":159681910,"t":1779235200},{"date":"2026-05-21","value":166028572,"t":1779321600},{"date":"2026-05-22","value":148269755,"t":1779408000},{"date":"2026-05-23","value":84175467,"t":1779494400},{"date":"2026-05-24","value":77451597,"t":1779580800},{"date":"2026-05-25","value":136517112,"t":1779667200},{"date":"2026-05-26","value":126286619,"t":1779753600},{"date":"2026-05-27","value":130825223,"t":1779840000},{"date":"2026-05-28","value":139289095,"t":1779926400},{"date":"2026-05-29","value":123476790,"t":1780012800},{"date":"2026-05-30","value":66573289,"t":1780099200},{"date":"2026-05-31","value":73887181,"t":1780185600},{"date":"2026-06-01","value":119460115,"t":1780272000},{"date":"2026-06-02","value":115251673,"t":1780358400},{"date":"2026-06-03","value":107439521,"t":1780444800},{"date":"2026-06-04","value":101865254,"t":1780531200},{"date":"2026-06-05","value":110088323,"t":1780617600},{"date":"2026-06-06","value":59281547,"t":1780704000},{"date":"2026-06-07","value":52428970,"t":1780790400},{"date":"2026-06-08","value":89706236,"t":1780876800},{"date":"2026-06-09","value":88617801,"t":1780963200},{"date":"2026-06-10","value":99176643,"t":1781049600},{"date":"2026-06-11","value":101752957,"t":1781136000},{"date":"2026-06-12","value":99027329,"t":1781222400},{"date":"2026-06-13","value":54933621,"t":1781308800},{"date":"2026-06-14","value":60077488,"t":1781395200},{"date":"2026-06-15","value":100282096,"t":1781481600},{"date":"2026-06-16","value":100925581,"t":1781568000},{"date":"2026-06-17","value":102186646,"t":1781654400},{"date":"2026-06-18","value":101035208,"t":1781740800},{"date":"2026-06-19","value":98811515,"t":1781827200},{"date":"2026-06-20","value":61494675,"t":1781913600},{"date":"2026-06-21","value":65096806,"t":1782000000},{"date":"2026-06-22","value":100987445,"t":1782086400},{"date":"2026-06-23","value":105484973,"t":1782172800},{"date":"2026-06-24","value":110766026,"t":1782259200},{"date":"2026-06-25","value":101782417,"t":1782345600},{"date":"2026-06-26","value":108481643,"t":1782432000},{"date":"2026-06-27","value":60500980,"t":1782518400},{"date":"2026-06-28","value":62824433,"t":1782604800},{"date":"2026-06-29","value":122150829,"t":1782691200},{"date":"2026-06-30","value":123080848,"t":1782777600},{"date":"2026-07-01","value":112763955,"t":1782864000},{"date":"2026-07-02","value":115685270,"t":1782950400},{"date":"2026-07-03","value":117606258,"t":1783036800},{"date":"2026-07-04","value":66304851,"t":1783123200},{"date":"2026-07-05","value":66347691,"t":1783209600},{"date":"2026-07-06","value":118421769,"t":1783296000},{"date":"2026-07-07","value":118904723,"t":1783382400},{"date":"2026-07-08","value":127664208,"t":1783468800}],"headlines":[{"date":"2025-07-02","title":"First Claude API key created — the bill is $12","sentiment":"pos","importance":1,"pointIndex":1},{"date":"2025-08-15","title":"Eval harness lands; prompt experiments triple","sentiment":"neutral","importance":1,"pointIndex":45},{"date":"2025-10-01","title":"AGENT MODE SHIPS — token burn triples overnight","sentiment":"pos","importance":3,"pointIndex":92},{"date":"2025-11-07","title":"Hacker News front page: signups 10x, tokens follow","sentiment":"pos","importance":2,"pointIndex":129},{"date":"2025-12-24","title":"Holiday lull: even the agents take Christmas off","sentiment":"neutral","importance":1,"pointIndex":176},{"date":"2026-01-12","title":"First enterprise contract signed — usage steps up","sentiment":"pos","importance":2,"pointIndex":195},{"date":"2026-02-10","title":"REGION OUTAGE: token usage falls 95% for two days","sentiment":"neg","importance":3,"pointIndex":224},{"date":"2026-02-13","title":"Full recovery — retry queues drain overnight","sentiment":"pos","importance":1,"pointIndex":227},{"date":"2026-04-02","title":"Batch pipeline migrates to Claude — nightly spikes begin","sentiment":"neutral","importance":1,"pointIndex":275},{"date":"2026-06-01","title":"Prompt caching rollout cuts token spend 30%","sentiment":"pos","importance":2,"pointIndex":335},{"date":"2026-06-20","title":"Growth resumes: caching savings reinvested in agents","sentiment":"pos","importance":1,"pointIndex":354}],"milestones":[{"date":"2025-10-08","label":"10M TOKENS A DAY","pointIndex":99},{"date":"2026-01-15","label":"50M TOKENS A DAY","pointIndex":198},{"date":"2026-05-10","label":"100M TOKENS A DAY","pointIndex":313}],"stats":{"min":895235,"max":167199705,"minIndex":5,"maxIndex":321,"first":1435653,"last":127664208,"totalReturn":87.92413974686083,"multiple":186.7662736599887,"maxDrawdown":0.9502693073082593,"maxDrawdownIndex":224}} \ No newline at end of file diff --git a/public/data/index.json b/public/data/index.json index f824254..783eae5 100644 --- a/public/data/index.json +++ b/public/data/index.json @@ -180,5 +180,18 @@ "multiple": 532.309729886326, "maxDrawdown": 0.75574350807427, "headlines": 36 + }, + { + "symbol": "TOKENS", + "name": "Claude Token Usage", + "tagline": "One startup's Claude bill, ridden day by day: agent-mode liftoff, one brutal outage, and the caching dividend.", + "theme": "crypto", + "points": 373, + "start": "2025-07-01", + "end": "2026-07-08", + "totalReturn": 87.92413974686083, + "multiple": 186.7662736599887, + "maxDrawdown": 0.9502693073082593, + "headlines": 11 } ] \ No newline at end of file diff --git a/src/billboards.js b/src/billboards.js index da7d0bb..407d830 100644 --- a/src/billboards.js +++ b/src/billboards.js @@ -3,6 +3,7 @@ import * as THREE from 'three'; import { lambert } from './textures.js'; import { groundHeight } from './terrain.js'; +import { fmtDate } from './series.js'; const SENTIMENT = { pos: { frame: '#15803d', accent: '#4ade80', tag: 'GOOD NEWS' }, @@ -10,13 +11,6 @@ const SENTIMENT = { neutral: { frame: '#52525b', accent: '#d4d4d8', tag: 'NEWS' }, }; -const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; - -export function fmtDate(iso) { - const [y, m] = iso.split('-').map(Number); - return `${MONTHS[(m - 1 + 12) % 12]} ${y}`; -} - function wrap(ctx, text, maxW) { const words = String(text).split(/\s+/); const lines = []; @@ -130,7 +124,7 @@ export function buildBillboards(track, T, theme, ride) { tangent.normalize(); const lat = new THREE.Vector3(-tangent.z, 0, tangent.x); sign.position.copy(cp).addScaledVector(lat, side * dist); - const surfaceY = groundHeight(sign.position.x, sign.position.z, ride.symbol); + const surfaceY = groundHeight(sign.position.x, sign.position.z, ride.id); const panelH = sign.userData.panelHeight ?? 4.6; // Low launch-day tracks can sit inside a trench; signs should read above the nearby terrain, not drown in it. sign.position.y = Math.max(cp.y + lift, surfaceY + panelH * 0.5 + 4.1); @@ -156,7 +150,7 @@ export function buildBillboards(track, T, theme, ride) { const scale = launchOpening ? Math.min(baseScale, 0.92) : baseScale; const tex = panelTexture({ title: h.title, - dateLabel: fmtDate(h.date), + dateLabel: h.date ? fmtDate(h.date) : null, sentiment: h.sentiment, big: imp >= 3, }); @@ -219,25 +213,27 @@ export function buildBillboards(track, T, theme, ride) { banner.position.y = 5.5; arch.add(banner); - const archGround = groundHeight(cp.x, cp.z, ride.symbol); + const archGround = groundHeight(cp.x, cp.z, ride.id); arch.position.set(cp.x, Math.max(cp.y, archGround + 1.4), cp.z); const yaw = Math.atan2(dir.x, dir.z); arch.rotation.y = yaw; group.add(arch); } - // --- year marker posts - const span = (new Date(ride.points[n - 1].date).getTime() - new Date(ride.points[0].date).getTime()) / 31557600000; - const step = span > 24 ? 5 : 1; - let lastYear = null; - for (let i = 0; i < n; i++) { - const year = Number(ride.points[i].date.slice(0, 4)); - if (year !== lastYear) { - lastYear = year; - if (i === 0 || year % step !== 0) continue; - const tex = panelTexture({ title: String(year), sentiment: 'neutral', big: true }); - const sign = makeSign(tex, 3.0, 1.9, T, theme.signFrame); - place(sign, i, (year % 2) * 2 - 1, 6.5, 1.1); + // --- year marker posts (only for series with dated points) + if (ride.hasDates) { + const span = (new Date(ride.points[n - 1].date).getTime() - new Date(ride.points[0].date).getTime()) / 31557600000; + const step = span > 24 ? 5 : 1; + let lastYear = null; + for (let i = 0; i < n; i++) { + const year = Number(ride.points[i].date.slice(0, 4)); + if (year !== lastYear) { + lastYear = year; + if (i === 0 || year % step !== 0) continue; + const tex = panelTexture({ title: String(year), sentiment: 'neutral', big: true }); + const sign = makeSign(tex, 3.0, 1.9, T, theme.signFrame); + place(sign, i, (year % 2) * 2 - 1, 6.5, 1.1); + } } } @@ -274,7 +270,7 @@ export function buildStations(track, T, theme, ride) { const t0 = new THREE.Vector3().subVectors(track.controlPoints[1], track.controlPoints[0]).setY(0).normalize(); const start = mkPlatform(track.controlPoints[0], t0); const titleTex = panelTexture({ - title: `${ride.symbol} — ${ride.name}`, + title: `${ride.id} — ${ride.name}`, dateLabel: 'NOW BOARDING', sentiment: 'pos', big: true, diff --git a/src/hud.js b/src/hud.js index 5e33ade..c4e3667 100644 --- a/src/hud.js +++ b/src/hud.js @@ -1,24 +1,10 @@ -// DOM HUD: date/price readouts, zone label, mini chart with progress, +// DOM HUD: date/value readouts, zone label, mini chart with progress, // headline toasts, ATH flash, ride-end summary. -import { fmtDate } from './billboards.js'; +import { fmtDate, fmtMoney, fmtPct } from './series.js'; const MONTHS_FULL = ['JANUARY', 'FEBRUARY', 'MARCH', 'APRIL', 'MAY', 'JUNE', 'JULY', 'AUGUST', 'SEPTEMBER', 'OCTOBER', 'NOVEMBER', 'DECEMBER']; -export function fmtMoney(v, currency = 'USD') { - const sym = currency === 'USD' ? '$' : currency + ' '; - if (v >= 1000) return sym + v.toLocaleString('en-US', { maximumFractionDigits: 0 }); - if (v >= 1) return sym + v.toFixed(2); - return sym + v.toFixed(4); -} - -export function fmtPct(p) { - const v = p * 100; - const sign = v >= 0 ? '+' : ''; - if (Math.abs(v) >= 10000) return `${sign}${(v / 1000).toFixed(1)}k%`; - return `${sign}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%`; -} - export class Hud { constructor() { this.el = { @@ -69,7 +55,7 @@ export class Hud { this.toastUntil = 0; this.athUntil = 0; this.el.hud.classList.add('active'); - this.el.symbol.innerHTML = `${esc(ride.symbol)}${esc(ride.name)}`; + this.el.symbol.innerHTML = `${esc(ride.id)}${esc(ride.name)}`; this.el.hint.textContent = 'SPACE pause · paused: S screenshot · 1-4 speed · click map to time-travel · ESC station'; this.speedLevel = 2; this.speedFlashUntil = 0; @@ -94,7 +80,7 @@ export class Hud { } prepChart(ride) { - // pre-render the full log-price polyline to an offscreen canvas + // pre-render the full scaled-value polyline to an offscreen canvas this.chartBase = document.createElement('canvas'); this.chartBase.width = this.el.chart.width; this.chartBase.height = this.el.chart.height; @@ -103,16 +89,9 @@ export class Hud { ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, W, H); const pts = ride.points; - let lmin = Infinity, lmax = -Infinity; - for (const p of pts) { - const l = Math.log(p.close); - if (l < lmin) lmin = l; - if (l > lmax) lmax = l; - } - const span = Math.max(1e-9, lmax - lmin); this.chartXY = i => [ 4 + (i / (pts.length - 1)) * (W - 8), - H - 5 - ((Math.log(pts[i].close) - lmin) / span) * (H - 10), + H - 5 - ride.norm(pts[i].value) * (H - 10), ]; ctx.strokeStyle = '#3b4761'; ctx.lineWidth = 1.5; @@ -133,13 +112,22 @@ export class Hud { if (index !== this.lastIndex) { this.lastIndex = index; const p = ride.points[index]; - const [y, m, d] = p.date.split('-').map(Number); - const day = p.timeLabel ? ` ${d}` : ''; - const time = p.timeLabel ? ` · ${esc(p.timeLabel)}` : ''; - this.el.date.innerHTML = `${MONTHS_FULL[m - 1]}${day} ${y}${time}${esc(ride.symbol)} RIDE`; - const pct = p.close / ride.points[0].close - 1; + let when; + if (p.date) { + const [y, m, d] = p.date.split('-').map(Number); + const day = p.timeLabel ? ` ${d}` : ''; + const time = p.timeLabel ? ` · ${esc(p.timeLabel)}` : ''; + when = `${MONTHS_FULL[m - 1]}${day} ${y}${time}`; + } else { + when = esc(p.label); + } + this.el.date.innerHTML = `${when}${esc(ride.id)} RIDE`; + const first = ride.points[0].value; + const delta = first > 0 + ? `${fmtPct(p.value / first - 1)} since start` + : `${p.value >= first ? '+' : '−'}${esc(ride.fmtValue(Math.abs(p.value - first)))} since start`; this.el.price.innerHTML = - `${fmtMoney(p.close, ride.currency)}${fmtPct(pct)} since start`; + `${esc(ride.fmtValue(p.value))}${delta}`; } const speedFlash = performance.now() < this.speedFlashUntil ? ` · SPEED ${this.speedLevel}` : ''; this.el.zone.innerHTML = @@ -173,7 +161,7 @@ export class Hud { ctx.fillStyle = '#fcd34d'; ctx.font = 'bold 11px monospace'; ctx.textAlign = hx > W / 2 ? 'right' : 'left'; - ctx.fillText(fmtDate(ride.points[this.hoverIndex].date), hx + (hx > W / 2 ? -5 : 5), 13); + ctx.fillText(ride.points[this.hoverIndex].label, hx + (hx > W / 2 ? -5 : 5), 13); ctx.textAlign = 'left'; } @@ -192,8 +180,9 @@ export class Hud { showToast(headline) { const s = headline.sentiment === 'pos' ? '#4ade80' : headline.sentiment === 'neg' ? '#f87171' : '#d4d4d8'; + const when = headline.date ? ` ${esc(fmtDate(headline.date))}` : ''; this.el.toast.innerHTML = - `📰 ${esc(fmtDate(headline.date))}
${esc(headline.title)}`; + `📰${when}
${esc(headline.title)}`; this.el.toast.style.display = 'block'; this.toastUntil = performance.now() + (this.toastQueue.length ? 4200 : 6000); } @@ -207,18 +196,32 @@ export class Hud { const stats = ride.stats; const last = ride.points[ride.points.length - 1]; const first = ride.points[0]; - const years = (new Date(last.date) - new Date(first.date)) / 31557600000; - const grand = 1000 * (last.close / first.close); + const upClass = ride.up ? 'up' : 'down'; + const change = stats.totalReturn != null + ? fmtPct(stats.totalReturn) + : esc(`${ride.fmtValue(stats.first)} → ${ride.fmtValue(stats.last)}`); const rows = [ - ['RIDE', `${ride.symbol} (${fmtDate(first.date)} → ${fmtDate(last.date)})`], - ['TOTAL RETURN', `${fmtPct(stats.totalReturn)}`], - ['$1,000 INVESTED', `${fmtMoney(grand)}`], - ['PEAK ALTITUDE', `${fmtMoney(stats.max, ride.currency)} (${fmtDate(ride.points[stats.maxIndex].date)})`], - ['MAX DRAWDOWN', `-${Math.round(stats.maxDrawdown * 100)}%`], - ['YEARS RIDDEN', years.toFixed(1)], + ['RIDE', esc(`${ride.id} (${first.label} → ${last.label})`)], + [ride.currency ? 'TOTAL RETURN' : 'TOTAL CHANGE', `${change}`], + ['PEAK ALTITUDE', esc(`${ride.fmtValue(stats.max)} (${ride.points[stats.maxIndex].label})`)], + [ride.currency ? 'MAX DRAWDOWN' : 'WORST DIP', `-${Math.round(stats.maxDrawdown * 100)}%`], ]; - document.getElementById('summary-title').textContent = - stats.totalReturn >= 0 ? `YOU SURVIVED ${ride.symbol}!` : `${ride.symbol} TOOK YOUR LUNCH MONEY`; + if (ride.currency && stats.first > 0) { + const grand = 1000 * (stats.last / stats.first); + rows.splice(2, 0, + ['$1,000 INVESTED', `${fmtMoney(grand)}`]); + } + if (ride.hasDates) { + const years = (new Date(last.date) - new Date(first.date)) / 31557600000; + rows.push(years >= 1 + ? ['YEARS RIDDEN', years.toFixed(1)] + : ['DAYS RIDDEN', Math.max(1, Math.round(years * 365.25)).toString()]); + } else { + rows.push(['POINTS RIDDEN', String(ride.points.length)]); + } + document.getElementById('summary-title').textContent = ride.up + ? `YOU SURVIVED ${ride.id}!` + : ride.currency ? `${ride.id} TOOK YOUR LUNCH MONEY` : `${ride.id} RODE ALL THE WAY DOWN`; document.getElementById('summary-rows').innerHTML = rows.map(([k, v]) => `
${k}${v}
`).join(''); document.getElementById('summary').style.display = 'flex'; diff --git a/src/main.js b/src/main.js index 462f04f..9ed8b96 100644 --- a/src/main.js +++ b/src/main.js @@ -11,7 +11,7 @@ import { Effects } from './effects.js'; import { Hud } from './hud.js'; import { GameAudio } from './audio.js'; import { sampleAtmosphere } from './zones.js'; -import { fmtPct } from './hud.js'; +import { fmtPct, normalizeRide } from './series.js'; const canvas = document.getElementById('game'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance', preserveDrawingBuffer: true }); @@ -25,7 +25,7 @@ const audio = new GameAudio(); const state = { mode: 'menu', // 'menu' | 'riding' - rides: new Map(), // symbol -> ride json + rides: new Map(), // ride id -> normalized ride scene: null, sky: null, cart: null, @@ -56,14 +56,37 @@ async function boot() { try { return await (await fetch(`data/${e.symbol}.json`)).json(); } catch { return null; } })); - for (const r of rides) if (r) state.rides.set(r.symbol, r); - buildMenu(index.filter(e => state.rides.has(e.symbol))); + for (const r of rides) { + if (!r) continue; + const ride = normalizeRide(r); + state.rides.set(ride.id, ride); + } - // ?ride=NVDA jumps straight onto a coaster (also used by automated tests) + // ?data= rides any time-series JSON (generic or stock format) const params = new URLSearchParams(location.search); + const dataUrl = params.get('data'); + let customId = null; + if (dataUrl) { + try { + const ride = normalizeRide(await (await fetch(dataUrl)).json()); + state.rides.set(ride.id, ride); + customId = ride.id; + } catch (e) { + console.error(`failed to load ?data=${dataUrl}`, e); + } + } + const ordered = index.map(e => state.rides.get(e.symbol)).filter(Boolean); + if (customId && !ordered.some(r => r.id === customId)) ordered.push(state.rides.get(customId)); + buildMenu(ordered); + + // ?ride=NVDA jumps straight onto a coaster (also used by automated tests); + // ?data= without ?ride= boards the custom series directly const auto = params.get('ride'); - if (auto && state.rides.has(auto.toUpperCase())) { - await startRide(auto.toUpperCase()); + const startId = auto + ? (state.rides.has(auto) ? auto : state.rides.has(auto.toUpperCase()) ? auto.toUpperCase() : null) + : customId; + if (startId) { + await startRide(startId); if (params.has('go')) { showLockHint(false); state.paused = false; @@ -78,7 +101,8 @@ async function boot() { function difficulty(ride) { let wild = 0; for (let i = 1; i < ride.points.length; i++) { - wild = Math.max(wild, Math.abs(ride.points[i].close / ride.points[i - 1].close - 1)); + const prev = ride.points[i - 1].value; + if (prev > 0) wild = Math.max(wild, Math.abs(ride.points[i].value / prev - 1)); } const score = ride.stats.maxDrawdown + wild; if (score < 0.8) return ['SCENIC', '#86efac']; @@ -87,7 +111,7 @@ function difficulty(ride) { return ['NIGHTMARE', '#f87171']; } -function buildMenu(index) { +function buildMenu(rides) { const menu = document.getElementById('menu'); menu.innerHTML = ` `; const grid = document.getElementById('rides'); - for (const entry of index) { - const ride = state.rides.get(entry.symbol); + for (const ride of rides) { const [diff, diffColor] = difficulty(ride); const card = document.createElement('div'); card.className = 'ride-card panel'; - const up = ride.stats.totalReturn >= 0; - const years = ((new Date(ride.points[ride.points.length - 1].date) - new Date(ride.points[0].date)) / 31557600000).toFixed(0); + const span = ride.hasDates + ? `${((new Date(ride.points[ride.points.length - 1].date) - new Date(ride.points[0].date)) / 31557600000).toFixed(0)} YRS` + : `${ride.points.length} PTS`; + const change = ride.stats.totalReturn != null ? fmtPct(ride.stats.totalReturn) : '—'; card.innerHTML = `
${diff}
-

${esc(ride.symbol)}

-
${esc(ride.name)} · ${years} YRS · ${ride.headlines?.length ?? 0} HEADLINES
+

${esc(ride.id)}

+
${esc(ride.name)} · ${span} · ${ride.headlines?.length ?? 0} HEADLINES
${esc(ride.tagline ?? '')}
- RETURN ${fmtPct(ride.stats.totalReturn)} + ${ride.currency ? 'RETURN' : 'CHANGE'} ${change} WORST DIP -${Math.round(ride.stats.maxDrawdown * 100)}%
`; drawPreview(card.querySelector('canvas'), ride); - card.addEventListener('click', () => startRide(ride.symbol)); + card.addEventListener('click', () => startRide(ride.id)); grid.appendChild(card); } } @@ -142,27 +167,20 @@ function drawPreview(cv, ride) { ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, W, H); const pts = ride.points; - let lmin = Infinity, lmax = -Infinity; - for (const p of pts) { - const l = Math.log(p.close); - if (l < lmin) lmin = l; - if (l > lmax) lmax = l; - } - const span = Math.max(1e-9, lmax - lmin); - ctx.strokeStyle = ride.stats.totalReturn >= 0 ? '#4ade80' : '#f87171'; + ctx.strokeStyle = ride.up ? '#4ade80' : '#f87171'; ctx.lineWidth = 2; ctx.beginPath(); for (let i = 0; i < pts.length; i++) { const x = 3 + (i / (pts.length - 1)) * (W - 6); - const y = H - 4 - ((Math.log(pts[i].close) - lmin) / span) * (H - 8); + const y = H - 4 - ride.norm(pts[i].value) * (H - 8); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.stroke(); } // ---------------------------------------------------------------- ride lifecycle -async function startRide(symbol) { - const ride = state.rides.get(symbol); +async function startRide(id) { + const ride = state.rides.get(id); if (!ride) return; document.getElementById('loading').style.display = 'flex'; // let the loading overlay actually paint before the synchronous scene build @@ -245,7 +263,7 @@ function finishRide() { showLockHint(false); state.paused = false; hud.showSummary(state.ride); - if (state.ride.stats.totalReturn >= 0) audio.fanfare(); + if (state.ride.up) audio.fanfare(); else audio.womp(); } @@ -337,7 +355,7 @@ function speedLevelFromKey(e) { function downloadScreenshot() { if (!canvas || state.mode !== 'riding') return; const stamp = new Date().toISOString().replace(/[:.]/g, '-'); - const symbol = state.ride?.symbol ?? 'STOCKCOASTER'; + const symbol = state.ride?.id ?? 'STOCKCOASTER'; canvas.toBlob(blob => { if (!blob) return; const url = URL.createObjectURL(blob); diff --git a/src/series.js b/src/series.js new file mode 100644 index 0000000..2a9b9e0 --- /dev/null +++ b/src/series.js @@ -0,0 +1,148 @@ +// Canonical series layer: normalizes any ride JSON into the shape the engine +// consumes, so the coaster can render any time series, not just stock prices. +// +// Accepted inputs: +// - legacy stock format: { symbol, currency, points: [{ date, close }], stats, ... } +// - generic format: { id, name, tagline, theme, +// scale: 'log' | 'linear', // optional, auto-detected +// unit: { prefix, suffix }, // optional, e.g. { suffix: ' tok' } +// points: [{ date?, label?, value }], +// headlines: [{ date? | pointIndex, title, sentiment, importance }], +// milestones: [{ date? | pointIndex, label }] } +// +// Output ride shape (superset of input): +// id, points (each with .value, .label), stats, scale, +// norm(v) -> 0..1 altitude, spanScore (drives track height span), +// fmtValue(v) -> display string, up (ended >= started), currency? + +const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; + +export function fmtDate(iso) { + const m = /^(\d{4})-(\d{2})/.exec(String(iso)); + if (!m) return String(iso); + return `${MONTHS[(Number(m[2]) - 1 + 12) % 12]} ${m[1]}`; +} + +export function fmtMoney(v, currency = 'USD') { + const sym = currency === 'USD' ? '$' : currency + ' '; + if (v >= 1000) return sym + v.toLocaleString('en-US', { maximumFractionDigits: 0 }); + if (v >= 1) return sym + v.toFixed(2); + return sym + v.toFixed(4); +} + +export function fmtPct(p) { + const v = p * 100; + const sign = v >= 0 ? '+' : ''; + if (Math.abs(v) >= 10000) return `${sign}${(v / 1000).toFixed(1)}k%`; + return `${sign}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%`; +} + +function abbrev(v) { + const a = Math.abs(v); + if (a >= 1e12) return (v / 1e12).toFixed(a < 1e13 ? 2 : 1) + 'T'; + if (a >= 1e9) return (v / 1e9).toFixed(a < 1e10 ? 2 : 1) + 'B'; + if (a >= 1e6) return (v / 1e6).toFixed(a < 1e7 ? 2 : 1) + 'M'; + if (a >= 1e4) return (v / 1e3).toFixed(1) + 'K'; + if (a >= 100) return v.toFixed(0); + if (a >= 1) return v.toFixed(2); + if (a === 0) return '0'; + return v.toFixed(4); +} + +function makeFormatter(json) { + if (json.currency) return v => fmtMoney(v, json.currency); + const prefix = json.unit?.prefix ?? ''; + const suffix = json.unit?.suffix ?? ''; + return v => prefix + abbrev(v) + suffix; +} + +export function buildStats(values) { + let min = Infinity, max = -Infinity, minI = 0, maxI = 0, minPositive = Infinity; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v < min) { min = v; minI = i; } + if (v > max) { max = v; maxI = i; } + if (v > 0 && v < minPositive) minPositive = v; + } + if (!Number.isFinite(minPositive)) minPositive = 1; + const first = values[0], last = values[values.length - 1]; + let peak = -Infinity, mdd = 0, mddI = 0; + for (let i = 0; i < values.length; i++) { + peak = Math.max(peak, values[i]); + if (peak > 0) { + const dd = 1 - values[i] / peak; + if (dd > mdd) { mdd = dd; mddI = i; } + } + } + return { + min, max, minIndex: minI, maxIndex: maxI, minPositive, + first, last, + totalReturn: first > 0 ? last / first - 1 : null, + multiple: min > 0 ? max / min : null, + maxDrawdown: mdd, maxDrawdownIndex: mddI, + }; +} + +function makeScale(stats, name) { + if (name === 'log') { + // non-positive values are clamped to the smallest positive one + const floor = stats.minPositive; + const lmin = Math.log(Math.max(Math.min(stats.min > 0 ? stats.min : floor, floor), 1e-12)); + const lmax = Math.log(Math.max(stats.max, floor)); + const span = Math.max(1e-9, lmax - lmin); + return v => Math.min(1, Math.max(0, (Math.log(Math.max(v, floor)) - lmin) / span)); + } + const span = Math.max(1e-9, stats.max - stats.min); + return v => Math.min(1, Math.max(0, (v - stats.min) / span)); +} + +/** Attach events (headlines/milestones) to the nearest point by date. */ +function attachEvents(points, events) { + if (!events?.length) return []; + const times = points.map(p => p.t ?? (p.date ? Date.parse(p.date) / 1000 : null)); + const out = []; + for (const ev of events) { + if (ev.pointIndex != null) { + if (ev.pointIndex >= 0 && ev.pointIndex < points.length) out.push(ev); + continue; + } + const t = ev.date ? Date.parse(ev.date) / 1000 : NaN; + if (!Number.isFinite(t)) continue; + let best = -1, bestD = Infinity; + for (let i = 0; i < points.length; i++) { + if (times[i] == null) continue; + const d = Math.abs(times[i] - t); + if (d < bestD) { bestD = d; best = i; } + } + if (best >= 0) out.push({ ...ev, pointIndex: best }); + } + out.sort((a, b) => a.pointIndex - b.pointIndex || (b.importance ?? 1) - (a.importance ?? 1)); + return out; +} + +export function normalizeRide(json) { + const id = json.id ?? json.symbol; + const points = (json.points ?? []).map((p, i) => ({ + ...p, + value: p.value ?? p.close, + label: p.label ?? (p.date ? fmtDate(p.date) : `#${i + 1}`), + })); + const values = points.map(p => p.value); + const stats = buildStats(values); + const scale = json.scale ?? (stats.min > 0 && stats.max / stats.min >= 4 ? 'log' : 'linear'); + const spanScore = scale === 'log' ? Math.log10(Math.max(1.0001, stats.max / stats.minPositive)) : 1.8; + return { + ...json, + id, + points, + stats, + scale, + spanScore, + hasDates: points.length > 0 && points.every(p => p.date), + norm: makeScale(stats, scale), + fmtValue: makeFormatter(json), + up: stats.last >= stats.first, + headlines: attachEvents(points, json.headlines), + milestones: attachEvents(points, json.milestones), + }; +} diff --git a/src/terrain.js b/src/terrain.js index c9cfb7f..e9e6f26 100644 --- a/src/terrain.js +++ b/src/terrain.js @@ -35,8 +35,8 @@ function noise2(x, z, seed) { } /** Deterministic terrain height. Used by track supports and terrain alike. */ -export function groundHeight(x, z, symbol) { - const seed = hashStr(symbol || 'STONK'); +export function groundHeight(x, z, seedKey) { + const seed = hashStr(seedKey || 'STONK'); const n1 = noise2(x * 0.022 + 31.7, z * 0.022 + 11.3, seed); const n2 = noise2(x * 0.006 + 7.1, z * 0.006 + 3.9, seed ^ 0x9e3779b9); return 15 + n1 * 5 + n2 * 8; @@ -44,8 +44,8 @@ export function groundHeight(x, z, symbol) { export function buildTerrain(track, T, theme) { const group = new THREE.Group(); - const symbol = track.ride.symbol; - const seed = hashStr(symbol); + const seedKey = track.ride.id; + const seed = hashStr(seedKey); const n = track.controlPoints.length; const box = new THREE.BoxGeometry(1, 1, 1); @@ -63,7 +63,7 @@ export function buildTerrain(track, T, theme) { const trench = track.meta[pi].trench; for (let dz = -HALF_WIDTH; dz <= HALF_WIDTH; dz += COL) { const z = Math.round((cp.z + dz) / COL) * COL; - const gh = groundHeight(x, z, symbol); + const gh = groundHeight(x, z, seedKey); const r = hash2(x * 13, z * 7, seed ^ 0x51ed); if (trench && Math.abs(z - cp.z) < 10) { diff --git a/src/track.js b/src/track.js index 99af22a..08d1501 100644 --- a/src/track.js +++ b/src/track.js @@ -1,5 +1,6 @@ -// Converts a price series into a 3D coaster track. -// - altitude = log-scaled price (so a 7000x run reads as cave -> space) +// Converts a time series into a 3D coaster track. +// - altitude = the ride's normalized value (log or linear scale, chosen by +// the series layer — so a 7000x stock run reads as cave -> space) // - the path meanders laterally so it feels like a coaster, not a chart // - blocky rails/ties/supports built from instanced boxes import * as THREE from 'three'; @@ -14,23 +15,15 @@ export function buildTrackData(ride, theme) { const pts = ride.points; const n = pts.length; - let logMin = Infinity, logMax = -Infinity; - for (const p of pts) { - const l = Math.log(p.close); - if (l < logMin) logMin = l; - if (l > logMax) logMax = l; - } - const logSpan = Math.max(1e-9, logMax - logMin); - const multiple = Math.exp(logSpan); const ySpanMult = theme?.launchDay ? 3.4 : (theme?.rockets ? 1.25 : 1.0); // IPO launch days + meme stocks get extra altitude const minSpan = theme?.launchDay ? 225 : 70; - const ySpan = THREE.MathUtils.clamp(95 * Math.log10(multiple) * ySpanMult, minSpan, 318); + const ySpan = THREE.MathUtils.clamp(95 * ride.spanScore * ySpanMult, minSpan, 318); // Raw altitudes, then two smoothing passes: keeps the macro chart shape but - // turns per-bar volatility sawtooth into rideable hills. HUD prices stay raw. + // turns per-bar volatility sawtooth into rideable hills. HUD values stay raw. let ys = []; for (let i = 0; i < n; i++) { - const p = (Math.log(pts[i].close) - logMin) / logSpan; + const p = ride.norm(pts[i].value); ys.push(Y_BASE + Math.pow(p, 1.12) * ySpan); } for (let pass = 0; pass < 2; pass++) { @@ -42,8 +35,7 @@ export function buildTrackData(ride, theme) { const meta = []; let runningMax = -Infinity; for (let i = 0; i < n; i++) { - const c = pts[i].close; - const p = (Math.log(c) - logMin) / logSpan; + const c = pts[i].value; const x = i * POINT_SPACING; const y = ys[i]; const z = 34 * Math.sin(x * 0.0045 + 1.7) + 14 * Math.sin(x * 0.013 + 0.5); @@ -52,16 +44,16 @@ export function buildTrackData(ride, theme) { const prevMax = runningMax; runningMax = Math.max(runningMax, c); meta.push({ - normPrice: p, + normValue: ride.norm(c), drawdown: prevMax > 0 ? Math.max(0, 1 - c / prevMax) : 0, isATH: c >= runningMax && i > 0, - gain: i > 0 ? c / pts[i - 1].close - 1 : 0, + gain: i > 0 && pts[i - 1].value > 0 ? c / pts[i - 1].value - 1 : 0, trench: false, // filled in below once we know ground height }); } for (let i = 0; i < n; i++) { const cp = controlPoints[i]; - meta[i].trench = cp.y < groundHeight(cp.x, cp.z, ride.symbol) + 2.5; + meta[i].trench = cp.y < groundHeight(cp.x, cp.z, ride.id) + 2.5; } // ATH parties: a new all-time high only deserves confetti after a real dip @@ -235,7 +227,7 @@ export function buildTrackMeshes(track, T, theme) { for (let i = 0; i < track.controlPoints.length; i += 6) { const cp = track.controlPoints[i]; if (track.meta[i].trench) continue; - const gy = groundHeight(cp.x, cp.z, track.ride.symbol); + const gy = groundHeight(cp.x, cp.z, track.ride.id); if (cp.y - gy < 3) continue; pillarPositions.push({ cp, gy }); } diff --git a/test/smoke.mjs b/test/smoke.mjs index ce05bcf..c3362be 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -65,6 +65,19 @@ await page.goto(BASE + '/?ride=PTON&go=1', { waitUntil: 'networkidle' }); await page.waitForTimeout(6000); await page.screenshot({ path: OUT + '06-pton.png' }); +// ---- 5. TOKENS (generic non-stock time series, loaded via ?data=) +await page.goto(BASE + '/?data=data/TOKENS.json&go=1', { waitUntil: 'networkidle' }); +await page.waitForTimeout(2500); +const tokPrice1 = await page.textContent('#hud-price'); +console.log('TOKENS value @2.5s:', JSON.stringify(tokPrice1?.trim().slice(0, 40))); +if (!tokPrice1?.includes('tok')) fail(`expected token unit in HUD value, got ${JSON.stringify(tokPrice1)}`); +await page.keyboard.press('Digit3'); +await page.waitForTimeout(8000); +const tokPrice2 = await page.textContent('#hud-price'); +console.log('TOKENS value @10.5s:', JSON.stringify(tokPrice2?.trim().slice(0, 40))); +if (tokPrice1 === tokPrice2) fail('TOKENS HUD value did not advance — cart appears stuck'); +await page.screenshot({ path: OUT + '07-tokens.png' }); + // ---- console errors const uniq = [...new Set(errors)]; console.log(`\nconsole errors/warnings (${uniq.length}):`);