From 8c0a3fbed16b29f404f70a18f39d4a55656bc334 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 6 May 2026 10:55:04 +0900 Subject: [PATCH] [#1055] Redesign airdrop chart as horizontal milestone progression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the time-series approach (vertical milestone lines on a Y-banded log axis with separate historical line + dashed projection) and replace with a 1D horizontal milestone progression chart per user feedback. - X-axis = MCap, banded so each milestone gets equal 25% of width. Reuses the same piecewise-linear-banded log interpolation from #1051, rotated to the X-axis. - 4 vertical dashed milestone markers at 25/50/75/100%. - Top-of-vertical labels (desktop): $X / unlocks Y% / (≈ #Z). - Mobile: 4-column grid below chart with tier name + value + pct. - Single horizontal line is the "MCap path"; thick portion = floor → current, dim portion = current → diamond. Heartbeat dot on the line at current MCap. - No date axis, no projection line, no historical area fill, no daily-prices fetch. - Chart is now 110px tall (vs 200px) — a thin band, not an area. Reviewer: block if any time-axis or projection element returns. Fixes #1055 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/components/airdrop/CampaignHero.tsx | 324 +++++++----------------- 2 files changed, 91 insertions(+), 235 deletions(-) diff --git a/package.json b/package.json index 3a3866f..cd6aeda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.6.3", + "version": "1.7.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index dbdb0c6..927ea05 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -27,21 +27,20 @@ interface StatusData { /* ─── Chart helpers ─── */ /** - * Piecewise-linear-banded Y-axis: each of the 4 milestones occupies an equal - * 25% band of chart height. Within a band, position is log-interpolated + * Piecewise-linear-banded X-axis: each of the 4 milestones occupies an equal + * 25% band of chart width. Within a band, position is log-interpolated * between that band's lower and upper milestone values. * - * knot 0 = bronze / 100 → 100% (chart bottom) - * knot 1 = bronze → 75% - * knot 2 = silver → 50% - * knot 3 = gold → 25% - * knot 4 = diamond → 0% (chart top) + * knot 0 = bronze / 100 → 0% (chart left, "$0") + * knot 1 = bronze → 25% + * knot 2 = silver → 50% + * knot 3 = gold → 75% + * knot 4 = diamond → 100% (chart right) * - * Returns a fn that maps MCap → 0–1 (0 = top, 1 = bottom). Always returns a - * value inside the chart region, so milestone labels are 25% apart and - * cannot collide regardless of MCap ratios. + * Returns a fn that maps MCap → 0–1 (0 = left, 1 = right). Milestone + * markers are 25% apart and cannot collide regardless of MCap ratios. */ -function makeMcapToY(milestones: StatusData["milestones"]) { +function makeMcapToX(milestones: StatusData["milestones"]) { const knots = [ milestones.bronze.mcap / 100, milestones.bronze.mcap, @@ -51,41 +50,31 @@ function makeMcapToY(milestones: StatusData["milestones"]) { ]; const zoneCount = knots.length - 1; return (mcap: number): number => { - if (mcap <= knots[0]) return 1; - if (mcap >= knots[knots.length - 1]) return 0; + if (mcap <= knots[0]) return 0; + if (mcap >= knots[knots.length - 1]) return 1; for (let i = 1; i < knots.length; i++) { if (mcap <= knots[i]) { const t = (Math.log10(mcap) - Math.log10(knots[i - 1])) / (Math.log10(knots[i]) - Math.log10(knots[i - 1])); - return 1 - (i - 1 + t) / zoneCount; + return (i - 1 + t) / zoneCount; } } - return 0; + return 1; }; } -/** 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 TIER_NAMES: Record = { + bronze: "Bronze", + silver: "Silver", + gold: "Gold", + diamond: "Diamond", }; const CMC_RANKS: Record = { @@ -133,262 +122,131 @@ function useCountdown(endDateStr: string) { return remaining; } -/* ─── MCap Time-Series Chart ─── */ +/* ─── MCap Milestone Progression 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(); + // 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 const svgW = 600; - const svgH = 200; - const pad = { top: 10, right: 10, bottom: 24, left: 10 }; + const svgH = 110; + const pad = { top: 50, right: 16, bottom: 14, left: 16 }; const chartW = svgW - pad.left - pad.right; - const chartH = svgH - pad.top - pad.bottom; + const barY = pad.top + (svgH - pad.top - pad.bottom) / 2; - // 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() }; + // Milestone entries — derive label/CMC rank from config so test/prod both work + const milestoneEntries = Object.entries(milestones).map(([key, val], idx) => { + const positions = [0.25, 0.5, 0.75, 1.0]; return { key, ...val, label: formatMcap(val.mcap), cmcRank: CMC_RANKS[val.mcap] ?? "", - letter: extra.letter, + tierName: TIER_NAMES[key] ?? key, + pos: positions[idx] ?? 1, }; }); - // Banded Y-axis (each milestone gets equal 25% of chart height) - const mcapToY = makeMcapToY(milestones); - const floorMcap = milestones.bronze.mcap / 100; - - // 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) * 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 : floorMcap); - const projX2 = toX(end); - const projY2 = toY(milestones.diamond.mcap); - - // Current dot position - const dotX = toX(now); - const dotY = toY(currentFdv > 0 ? currentFdv : floorMcap); - - // 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)); - } - - // Alternating band stripes (subtle visual band separators) - const bands = [ - { yTop: 0, yBottom: 0.25 }, // gold→diamond - { yTop: 0.25, yBottom: 0.5 }, // silver→gold - { yTop: 0.5, yBottom: 0.75 }, // bronze→silver - { yTop: 0.75, yBottom: 1 }, // floor→bronze - ]; + const dotXFrac = mcapToX(currentFdv > 0 ? currentFdv : floorMcap); + const dotX = pad.left + dotXFrac * chartW; + const startX = pad.left; + const endX = pad.left + chartW; return ( -
+
0 ? (currentFdv / 1_000).toFixed(0) + "K" : "0"}`} + aria-label={`MCap milestone chart: current ${formatMcap(currentFdv)} of ${formatMcap(milestones.diamond.mcap)}`} > - - - - - - - - {/* Chart background */} - - - {/* Alternating band stripes — subtle visual separation between zones */} - {bands.map((b, i) => ( - i % 2 === 0 ? ( - - ) : null - ))} - - {/* Milestone horizontal dashed lines (4 lines, 25% apart) */} + {/* 4 vertical milestone markers at 25/50/75/100% */} {milestoneEntries.map((ms) => { - const y = toY(ms.mcap); + const x = pad.left + ms.pos * chartW; return ( ); })} - {/* Right-edge milestone labels — desktop only, never overlap (25% apart) */} + {/* Desktop top-of-vertical labels: $X / unlocks Y% / (≈ #Z) */} {milestoneEntries.map((ms) => { - const y = toY(ms.mcap); - // Diamond (top) milestone: label below the line so it doesn't clip - // out of the chart area; others: above the line (standard). - const isTop = ms.key === "diamond"; + const x = pad.left + ms.pos * chartW; return ( - - {ms.label} — unlocks {ms.pct}%{ms.cmcRank ? ` (${ms.cmcRank})` : ""} - + + + {ms.label} + + + unlocks {ms.pct}% + + {ms.cmcRank && ( + + {ms.cmcRank} + + )} + ); })} - {/* Projection line (dashed) */} - - - {/* Historical area fill */} - {areaPath && ( - - )} - - {/* Historical line */} - {linePath && ( - - )} - - {/* Current MCap heartbeat dot */} - + {/* Endpoint $0 / $100M labels (desktop only) */} + + + $0 + + + + {/* Background line: floor → diamond, dim */} + + + {/* Filled portion: floor → current MCap, solid */} + + + {/* Heartbeat dot at current MCap */} + - - - {/* 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} - - )} - - ); - })} - + - {/* Mobile milestone legend */} -
+ {/* Mobile milestone legend — tier name + value + unlock% in 4 columns under chart */} +
{milestoneEntries.map((ms) => ( -
- {ms.letter} -
-
{ms.label}
-
unlocks {ms.pct}%
-
{ms.cmcRank}
+
+
+ {ms.tierName}
+
{ms.label}
+
{ms.pct}%
))}
+ + {/* Current MCap caption — centered below chart */} +
+ Current: {formatMcap(currentFdv > 0 ? currentFdv : 0)} +
); } @@ -471,11 +329,9 @@ export function CampaignHero() {
)} - {/* ── MCap time-series chart ── */} + {/* ── MCap milestone progression chart ── */}