Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/app/story/[storylineId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ export default async function StoryPage({ params }: { params: Params }) {
{sl.token_address && priceInfo && (
<PriceChart
tokenAddress={sl.token_address as Address}
totalSupplyRaw={priceInfo.totalSupplyRaw}
currentPriceRaw={priceInfo.priceRaw}
/>
)}
Expand All @@ -195,7 +194,6 @@ export default async function StoryPage({ params }: { params: Params }) {
{priceInfo && (
<PriceChart
tokenAddress={sl.token_address as Address}
totalSupplyRaw={priceInfo.totalSupplyRaw}
currentPriceRaw={priceInfo.priceRaw}
/>
)}
Expand Down
251 changes: 128 additions & 123 deletions src/components/PriceChart.tsx
Original file line number Diff line number Diff line change
@@ -1,120 +1,134 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { type Address, parseUnits, formatUnits } from "viem";
import { publicClient } from "../../lib/rpc";
import { mcv2BondAbi } from "../../lib/price";
import { MCV2_BOND, IS_TESTNET } from "../../lib/contracts/constants";
import { type Address, formatUnits } from "viem";
import { supabase } from "../../lib/supabase";
import { IS_TESTNET } from "../../lib/contracts/constants";

const CHART_W = 320;
const CHART_H = 140;
const PAD = { top: 10, right: 10, bottom: 24, left: 48 };
const PLOT_W = CHART_W - PAD.left - PAD.right;
const PLOT_H = CHART_H - PAD.top - PAD.bottom;
const NUM_POINTS = 20;
const MAX_POINTS = 50;

interface PriceChartProps {
tokenAddress: Address;
totalSupplyRaw: bigint;
currentPriceRaw: bigint;
}

/**
* Lightweight bonding curve chart.
*
* Samples getReserveForToken at evenly spaced supply points to plot
* the price curve, then marks the current supply position.
*/
export function PriceChart({ tokenAddress, totalSupplyRaw, currentPriceRaw }: PriceChartProps) {
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
if (diffDays < 1) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return d.toLocaleDateString([], { month: "short", day: "numeric" });
}

function formatPrice(v: number): string {
if (v === 0) return "0";
if (v < 0.001) return v.toExponential(0);
if (v < 1) return v.toFixed(4);
return v.toFixed(2);
}

export function PriceChart({ tokenAddress, currentPriceRaw }: PriceChartProps) {
const reserveLabel = IS_TESTNET ? "WETH" : "$PLOT";
const currentPrice = Number(formatUnits(currentPriceRaw, 18));

// Sample the bonding curve at multiple supply points
const { data: curvePoints } = useQuery({
queryKey: ["price-curve", tokenAddress],
const { data: tradePoints } = useQuery({
queryKey: ["price-history", tokenAddress],
queryFn: async () => {
// Sample from 1 token to 2x current supply (or a minimum of 100 tokens)
const minMax = parseUnits("100", 18);
const maxSupply =
totalSupplyRaw * BigInt(2) > minMax
? totalSupplyRaw * BigInt(2)
: minMax;

const points: { supply: number; price: number }[] = [];
const step = maxSupply / BigInt(NUM_POINTS);
if (step === BigInt(0)) return [];

// Query cumulative costs for increasing mint amounts from current supply
const promises: Promise<bigint>[] = [];
for (let i = 1; i <= NUM_POINTS; i++) {
const amount = step * BigInt(i);
promises.push(
publicClient
.readContract({
address: MCV2_BOND,
abi: mcv2BondAbi,
functionName: "getReserveForToken",
args: [tokenAddress, amount],
})
.then((r) => (r as readonly [bigint, bigint])[0])
.catch(() => BigInt(0)),
);
}

const cumulativeCosts = await Promise.all(promises);

// Start with the actual current supply/price point
const currentSupplyNum = Number(formatUnits(totalSupplyRaw, 18));
const currentPriceNum = Number(formatUnits(currentPriceRaw, 18));
points.push({ supply: currentSupplyNum, price: currentPriceNum });

// Compute marginal price at each future supply step
let prevCost = BigInt(0);
for (let i = 0; i < cumulativeCosts.length; i++) {
const amount = step * BigInt(i + 1);
const marginalCost = cumulativeCosts[i] - prevCost;
const pricePerToken =
Number(formatUnits(marginalCost, 18)) /
Number(formatUnits(step, 18));
points.push({
supply: currentSupplyNum + Number(formatUnits(amount, 18)),
price: pricePerToken,
});
prevCost = cumulativeCosts[i];
if (!supabase) return [];
const { data } = await supabase
.from("trade_history")
.select("price_per_token, block_timestamp")
.eq("token_address", tokenAddress.toLowerCase())
.order("block_timestamp", { ascending: true });
if (!data || data.length === 0) return [];

// Downsample if too many points
if (data.length <= MAX_POINTS) return data;
const step = (data.length - 1) / (MAX_POINTS - 1);
const sampled = [];
for (let i = 0; i < MAX_POINTS; i++) {
sampled.push(data[Math.round(i * step)]);
}
return points;
return sampled;
},
staleTime: 60000,
staleTime: 30000,
refetchInterval: 30000,
});

if (!curvePoints || curvePoints.length === 0) return null;

// Scale to chart coords
const maxX = Math.max(...curvePoints.map((p) => p.supply));
const maxY = Math.max(...curvePoints.map((p) => p.price));
if (maxX === 0 || maxY === 0) return null;

const scaleX = (v: number) => PAD.left + (v / maxX) * PLOT_W;
const scaleY = (v: number) => PAD.top + PLOT_H - (v / maxY) * PLOT_H;

// Build SVG polyline
const linePoints = curvePoints
.map((p) => `${scaleX(p.supply)},${scaleY(p.price)}`)
const hasData = tradePoints && tradePoints.length > 0;

// Empty state
if (!hasData) {
return (
<section className="border-border mt-4 rounded border px-4 py-4">
<h2 className="text-foreground text-sm font-medium">Price</h2>
<div className="mt-3 flex flex-col items-center justify-center py-6">
<svg width="40" height="40" viewBox="0 0 40 40">
<circle cx="20" cy="20" r="3" fill="var(--accent)" />
<circle cx="20" cy="20" r="3" fill="none" stroke="var(--accent)" strokeWidth="1.5" opacity="0.4">
<animate attributeName="r" values="3;8" dur="1.5s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.4;0" dur="1.5s" repeatCount="indefinite" />
</circle>
</svg>
<p className="text-muted mt-2 text-[10px]">No trading activity yet</p>
{currentPrice > 0 && (
<p className="text-accent mt-1 text-xs font-medium">
{formatPrice(currentPrice)} {reserveLabel}
</p>
)}
</div>
</section>
);
}

// Build points array
const points = tradePoints.map((t) => ({
time: t.block_timestamp,
price: Number(t.price_per_token),
}));

// Scale
const prices = points.map((p) => p.price);
const minY = Math.min(...prices);
const maxY = Math.max(...prices);
const yRange = maxY - minY || maxY || 1;
const yPad = yRange * 0.1;

const scaleX = (i: number) =>
PAD.left + (i / (points.length - 1 || 1)) * PLOT_W;
const scaleY = (v: number) =>
PAD.top + PLOT_H - ((v - (minY - yPad)) / (yRange + yPad * 2)) * PLOT_H;

const linePoints = points
.map((p, i) => `${scaleX(i)},${scaleY(p.price)}`)
.join(" ");

// Current supply marker — uses actual current supply and price
const currentSupply = Number(formatUnits(totalSupplyRaw, 18));
const currentPrice = Number(formatUnits(currentPriceRaw, 18));
const markerX = scaleX(currentSupply);
const markerY = scaleY(currentPrice);
// Last point for pulse marker
const lastIdx = points.length - 1;
const lastX = scaleX(lastIdx);
const lastY = scaleY(points[lastIdx].price);

// Y-axis ticks
const yTicks = [minY, (minY + maxY) / 2, maxY];

// Y-axis labels (3 ticks)
const yTicks = [0, maxY / 2, maxY];
// X-axis labels
const xTicks = [0, maxX / 2, maxX];
// X-axis time labels (first, mid, last)
const xLabels = [
{ idx: 0, label: formatTime(points[0].time) },
{ idx: Math.floor(lastIdx / 2), label: formatTime(points[Math.floor(lastIdx / 2)].time) },
{ idx: lastIdx, label: formatTime(points[lastIdx].time) },
];

return (
<section className="border-border mt-4 rounded border px-4 py-4">
<h2 className="text-foreground text-sm font-medium">Price Curve</h2>
<h2 className="text-foreground text-sm font-medium">Price</h2>
<svg
viewBox={`0 0 ${CHART_W} ${CHART_H}`}
className="mt-2 w-full"
Expand Down Expand Up @@ -144,26 +158,26 @@ export function PriceChart({ tokenAddress, totalSupplyRaw, currentPriceRaw }: Pr
fontSize={8}
fontFamily="monospace"
>
{v < 0.001 ? v.toExponential(0) : v.toFixed(4)}
{formatPrice(v)}
</text>
))}

{/* X-axis labels */}
{xTicks.map((v, i) => (
{/* X-axis time labels */}
{xLabels.map(({ idx, label }) => (
<text
key={`xl-${i}`}
x={scaleX(v)}
key={`xl-${idx}`}
x={scaleX(idx)}
y={CHART_H - 4}
textAnchor="middle"
fill="var(--text-muted)"
fontSize={8}
fontFamily="monospace"
>
{v < 1 ? v.toFixed(1) : Math.round(v).toLocaleString()}
{label}
</text>
))}

{/* Curve */}
{/* Price line */}
<polyline
points={linePoints}
fill="none"
Expand All @@ -172,35 +186,26 @@ export function PriceChart({ tokenAddress, totalSupplyRaw, currentPriceRaw }: Pr
strokeLinejoin="round"
/>

{/* Current supply marker */}
{currentSupply > 0 && (
<>
<line
x1={markerX}
y1={PAD.top}
x2={markerX}
y2={PAD.top + PLOT_H}
stroke="var(--accent-dim)"
strokeWidth={0.5}
strokeDasharray="3,2"
/>
<circle
cx={markerX}
cy={markerY}
r={3}
fill="var(--accent)"
/>
</>
)}
{/* Current price pulse marker */}
<circle
cx={lastX}
cy={lastY}
r={3}
fill="none"
stroke="var(--accent)"
strokeWidth={1.5}
opacity={0.4}
>
<animate attributeName="r" values="3;8" dur="1.5s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.4;0" dur="1.5s" repeatCount="indefinite" />
</circle>
<circle cx={lastX} cy={lastY} r={3} fill="var(--accent)" />
</svg>
<p className="text-muted mt-1 text-[10px]">
Supply vs. price per token ({reserveLabel})
{currentSupply > 0 && (
<span className="text-accent-dim">
{" "}
&middot; current: {currentSupply.toLocaleString()} tokens
</span>
)}
Price per token ({reserveLabel})
<span className="text-accent-dim">
{" "}&middot; latest: {formatPrice(points[lastIdx].price)} {reserveLabel}
</span>
</p>
</section>
);
Expand Down
Loading