diff --git a/package.json b/package.json index 4abc81c..9289a34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.7.1", + "version": "1.8.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index e9bff43..565a43d 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -128,7 +128,7 @@ function useCountdown(endDateStr: string) { return remaining; } -/* ─── MCap Milestone Progression Chart ─── */ +/* ─── MCap Milestone Area Chart ─── */ function MCapChart({ currentFdv, @@ -139,14 +139,13 @@ function MCapChart({ }) { // Banded X-axis (each milestone gets equal 25% of chart width) const mcapToX = makeMcapToX(milestones); - const floorMcap = milestones.bronze.mcap / 100; - // SVG geometry — short horizontal band, room for top labels on desktop + // SVG geometry — area chart with room for top labels and Y-axis ticks const svgW = 600; - const svgH = 110; - const pad = { top: 50, right: 50, bottom: 14, left: 24 }; + const svgH = 280; + const pad = { top: 78, right: 16, bottom: 30, left: 48 }; const chartW = svgW - pad.left - pad.right; - const barY = pad.top + (svgH - pad.top - pad.bottom) / 2; + const chartH = svgH - pad.top - pad.bottom; // Milestone entries — derive label/CMC rank from config so test/prod both work const milestoneEntries = Object.entries(milestones).map(([key, val], idx) => { @@ -161,10 +160,39 @@ function MCapChart({ }; }); - const dotXFrac = mcapToX(currentFdv > 0 ? currentFdv : floorMcap); - const dotX = pad.left + dotXFrac * chartW; - const startX = pad.left; - const endX = pad.left + chartW; + // Y-axis: linear, $0 → diamond. 5 evenly-spaced ticks at 0/25/50/75/100% of diamond. + const yMax = milestones.diamond.mcap; + const yTicks = [0, 0.25, 0.5, 0.75, 1.0].map((f) => f * yMax); + const toX = (xFrac: number) => pad.left + xFrac * chartW; + const toY = (mcap: number) => pad.top + (1 - Math.min(1, Math.max(0, mcap / yMax))) * chartH; + + // Smooth curve: sample 20 points per band using the same banded log + // interpolation as mcapToX, so the curve passes exactly through the + // milestone knots and the heartbeat dot lies exactly on the curve. + const knots = [ + milestones.bronze.mcap / 100, + milestones.bronze.mcap, + milestones.silver.mcap, + milestones.gold.mcap, + milestones.diamond.mcap, + ]; + const curve: { x: number; y: number }[] = [{ x: toX(0), y: toY(0) }]; + for (let i = 1; i < knots.length; i++) { + const lo = Math.log10(knots[i - 1]); + const hi = Math.log10(knots[i]); + const samples = 24; + for (let s = 1; s <= samples; s++) { + const m = Math.pow(10, lo + (hi - lo) * (s / samples)); + curve.push({ x: toX(mcapToX(m)), y: toY(m) }); + } + } + const linePath = curve.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" "); + const areaPath = `${linePath} L${curve[curve.length - 1].x},${pad.top + chartH} L${curve[0].x},${pad.top + chartH} Z`; + + // Current MCap dot — clamp at floor so dot is always visible if currentFdv = 0 + const dotMcap = currentFdv > 0 ? currentFdv : 0; + const dotX = toX(mcapToX(dotMcap)); + const dotY = toY(dotMcap); return (
@@ -173,67 +201,137 @@ function MCapChart({ className="w-full" preserveAspectRatio="xMidYMid meet" role="img" - aria-label={`MCap milestone chart: current ${formatMcap(currentFdv)} of ${formatMcap(milestones.diamond.mcap)}`} + aria-label={`MCap area chart: current ${formatMcap(currentFdv)} of ${formatMcap(yMax)}`} > - {/* 4 vertical milestone markers at 25/50/75/100% */} - {milestoneEntries.map((ms) => { - const x = pad.left + ms.pos * chartW; + + + + + + + + {/* Y-axis tick gridlines (subtle) */} + {yTicks.map((tick) => { + const y = toY(tick); + return ( + + ); + })} + + {/* Vertical band dividers — span full chart height (3 inner + 2 edges) */} + {[0, 0.25, 0.5, 0.75, 1].map((frac) => { + const x = toX(frac); + const isEdge = frac === 0 || frac === 1; return ( ); })} - {/* Desktop top-of-vertical labels: $X / unlocks Y% / (≈ #Z) */} - - {milestoneEntries.map((ms) => { - const x = pad.left + ms.pos * chartW; - return ( - - - {ms.label} - - - unlocks {ms.pct}% + {/* Top-of-band labels: TIER / $value / unlocks Y% / (≈ #Z), centered in each band column */} + {milestoneEntries.map((ms, i) => { + // Bronze owns band 0 (0-25%), Silver band 1 (25-50%), etc. Center = (i + 0.5) / 4. + const cx = toX((i + 0.5) / 4); + return ( + + + {ms.tierName.toUpperCase()} + + + {ms.label} + + + unlocks {ms.pct}% + + {ms.cmcRank && ( + + {ms.cmcRank} - {ms.cmcRank && ( - - {ms.cmcRank} - - )} - - ); - })} - - - {/* Endpoint $0 / $100M labels (desktop only) */} - - - $0 - - - - {/* Background line: floor → diamond, dim */} - - - {/* Filled portion: floor → current MCap, solid */} - - - {/* Heartbeat dot at current MCap */} - + )} + + ); + })} + + {/* Y-axis tick labels on the left */} + {yTicks.map((tick) => { + const y = toY(tick); + return ( + + {formatMcap(tick)} + + ); + })} + + {/* Area fill below curve */} + + + {/* Curve line */} + + + {/* Heartbeat dot at current MCap (always lies on curve by construction) */} + - + + + {/* Bottom tier-name labels under each band (desktop only) */} + {milestoneEntries.map((ms, i) => { + const cx = toX((i + 0.5) / 4); + return ( + + {ms.tierName.toLowerCase()} + + ); + })} + + {/* Current MCap caption right above the dot */} + + {formatMcap(currentFdv)} + {/* Mobile milestone legend — tier name + value + unlock% in 4 columns under chart */} @@ -248,11 +346,6 @@ function MCapChart({
))} - - {/* Current MCap caption — centered below chart */} -
- Current: {formatMcap(currentFdv > 0 ? currentFdv : 0)} -
); }