From 7c0155ba18e3e2808e28837529fcce5d482dead8 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 6 May 2026 12:55:08 +0900 Subject: [PATCH] [#1061] Polish airdrop chart: inside labels, generic numbering, CMC, smooth curve, font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five fixes per user feedback on the chart from #1060: 1. Move milestone label blocks INSIDE the chart, anchored to the LEFT of each vertical line (text-anchor="end", x = lineX - 6). Each block has 4 lines stacked from top of chart: tier title / $value / unlocks % / CMC ≈ rank. 2. Replace "Bronze/Silver/Gold/Diamond" with "MILESTONE 1/2/3/4" in labels and mobile legend ("M1/M2/M3/M4"). Drop TIER_NAMES map and the redundant bottom tier-name X-axis labels. 3. Add "CMC" prefix to ranks ("CMC ≈ #1900") and append "· CMC = CoinMarketCap" to both footnotes (campaign hero footer and the modal footnote). 4. Replace banded log mcapToX with monotone cubic Hermite using Catmull-Rom slopes in (log10 mcap, X) space. C¹-continuous so the curve has no visible slope discontinuity at any milestone knot, while still passing exactly through the 5 knot points (X positions remain 0/0.25/0.5/0.75/1.0). 5. Apply the project's font system: wrap the SVG in so all text inherits Geist Mono per the .font-mono rule in src/app/globals.css. Remove all inline fontFamily="monospace". Mobile legend wrapper also picks up font-mono. Reviewer note: block if labels go back above the chart, tier names return, or curve kink reappears at any milestone knot. Fixes #1061 Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/components/airdrop/CampaignHero.tsx | 254 +++++++++--------------- 2 files changed, 99 insertions(+), 157 deletions(-) diff --git a/package.json b/package.json index 9289a34..9e8d799 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.8.0", + "version": "1.8.1", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 565a43d..86e5ffa 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -27,40 +27,50 @@ interface StatusData { /* ─── Chart helpers ─── */ /** - * 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 → 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 = left, 1 = right). Milestone - * markers are 25% apart and cannot collide regardless of MCap ratios. + * Maps MCap → 0–1 X position with milestones at fixed positions + * (0/0.25/0.5/0.75/1.0). Uses monotone cubic Hermite interpolation in + * (log10(mcap), X) space, with Catmull-Rom slopes at each knot, so the + * function is C¹-continuous everywhere — no slope discontinuities at + * milestone boundaries (which cause visible kinks in the rendered curve). */ function makeMcapToX(milestones: StatusData["milestones"]) { - const knots = [ - milestones.bronze.mcap / 100, - milestones.bronze.mcap, - milestones.silver.mcap, - milestones.gold.mcap, - milestones.diamond.mcap, + const lms = [ + Math.log10(milestones.bronze.mcap / 100), + Math.log10(milestones.bronze.mcap), + Math.log10(milestones.silver.mcap), + Math.log10(milestones.gold.mcap), + Math.log10(milestones.diamond.mcap), ]; - const zoneCount = knots.length - 1; + const xs = [0, 0.25, 0.5, 0.75, 1.0]; + + // Catmull-Rom slopes (centered diff interior, one-sided at ends) + const slopes = lms.map((_, i) => { + if (i === 0) return (xs[1] - xs[0]) / (lms[1] - lms[0]); + if (i === lms.length - 1) return (xs[i] - xs[i - 1]) / (lms[i] - lms[i - 1]); + return (xs[i + 1] - xs[i - 1]) / (lms[i + 1] - lms[i - 1]); + }); + return (mcap: number): number => { - 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 (i - 1 + t) / zoneCount; - } - } - return 1; + if (mcap <= 0) return 0; + const t = Math.log10(mcap); + if (t <= lms[0]) return 0; + if (t >= lms[lms.length - 1]) return 1; + + let i = 1; + while (i < lms.length && t > lms[i]) i++; + + const t0 = lms[i - 1], t1 = lms[i]; + const x0 = xs[i - 1], x1 = xs[i]; + const m0 = slopes[i - 1], m1 = slopes[i]; + const dt = t1 - t0; + const u = (t - t0) / dt; + const u2 = u * u; + const u3 = u2 * u; + const h00 = 2 * u3 - 3 * u2 + 1; + const h10 = u3 - 2 * u2 + u; + const h01 = -2 * u3 + 3 * u2; + const h11 = u3 - u2; + return h00 * x0 + h10 * dt * m0 + h01 * x1 + h11 * dt * m1; }; } @@ -76,13 +86,6 @@ function formatMcap(n: number): string { return `$${n.toFixed(0)}`; } -const TIER_NAMES: Record = { - bronze: "Bronze", - silver: "Silver", - gold: "Gold", - diamond: "Diamond", -}; - const CMC_RANKS: Record = { 1_000_000: "≈ #1900", 10_000_000: "≈ #950", @@ -137,13 +140,12 @@ function MCapChart({ currentFdv: number; milestones: StatusData["milestones"]; }) { - // Banded X-axis (each milestone gets equal 25% of chart width) const mcapToX = makeMcapToX(milestones); - // SVG geometry — area chart with room for top labels and Y-axis ticks + // SVG geometry — area chart, no label space above (labels are inside) const svgW = 600; - const svgH = 280; - const pad = { top: 78, right: 16, bottom: 30, left: 48 }; + const svgH = 240; + const pad = { top: 14, right: 16, bottom: 14, left: 48 }; const chartW = svgW - pad.left - pad.right; const chartH = svgH - pad.top - pad.bottom; @@ -155,41 +157,32 @@ function MCapChart({ ...val, label: formatMcap(val.mcap), cmcRank: CMC_RANKS[val.mcap] ?? "", - tierName: TIER_NAMES[key] ?? key, + milestoneNum: idx + 1, pos: positions[idx] ?? 1, }; }); - // Y-axis: linear, $0 → diamond. 5 evenly-spaced ticks at 0/25/50/75/100% of diamond. + // Y-axis: linear, $0 → diamond. 5 evenly-spaced ticks. 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, - ]; + // Smooth curve: sample 200 points across full log range. Because mcapToX + // is C¹-smooth (Catmull-Rom Hermite), the curve has no slope discontinuities + // at milestone knots. Heartbeat dot lies exactly on the curve. + const lmStart = Math.log10(milestones.bronze.mcap / 100); + const lmEnd = Math.log10(milestones.diamond.mcap); + const numSamples = 200; 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) }); - } + for (let s = 1; s <= numSamples; s++) { + const m = Math.pow(10, lmStart + (lmEnd - lmStart) * (s / numSamples)); + 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 + // Current MCap dot const dotMcap = currentFdv > 0 ? currentFdv : 0; const dotX = toX(mcapToX(dotMcap)); const dotY = toY(dotMcap); @@ -198,7 +191,7 @@ function MCapChart({
- {/* Y-axis tick gridlines (subtle) */} + {/* Y-axis tick gridlines */} {yTicks.map((tick) => { const y = toY(tick); return ( - + ); })} - {/* Vertical band dividers — span full chart height (3 inner + 2 edges) */} + {/* Vertical band dividers (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 ( - - ); - })} - - {/* 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} - - )} - + ); })} - {/* Y-axis tick labels on the left */} + {/* Y-axis tick labels (left) */} {yTicks.map((tick) => { const y = toY(tick); return ( - + {formatMcap(tick)} ); @@ -293,56 +236,55 @@ function MCapChart({ {/* Curve line */} - {/* Heartbeat dot at current MCap (always lies on curve by construction) */} + {/* Milestone label blocks — inside chart, anchored to LEFT of each vertical line */} + + {milestoneEntries.map((ms) => { + const lineX = toX(ms.pos); + const labelX = lineX - 6; + const top = pad.top + 14; + return ( + + + MILESTONE {ms.milestoneNum} + + + {ms.label} + + + unlocks {ms.pct}% + + {ms.cmcRank && ( + + CMC {ms.cmcRank} + + )} + + ); + })} + + + {/* Heartbeat dot at current MCap */} - {/* 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 */} -
+ {/* Mobile milestone legend — Mn / value / pct in 4 columns */} +
{milestoneEntries.map((ms) => (
- {ms.tierName} + M{ms.milestoneNum}
-
{ms.label}
-
{ms.pct}%
+
{ms.label}
+
{ms.pct}%
))}
@@ -436,7 +378,7 @@ export function CampaignHero() { {/* ── MCap explanation footnote ── */}
- MCap = PLOT price × 1M max supply + MCap = PLOT price × 1M max supply · CMC = CoinMarketCap
{/* ── How It Works modal ── */} @@ -556,7 +498,7 @@ export function CampaignHero() { {/* Footnote */}
- MCap = PLOT price × 1M max supply + MCap = PLOT price × 1M max supply · CMC = CoinMarketCap