From 03ca5fe7c0271627c4ae5969681bb9d5ceeb9e93 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 10:24:31 +0900 Subject: [PATCH 1/3] [#924] Redesign airdrop hero section with bold campaign concept display - CampaignHero: "PLOT Big or Nothing Airdrop" title, tagline, countdown - MilestoneTrack: SVG area/step chart with 4 FDV milestone zones (Bronze/Silver/Gold/Diamond) styled after ultrasound.money - Heartbeat-animated current FDV position dot with pulse ring - Logarithmic x-axis, vertical zone dividers, milestone annotations - PlotLink brown/cream palette, responsive on mobile Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/components/airdrop/CampaignHero.tsx | 126 +++---- src/components/airdrop/MilestoneTrack.tsx | 421 +++++++++++++++++----- 3 files changed, 387 insertions(+), 162 deletions(-) diff --git a/package.json b/package.json index 00464f7f..6fd7875f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.30", + "version": "0.1.31", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/airdrop/CampaignHero.tsx b/src/components/airdrop/CampaignHero.tsx index 545eceb4..bd45889f 100644 --- a/src/components/airdrop/CampaignHero.tsx +++ b/src/components/airdrop/CampaignHero.tsx @@ -34,6 +34,29 @@ function useAirdropStatus() { }); } +function CountdownDisplay({ days }: { days: number }) { + const weeks = Math.floor(days / 7); + const remainingDays = days % 7; + + return ( +
+ {weeks > 0 && ( +
+
{weeks}
+
weeks
+
+ )} + {weeks > 0 && ( +
:
+ )} +
+
{weeks > 0 ? remainingDays : days}
+
days
+
+
+ ); +} + export function CampaignHero() { const { data, isLoading } = useAirdropStatus(); @@ -45,88 +68,33 @@ export function CampaignHero() { ); } - // Find the next milestone target - const nextMilestone = !data.milestones.bronze.reached - ? { name: "Bronze", mcap: data.milestones.bronze.mcap } - : !data.milestones.silver.reached - ? { name: "Silver", mcap: data.milestones.silver.mcap } - : !data.milestones.gold.reached - ? { name: "Gold", mcap: data.milestones.gold.mcap } - : null; - - const mcapProgress = nextMilestone - ? Math.min(100, (data.currentMcap / nextMilestone.mcap) * 100) - : 100; - return ( -
-
-

PLOT 10x Airdrop

-

- {data.poolAmount.toLocaleString()} PLOT locked. Big or nothing. +

+ {/* Title + Tagline */} +
+

+ PLOT Big or Nothing Airdrop +

+

+ {data.poolAmount.toLocaleString()} PLOT locked. Earn or burn.

- {/* Time progress */} -
-
- Time remaining - {data.timeRemainingDays} days -
-
-
-
-
{data.timeElapsedPercent}% elapsed
-
- - {/* Market Cap progress */} -
-
- Market Cap - {formatUsdValue(data.currentMcap)} -
-
-
-
-
- {nextMilestone ? `< ${nextMilestone.name} (${formatUsdValue(nextMilestone.mcap)})` : "Gold reached"} -
-
- - {/* Pool value at next milestone */} - {data.latestPriceUsd != null && data.latestPriceUsd > 0 && ( -
- {nextMilestone ? ( - - Pool value if {nextMilestone.name}:{" "} - - {formatUsdValue( - data.poolAmount * - (data.milestones[ - nextMilestone.name.toLowerCase() as keyof typeof data.milestones - ].pct / 100) * - data.latestPriceUsd - )} - - - ) : ( - - Pool value at Gold:{" "} - - {formatUsdValue( - data.poolAmount * - (data.milestones.gold.pct / 100) * - data.latestPriceUsd - )} - - - )} + {/* Countdown */} + {data.timeRemainingDays > 0 && ( +
+ +
+
+
+
+
+ {data.timeElapsedPercent}% elapsed +
+
)} @@ -142,7 +110,7 @@ export function CampaignHero() {
- {data.latestPriceUsd ? formatUsdValue(data.latestPriceUsd) : "—"} + {data.latestPriceUsd ? formatUsdValue(data.latestPriceUsd) : "\u2014"}
PLOT Price
diff --git a/src/components/airdrop/MilestoneTrack.tsx b/src/components/airdrop/MilestoneTrack.tsx index 912e27e9..6a3a0110 100644 --- a/src/components/airdrop/MilestoneTrack.tsx +++ b/src/components/airdrop/MilestoneTrack.tsx @@ -14,11 +14,90 @@ interface StatusData { }; } -const TIERS = [ - { key: "bronze" as const, label: "Bronze", color: "text-[#cd7f32]" }, - { key: "silver" as const, label: "Silver", color: "text-[#c0c0c0]" }, - { key: "gold" as const, label: "Gold", color: "text-[#ffd700]" }, -]; +/* ─── Chart milestones (presentation layer) ─── */ + +const MAX_SUPPLY = 1_000_000; +const CHART_MILESTONES = [ + { label: "Bronze", emoji: "\uD83E\uDD49", fdv: 1_000_000, poolPct: 10, poolUsd: 5_000 }, + { label: "Silver", emoji: "\uD83E\uDD48", fdv: 10_000_000, poolPct: 30, poolUsd: 150_000 }, + { label: "Gold", emoji: "\uD83E\uDD47", fdv: 50_000_000, poolPct: 50, poolUsd: 1_250_000 }, + { label: "Diamond", emoji: "\uD83D\uDC8E", fdv: 100_000_000, poolPct: 100, poolUsd: 5_000_000 }, +] as const; + +/* ─── SVG layout constants ─── */ + +const SVG_W = 600; +const SVG_H = 300; +const PAD = { top: 40, right: 20, bottom: 50, left: 65 }; +const CHART_W = SVG_W - PAD.left - PAD.right; +const CHART_H = SVG_H - PAD.top - PAD.bottom; + +// Log scale helpers — FDV axis from 10k to 200M +const LOG_MIN = Math.log10(10_000); // 4 +const LOG_MAX = Math.log10(200_000_000); // ~8.3 + +function fdvToX(fdv: number): number { + if (fdv <= 0) return PAD.left; + const logVal = Math.log10(Math.max(fdv, 10_000)); + const t = (logVal - LOG_MIN) / (LOG_MAX - LOG_MIN); + return PAD.left + t * CHART_W; +} + +// Y axis: pool USD value (linear, 0 to 5.5M) +const Y_MAX = 5_500_000; + +function usdToY(usd: number): number { + const t = usd / Y_MAX; + return PAD.top + CHART_H * (1 - t); +} + +/* ─── Y-axis tick values ─── */ +const Y_TICKS = [0, 1_000_000, 2_500_000, 5_000_000]; + +/* ─── Step area path builder ─── */ + +function buildAreaPath(): string { + const baseline = usdToY(0); + // Start at origin + let path = `M ${fdvToX(10_000)} ${baseline}`; + // Step up at each milestone + let prevY = baseline; + for (const m of CHART_MILESTONES) { + const x = fdvToX(m.fdv); + const y = usdToY(m.poolUsd); + // Horizontal to milestone x at previous level + path += ` L ${x} ${prevY}`; + // Step up to new level + path += ` L ${x} ${y}`; + prevY = y; + } + // Extend to right edge at last level + path += ` L ${PAD.left + CHART_W} ${prevY}`; + // Close back down to baseline + path += ` L ${PAD.left + CHART_W} ${baseline}`; + path += " Z"; + return path; +} + +function buildLinePath(): string { + let path = ""; + let prevY = usdToY(0); + for (let i = 0; i < CHART_MILESTONES.length; i++) { + const m = CHART_MILESTONES[i]; + const x = fdvToX(m.fdv); + const y = usdToY(m.poolUsd); + if (i === 0) { + path = `M ${fdvToX(10_000)} ${prevY} L ${x} ${prevY} L ${x} ${y}`; + } else { + path += ` L ${x} ${prevY} L ${x} ${y}`; + } + prevY = y; + } + path += ` L ${PAD.left + CHART_W} ${prevY}`; + return path; +} + +/* ─── Component ─── */ export function MilestoneTrack() { const { data, isLoading } = useQuery({ @@ -39,94 +118,272 @@ export function MilestoneTrack() { ); } - // Progress across all three tiers (0–100 mapped to full track) - const goldMcap = data.milestones.gold.mcap; - const overallProgress = Math.min(100, (data.currentMcap / goldMcap) * 100); + // FDV = price * max supply + const currentFdv = + data.latestPriceUsd != null && data.latestPriceUsd > 0 + ? data.latestPriceUsd * MAX_SUPPLY + : 0; + + // Determine current zone + const currentZone = CHART_MILESTONES.reduce( + (zone, m, i) => (currentFdv >= m.fdv ? i + 1 : zone), + 0, + ); + const currentZoneLabel = + currentZone === 0 + ? "Pre-Bronze" + : CHART_MILESTONES[currentZone - 1].label; + + // Current pool value based on FDV position + const currentPoolUsd = + currentZone > 0 ? CHART_MILESTONES[currentZone - 1].poolUsd : 0; + + const dotX = fdvToX(Math.max(currentFdv, 10_000)); + const dotY = usdToY(currentPoolUsd); + + const areaPath = buildAreaPath(); + const linePath = buildLinePath(); return (
-

Milestone Progress

- - {/* Track bar */} -
-
-
-
+

+ FDV Milestone Chart +

+

+ Pool unlock curve across FDV milestones · FDV = PLOT price × {MAX_SUPPLY.toLocaleString()} max supply +

- {/* Current mcap marker */} -
+ -
-
- {formatUsdValue(data.currentMcap)} -
-
+ + + + + + - {/* Milestone markers */} - {TIERS.map((tier) => { - const milestone = data.milestones[tier.key]; - const position = (milestone.mcap / goldMcap) * 100; - return ( -
-
( + + -
- ); - })} -
+ + {val === 0 ? "$0" : `$${(val / 1_000_000).toFixed(1)}M`} + + + ))} + + {/* Filled area */} + - {/* Tier details */} -
- {TIERS.map((tier) => { - const milestone = data.milestones[tier.key]; - const poolValue = data.poolAmount * (milestone.pct / 100); - return ( -
+ + {/* Vertical zone dividers + annotations */} + {CHART_MILESTONES.map((m) => { + const x = fdvToX(m.fdv); + const y = usdToY(m.poolUsd); + return ( + + {/* Vertical divider */} + + {/* Annotation at top of zone */} + + {m.emoji} {m.label} + + + {m.poolPct}% (${m.poolUsd >= 1_000_000 ? `${(m.poolUsd / 1_000_000).toFixed(1)}M` : `${(m.poolUsd / 1_000).toFixed(0)}K`}) + + {/* Dot at step corner */} + + + ); + })} + + {/* X-axis labels */} + {CHART_MILESTONES.map((m) => ( + -
- {tier.label} - {milestone.reached && " \u2713"} -
-
- {formatUsdValue(milestone.mcap)} -
-
MCap target
-
- {milestone.pct}% · {poolValue.toLocaleString()} PLOT -
- {data.latestPriceUsd != null && data.latestPriceUsd > 0 && ( -
- Pool: {formatUsdValue(poolValue * data.latestPriceUsd)} -
- )} -
- ); - })} + ${m.fdv >= 1_000_000 ? `${m.fdv / 1_000_000}M` : `${m.fdv / 1_000}K`} + + ))} + + {/* X-axis label */} + + FDV → + + + {/* Y-axis label */} + + Pool Value + + + {/* Current FDV indicator */} + {currentFdv > 0 && ( + + {/* Vertical dashed line from dot to x-axis */} + + {/* Pulse ring */} + + + + + {/* Heartbeat dot */} + + + + + {/* FDV label below axis */} + + {formatUsdValue(currentFdv)} + + + Current + + + )} +
- {/* Current position label */} -
- - Current: {formatUsdValue(data.currentMcap)} / {formatUsdValue(goldMcap)} - + {/* Current position summary */} +
+
+ Current FDV: {currentFdv > 0 ? formatUsdValue(currentFdv) : "\u2014"} + · Zone: {currentZoneLabel} +
+ {currentFdv > 0 && currentZone < CHART_MILESTONES.length && ( +
+ Next: {CHART_MILESTONES[currentZone].emoji} {CHART_MILESTONES[currentZone].label} at{" "} + {formatUsdValue(CHART_MILESTONES[currentZone].fdv)} FDV +
+ )}
); From 29ba02267890a58d1e458cd0e19f19786e03c4bb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 10:29:00 +0900 Subject: [PATCH 2/3] [#924] Address review: dynamic poolUsd + SVG accessibility - Compute poolUsd from poolAmount/fdv/pct instead of hardcoded values - Y-axis now scales dynamically based on pool amount - Add role="img" and aria-label to SVG element - Wrap milestone computation in useMemo Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/MilestoneTrack.tsx | 156 ++++++++++++---------- 1 file changed, 85 insertions(+), 71 deletions(-) diff --git a/src/components/airdrop/MilestoneTrack.tsx b/src/components/airdrop/MilestoneTrack.tsx index 6a3a0110..d97eeee1 100644 --- a/src/components/airdrop/MilestoneTrack.tsx +++ b/src/components/airdrop/MilestoneTrack.tsx @@ -1,5 +1,6 @@ "use client"; +import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { formatUsdValue } from "../../../lib/usd-price"; @@ -14,16 +15,29 @@ interface StatusData { }; } -/* ─── Chart milestones (presentation layer) ─── */ +/* ─── Chart tier definitions (presentation layer) ─── */ const MAX_SUPPLY = 1_000_000; -const CHART_MILESTONES = [ - { label: "Bronze", emoji: "\uD83E\uDD49", fdv: 1_000_000, poolPct: 10, poolUsd: 5_000 }, - { label: "Silver", emoji: "\uD83E\uDD48", fdv: 10_000_000, poolPct: 30, poolUsd: 150_000 }, - { label: "Gold", emoji: "\uD83E\uDD47", fdv: 50_000_000, poolPct: 50, poolUsd: 1_250_000 }, - { label: "Diamond", emoji: "\uD83D\uDC8E", fdv: 100_000_000, poolPct: 100, poolUsd: 5_000_000 }, +const CHART_TIERS = [ + { label: "Bronze", emoji: "\uD83E\uDD49", fdv: 1_000_000, poolPct: 10 }, + { label: "Silver", emoji: "\uD83E\uDD48", fdv: 10_000_000, poolPct: 30 }, + { label: "Gold", emoji: "\uD83E\uDD47", fdv: 50_000_000, poolPct: 50 }, + { label: "Diamond", emoji: "\uD83D\uDC8E", fdv: 100_000_000, poolPct: 100 }, ] as const; +/** Pool USD value at a given FDV milestone: poolAmount * (pct/100) * (fdv / maxSupply) */ +function poolUsdAt(fdv: number, poolPct: number, poolAmount: number): number { + return poolAmount * (poolPct / 100) * (fdv / MAX_SUPPLY); +} + +type ChartMilestone = { + fdv: number; + poolUsd: number; + label: string; + emoji: string; + poolPct: number; +}; + /* ─── SVG layout constants ─── */ const SVG_W = 600; @@ -33,8 +47,8 @@ const CHART_W = SVG_W - PAD.left - PAD.right; const CHART_H = SVG_H - PAD.top - PAD.bottom; // Log scale helpers — FDV axis from 10k to 200M -const LOG_MIN = Math.log10(10_000); // 4 -const LOG_MAX = Math.log10(200_000_000); // ~8.3 +const LOG_MIN = Math.log10(10_000); +const LOG_MAX = Math.log10(200_000_000); function fdvToX(fdv: number): number { if (fdv <= 0) return PAD.left; @@ -43,49 +57,35 @@ function fdvToX(fdv: number): number { return PAD.left + t * CHART_W; } -// Y axis: pool USD value (linear, 0 to 5.5M) -const Y_MAX = 5_500_000; - -function usdToY(usd: number): number { - const t = usd / Y_MAX; +function usdToY(usd: number, yMax: number): number { + const t = usd / yMax; return PAD.top + CHART_H * (1 - t); } -/* ─── Y-axis tick values ─── */ -const Y_TICKS = [0, 1_000_000, 2_500_000, 5_000_000]; - -/* ─── Step area path builder ─── */ +/* ─── Path builders ─── */ -function buildAreaPath(): string { - const baseline = usdToY(0); - // Start at origin +function buildAreaPath(milestones: ChartMilestone[], yMax: number): string { + const baseline = usdToY(0, yMax); let path = `M ${fdvToX(10_000)} ${baseline}`; - // Step up at each milestone let prevY = baseline; - for (const m of CHART_MILESTONES) { + for (const m of milestones) { const x = fdvToX(m.fdv); - const y = usdToY(m.poolUsd); - // Horizontal to milestone x at previous level - path += ` L ${x} ${prevY}`; - // Step up to new level - path += ` L ${x} ${y}`; + const y = usdToY(m.poolUsd, yMax); + path += ` L ${x} ${prevY} L ${x} ${y}`; prevY = y; } - // Extend to right edge at last level path += ` L ${PAD.left + CHART_W} ${prevY}`; - // Close back down to baseline - path += ` L ${PAD.left + CHART_W} ${baseline}`; - path += " Z"; + path += ` L ${PAD.left + CHART_W} ${baseline} Z`; return path; } -function buildLinePath(): string { +function buildLinePath(milestones: ChartMilestone[], yMax: number): string { let path = ""; - let prevY = usdToY(0); - for (let i = 0; i < CHART_MILESTONES.length; i++) { - const m = CHART_MILESTONES[i]; + let prevY = usdToY(0, yMax); + for (let i = 0; i < milestones.length; i++) { + const m = milestones[i]; const x = fdvToX(m.fdv); - const y = usdToY(m.poolUsd); + const y = usdToY(m.poolUsd, yMax); if (i === 0) { path = `M ${fdvToX(10_000)} ${prevY} L ${x} ${prevY} L ${x} ${y}`; } else { @@ -110,6 +110,15 @@ export function MilestoneTrack() { staleTime: 60_000, }); + const milestones: ChartMilestone[] = useMemo( + () => + CHART_TIERS.map((t) => ({ + ...t, + poolUsd: poolUsdAt(t.fdv, t.poolPct, data?.poolAmount ?? 50_000), + })), + [data?.poolAmount], + ); + if (isLoading || !data) { return (
@@ -118,6 +127,13 @@ export function MilestoneTrack() { ); } + // Y-axis max with 10% headroom above Diamond + const yMax = milestones[milestones.length - 1].poolUsd * 1.1; + + // Y-axis ticks: evenly space 4 ticks from 0 to Diamond poolUsd + const diamondUsd = milestones[milestones.length - 1].poolUsd; + const yTicks = [0, diamondUsd * 0.2, diamondUsd * 0.5, diamondUsd]; + // FDV = price * max supply const currentFdv = data.latestPriceUsd != null && data.latestPriceUsd > 0 @@ -125,24 +141,21 @@ export function MilestoneTrack() { : 0; // Determine current zone - const currentZone = CHART_MILESTONES.reduce( + const currentZone = milestones.reduce( (zone, m, i) => (currentFdv >= m.fdv ? i + 1 : zone), 0, ); const currentZoneLabel = - currentZone === 0 - ? "Pre-Bronze" - : CHART_MILESTONES[currentZone - 1].label; + currentZone === 0 ? "Pre-Bronze" : milestones[currentZone - 1].label; - // Current pool value based on FDV position const currentPoolUsd = - currentZone > 0 ? CHART_MILESTONES[currentZone - 1].poolUsd : 0; + currentZone > 0 ? milestones[currentZone - 1].poolUsd : 0; const dotX = fdvToX(Math.max(currentFdv, 10_000)); - const dotY = usdToY(currentPoolUsd); + const dotY = usdToY(currentPoolUsd, yMax); - const areaPath = buildAreaPath(); - const linePath = buildLinePath(); + const areaPath = buildAreaPath(milestones, yMax); + const linePath = buildLinePath(milestones, yMax); return (
@@ -150,7 +163,8 @@ export function MilestoneTrack() { FDV Milestone Chart

- Pool unlock curve across FDV milestones · FDV = PLOT price × {MAX_SUPPLY.toLocaleString()} max supply + Pool unlock curve across FDV milestones · FDV = PLOT price + × {MAX_SUPPLY.toLocaleString()} max supply

@@ -158,6 +172,8 @@ export function MilestoneTrack() { viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="w-full h-auto" style={{ minWidth: 320 }} + role="img" + aria-label="FDV milestone chart showing pool value unlock curve across Bronze, Silver, Gold, and Diamond tiers" > @@ -167,26 +183,30 @@ export function MilestoneTrack() { {/* Y-axis grid lines */} - {Y_TICKS.map((val) => ( + {yTicks.map((val) => ( - {val === 0 ? "$0" : `$${(val / 1_000_000).toFixed(1)}M`} + {val === 0 + ? "$0" + : val >= 1_000_000 + ? `$${(val / 1_000_000).toFixed(1)}M` + : `$${(val / 1_000).toFixed(0)}K`} ))} @@ -195,20 +215,14 @@ export function MilestoneTrack() { {/* Step line */} - + {/* Vertical zone dividers + annotations */} - {CHART_MILESTONES.map((m) => { + {milestones.map((m) => { const x = fdvToX(m.fdv); - const y = usdToY(m.poolUsd); + const y = usdToY(m.poolUsd, yMax); return ( - {/* Vertical divider */} - {/* Annotation at top of zone */} - {m.poolPct}% (${m.poolUsd >= 1_000_000 ? `${(m.poolUsd / 1_000_000).toFixed(1)}M` : `${(m.poolUsd / 1_000).toFixed(0)}K`}) + {m.poolPct}% ($ + {m.poolUsd >= 1_000_000 + ? `${(m.poolUsd / 1_000_000).toFixed(1)}M` + : `${(m.poolUsd / 1_000).toFixed(0)}K`} + ) - {/* Dot at step corner */} ( + {milestones.map((m) => ( ))} - {/* X-axis label */} + {/* Axis labels */} FDV → - - {/* Y-axis label */} 0 && ( - {/* Vertical dashed line from dot to x-axis */} 0 ? formatUsdValue(currentFdv) : "\u2014"} · Zone: {currentZoneLabel}
- {currentFdv > 0 && currentZone < CHART_MILESTONES.length && ( + {currentFdv > 0 && currentZone < milestones.length && (
- Next: {CHART_MILESTONES[currentZone].emoji} {CHART_MILESTONES[currentZone].label} at{" "} - {formatUsdValue(CHART_MILESTONES[currentZone].fdv)} FDV + Next: {milestones[currentZone].emoji}{" "} + {milestones[currentZone].label} at{" "} + {formatUsdValue(milestones[currentZone].fdv)} FDV
)}
From d9c96ed70104d9029b2afa72cd81b520745cb437 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Wed, 22 Apr 2026 10:29:56 +0900 Subject: [PATCH 3/3] [#924] Fix FDV dot placement: pulse on x-axis with dashed line up to area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per re1 review — spec says "pulsing dot on the x-axis, with a vertical dashed line up to the area." Moved heartbeat dot to baseline, dashed line now extends upward to the step level, with small marker at intersection. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/MilestoneTrack.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/components/airdrop/MilestoneTrack.tsx b/src/components/airdrop/MilestoneTrack.tsx index d97eeee1..eeee42dc 100644 --- a/src/components/airdrop/MilestoneTrack.tsx +++ b/src/components/airdrop/MilestoneTrack.tsx @@ -307,23 +307,32 @@ export function MilestoneTrack() { Pool Value - {/* Current FDV indicator */} + {/* Current FDV indicator — dot on x-axis, dashed line up to area */} {currentFdv > 0 && ( + {/* Vertical dashed line from x-axis up to the step level */} - {/* Pulse ring */} + {/* Small marker at the step intersection */} + {/* Pulse ring on x-axis */} + - {/* Heartbeat dot */} - + {/* Heartbeat dot on x-axis */} +