diff --git a/docs/mcap-chart-screenshot.png b/docs/mcap-chart-screenshot.png new file mode 100644 index 00000000..7a19d273 Binary files /dev/null and b/docs/mcap-chart-screenshot.png differ diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 3941b8a6..9e0a8434 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -24,39 +24,49 @@ interface StatusData { lockerTx: string | null; } -const MILESTONES = [ - { mcap: 1_000_000, label: "$1M", cmcRank: "≈ #1900", pct: 10, letter: "A" }, - { mcap: 10_000_000, label: "$10M", cmcRank: "≈ #950", pct: 30, letter: "B" }, - { mcap: 50_000_000, label: "$50M", cmcRank: "≈ #400", pct: 50, letter: "C" }, - { mcap: 100_000_000, label: "$100M", cmcRank: "≈ #250", pct: 100, letter: "D" }, -]; - -const MAX_MCAP = 100_000_000; -const ACCENT = "#8B4513"; - -/** Fixed milestone positions at 25/50/75/100% for even visual spacing */ -const MILESTONE_POS = new Map([ - [1_000_000, 0.25], - [10_000_000, 0.50], - [50_000_000, 0.75], - [100_000_000, 1.0], -]); - -/** Map MCap to 0–1 using piecewise linear interpolation between milestones */ -function mcapToX(mcap: number): number { - if (mcap <= 0) return 0; - if (mcap >= MAX_MCAP) return 1; - const thresholds = [0, 1_000_000, 10_000_000, 50_000_000, 100_000_000]; - const positions = [0, 0.25, 0.50, 0.75, 1.0]; - for (let i = 1; i < thresholds.length; i++) { - if (mcap <= thresholds[i]) { - const t = (mcap - thresholds[i - 1]) / (thresholds[i] - thresholds[i - 1]); - return positions[i - 1] + t * (positions[i] - positions[i - 1]); - } - } - return 1; +/* ─── Chart helpers ─── */ + +const Y_FLOOR = 1_000; // $1K minimum for log scale + +/** Map MCap → 0–1 on log Y scale (inverted: 0 = top, 1 = bottom) */ +function mcapToY(mcap: number, yMin: number, yMax: number): number { + if (mcap <= 0) return 1; + const logMin = Math.log10(yMin); + const logMax = Math.log10(yMax); + const clamped = Math.min(Math.max(mcap, yMin), yMax); + return 1 - (Math.log10(clamped) - logMin) / (logMax - logMin); +} + +/** Map date → 0–1 on X (time) axis */ +function dateToX(date: Date, start: Date, end: Date): number { + const range = end.getTime() - start.getTime(); + if (range <= 0) return 0; + return Math.min(1, Math.max(0, (date.getTime() - start.getTime()) / range)); +} + +function formatMcap(n: number): string { + if (n >= 1_000_000) return `$${n / 1_000_000}M`; + if (n >= 1_000) return `$${n / 1_000}K`; + return `$${n}`; } +interface DailyPrice { date: string; fdv: number } + +/** CMC ranks only apply at specific production milestone values */ +const MILESTONE_EXTRA: Record = { + bronze: { letter: "A" }, + silver: { letter: "B" }, + gold: { letter: "C" }, + diamond: { letter: "D" }, +}; + +const CMC_RANKS: Record = { + 1_000_000: "≈ #1900", + 10_000_000: "≈ #950", + 50_000_000: "≈ #400", + 100_000_000: "≈ #250", +}; + /* ─── Helpers ─── */ function useAirdropStatus() { @@ -95,155 +105,237 @@ function useCountdown(endDateStr: string) { return remaining; } -/* ─── MCap Chart ─── */ +/* ─── MCap Time-Series Chart ─── */ + +function MCapChart({ + currentFdv, + campaignStart, + campaignEnd, + milestones, +}: { + currentFdv: number; + campaignStart: string; + campaignEnd: string; + milestones: StatusData["milestones"]; +}) { + const { data: history } = useQuery({ + queryKey: ["airdrop-daily-prices"], + queryFn: async () => { + const res = await fetch("/api/airdrop/daily-prices"); + if (!res.ok) throw new Error("Failed to fetch daily prices"); + return res.json(); + }, + staleTime: 300_000, + }); + + const start = new Date(campaignStart + "T00:00:00Z"); + const end = new Date(campaignEnd + "T00:00:00Z"); + const now = new Date(); -function MCapChart({ currentFdv }: { currentFdv: number }) { - const progress = mcapToX(currentFdv); const svgW = 600; - const svgH = 80; - const pad = { left: 10, right: 10 }; + const svgH = 200; + const pad = { top: 10, right: 10, bottom: 24, left: 10 }; const chartW = svgW - pad.left - pad.right; - const fillX = pad.left + progress * chartW; + const chartH = svgH - pad.top - pad.bottom; + + // Milestone entries — derive labels from config values, cmcRank only for matching prod values + const milestoneEntries = Object.entries(milestones).map(([key, val]) => { + const extra = MILESTONE_EXTRA[key] ?? { letter: key[0].toUpperCase() }; + return { + key, + ...val, + label: formatMcap(val.mcap), + cmcRank: CMC_RANKS[val.mcap] ?? "", + letter: extra.letter, + }; + }); + + // Y-axis domain: floor to diamond milestone (config-driven) + const yMax = milestones.diamond.mcap; + const yMin = Math.min(Y_FLOOR, yMax / 1000); + + // X/Y coordinate helpers (within chart area) + const toX = (d: Date) => pad.left + dateToX(d, start, end) * chartW; + const toY = (mcap: number) => pad.top + mcapToY(mcap, yMin, yMax) * chartH; + + // Historical line path + area fill + const points = (history ?? []).map((p) => ({ + x: toX(new Date(p.date)), + y: toY(p.fdv), + })); + const linePath = points.length > 0 + ? points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ") + : ""; + const areaPath = points.length > 0 + ? `${linePath} L${points[points.length - 1].x},${pad.top + chartH} L${points[0].x},${pad.top + chartH} Z` + : ""; + + // Projection line: start FDV → diamond milestone at campaign end + const startFdv = history && history.length > 0 ? history[0].fdv : currentFdv; + const projX1 = toX(start); + const projY1 = toY(startFdv > 0 ? startFdv : yMin); + const projX2 = toX(end); + const projY2 = toY(yMax); + + // Current dot position + const dotX = toX(now); + const dotY = toY(currentFdv > 0 ? currentFdv : yMin); + + // X-axis time ticks + const totalDays = Math.ceil((end.getTime() - start.getTime()) / 86_400_000); + const tickInterval = totalDays <= 14 ? 1 : totalDays <= 60 ? 7 : 30; + const xTicks: Date[] = []; + for (let d = new Date(start); d <= end; d = new Date(d.getTime() + tickInterval * 86_400_000)) { + xTicks.push(new Date(d)); + } + + // Y-axis ticks at milestone values + floor + const yTicks = [yMin, ...milestoneEntries.map((m) => m.mcap)]; return (
- {/* Desktop labels above chart */} -
- {MILESTONES.map((ms, i) => { - const x = (MILESTONE_POS.get(ms.mcap) ?? 0) * 100; - const isLast = i === MILESTONES.length - 1; - return ( -
-
{ms.label}
-
unlocks {ms.pct}%
-
{ms.cmcRank}
-
- ); - })} -
- - {/* SVG chart */} 0 ? `$${(currentFdv / 1_000_000).toFixed(2)}M` : "$0"} of $100M`} + aria-label={`MCap time-series chart: current $${currentFdv > 0 ? (currentFdv / 1_000).toFixed(0) + "K" : "0"}`} > - - - + + + - {/* Unfilled area */} + {/* Chart background */} - {/* Filled area */} - {progress > 0 && ( - - )} - - {/* Milestone vertical dashed lines */} - {MILESTONES.map((ms) => { - const mx = pad.left + (MILESTONE_POS.get(ms.mcap) ?? 0) * chartW; + {/* Milestone horizontal dashed lines */} + {milestoneEntries.map((ms) => { + const y = toY(ms.mcap); return ( - + + + {/* Right-edge label — desktop only */} + + {ms.label} — unlocks {ms.pct}%{ms.cmcRank ? ` (${ms.cmcRank})` : ""} + + ); })} - {/* Mobile letter markers */} - {MILESTONES.map((ms) => { - const mx = pad.left + (MILESTONE_POS.get(ms.mcap) ?? 0) * chartW; - return ( - - {ms.letter} - - ); - })} + {/* Projection line (dashed) */} + + + {/* Historical area fill */} + {areaPath && ( + + )} - {/* Current MCap line */} - {progress > 0 && ( - )} - {/* Heartbeat dot — inside SVG for pixel-perfect alignment */} - {progress > 0 && ( - <> - - - + + + + + + {/* X-axis time ticks */} + {xTicks.map((d, i) => { + const x = toX(d); + const label = totalDays <= 60 + ? `${d.getUTCMonth() + 1}/${d.getUTCDate()}` + : d.toLocaleString("en", { month: "short", timeZone: "UTC" }); + // Show every other label if crowded + const showLabel = totalDays <= 14 || i % 2 === 0; + return ( + + - - - - )} - + {showLabel && ( + + {label} + + )} + + ); + })} - {/* Scale labels */} -
- $0 - $100M -
+ {/* Y-axis labels — desktop only */} + {yTicks.map((mcap) => ( + + {formatMcap(mcap)} + + ))} + - {/* Mobile legend */} + {/* Mobile milestone legend */}
- {MILESTONES.map((ms) => ( -
+ {milestoneEntries.map((ms) => ( +
{ms.letter}
{ms.label}
@@ -335,8 +427,13 @@ export function CampaignHero() {
)} - {/* ── MCap progress chart ── */} - + {/* ── MCap time-series chart ── */} + {/* ── MCap explanation footnote ── */}