247 lines
8.3 KiB
TypeScript
247 lines
8.3 KiB
TypeScript
'use client'
|
|
|
|
// gpt idk how to do ts
|
|
|
|
import { useEffect, useRef, useState, useMemo } from 'react'
|
|
import { createChart, ColorType, IChartApi, Time } from 'lightweight-charts'
|
|
import { PricePoint } from '@/types'
|
|
|
|
interface TradingChartProps {
|
|
priceHistory: PricePoint[]
|
|
currentPrice: number
|
|
solPrice?: number
|
|
}
|
|
|
|
function aggregateToCandles(data: { time: number; value: number }[], periodSeconds = 60 * 5) {
|
|
if (data.length === 0) return []
|
|
|
|
const candles = []
|
|
|
|
let currentCandleStartTime = Math.floor(data[0].time / periodSeconds) * periodSeconds
|
|
let currentOpen = data[0].value
|
|
let currentHigh = data[0].value
|
|
let currentLow = data[0].value
|
|
let currentClose = data[0].value
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
const point = data[i]
|
|
if (point.time < currentCandleStartTime + periodSeconds) {
|
|
currentHigh = Math.max(currentHigh, point.value)
|
|
currentLow = Math.min(currentLow, point.value)
|
|
currentClose = point.value
|
|
} else {
|
|
candles.push({
|
|
time: currentCandleStartTime as Time,
|
|
open: currentOpen,
|
|
high: currentHigh,
|
|
low: currentLow,
|
|
close: currentClose,
|
|
})
|
|
|
|
currentOpen = currentClose
|
|
currentCandleStartTime = Math.floor(point.time / periodSeconds) * periodSeconds
|
|
currentHigh = point.value
|
|
currentLow = point.value
|
|
currentClose = point.value
|
|
}
|
|
}
|
|
|
|
candles.push({
|
|
time: currentCandleStartTime as Time,
|
|
open: currentOpen,
|
|
high: currentHigh,
|
|
low: currentLow,
|
|
close: currentClose,
|
|
})
|
|
|
|
return candles
|
|
}
|
|
|
|
export function TradingChart({ priceHistory, currentPrice, solPrice = 0 }: TradingChartProps) {
|
|
const chartContainerRef = useRef<HTMLDivElement>(null)
|
|
const chartRef = useRef<IChartApi | null>(null)
|
|
const [showUsd, setShowUsd] = useState(true)
|
|
|
|
const baseData = useMemo(() => {
|
|
return priceHistory.map(point => ({
|
|
time: (new Date(point.timestamp).getTime() / 1000),
|
|
value: showUsd ? point.price * (solPrice || 1) : point.price
|
|
})).sort((a, b) => (a.time as number) - (b.time as number))
|
|
}, [priceHistory, showUsd, solPrice])
|
|
|
|
const displayData = useMemo(() => {
|
|
const data = [...baseData]
|
|
if (data.length > 0) {
|
|
const lastTime = data[data.length - 1].time
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const currentVal = showUsd ? currentPrice * (solPrice || 1) : currentPrice
|
|
|
|
if (now > lastTime) {
|
|
data.push({ time: now, value: currentVal })
|
|
} else {
|
|
data[data.length - 1].value = currentVal
|
|
}
|
|
} else {
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const currentVal = showUsd ? currentPrice * (solPrice || 1) : currentPrice
|
|
data.push({ time: now, value: currentVal })
|
|
}
|
|
return data
|
|
}, [baseData, currentPrice, solPrice, showUsd])
|
|
|
|
|
|
const startPrice = displayData[0]?.value || 0
|
|
const endPrice = displayData[displayData.length - 1]?.value || 0
|
|
const priceChange = startPrice !== 0 ? ((endPrice - startPrice) / startPrice) * 100 : 0
|
|
const isPositive = priceChange >= 0
|
|
|
|
const displayPrice = endPrice
|
|
|
|
const formatPrice = (price: number) => {
|
|
if (price === 0) return '0.00'
|
|
if (price < 0.00000001) return price.toFixed(12)
|
|
if (price < 0.000001) return price.toFixed(9)
|
|
if (price < 0.001) return price.toFixed(7)
|
|
if (price < 1) return price.toFixed(5)
|
|
return price.toFixed(3)
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!chartContainerRef.current) return
|
|
|
|
const handleResize = () => {
|
|
chartRef.current?.applyOptions({ width: chartContainerRef.current!.clientWidth })
|
|
}
|
|
|
|
const chart = createChart(chartContainerRef.current, {
|
|
layout: {
|
|
background: { type: ColorType.Solid, color: 'transparent' },
|
|
textColor: '#9ca3af',
|
|
},
|
|
width: chartContainerRef.current.clientWidth,
|
|
height: 500,
|
|
grid: {
|
|
vertLines: { color: 'rgba(42, 46, 57, 0.1)' },
|
|
horzLines: { color: 'rgba(42, 46, 57, 0.1)' },
|
|
},
|
|
rightPriceScale: {
|
|
borderColor: 'rgba(197, 203, 206, 0.1)',
|
|
},
|
|
timeScale: {
|
|
borderColor: 'rgba(197, 203, 206, 0.1)',
|
|
timeVisible: true,
|
|
secondsVisible: false,
|
|
rightOffset: 12,
|
|
barSpacing: 6,
|
|
},
|
|
crosshair: {
|
|
mode: 1, // @type CrosshairMode.Normal
|
|
}
|
|
})
|
|
|
|
// Create Candle Series
|
|
let series: any;
|
|
|
|
try {
|
|
// Standard v3/v4 way
|
|
series = (chart as any).addCandlestickSeries({
|
|
upColor: '#10b981',
|
|
downColor: '#ef4444',
|
|
borderVisible: false,
|
|
wickUpColor: '#10b981',
|
|
wickDownColor: '#ef4444',
|
|
priceFormat: {
|
|
type: 'custom',
|
|
formatter: (price: number) => {
|
|
if (price < 0.000001) return price.toFixed(9)
|
|
if (price < 0.001) return price.toFixed(6)
|
|
if (price < 1) return price.toFixed(4)
|
|
return price.toFixed(2)
|
|
},
|
|
minMove: 0.000000001,
|
|
}
|
|
})
|
|
|
|
// Aggregate data into candles (5 min intervals? or appropriate based on history length)
|
|
// If history is short (new coin), use small interval (1 min)
|
|
// If long, use longer.
|
|
const duration = displayData.length > 0 ? displayData[displayData.length - 1].time - displayData[0].time : 0
|
|
const interval = duration < 3600 ? 60 : 300 // 1 min vs 5 min
|
|
|
|
const candleData = aggregateToCandles(displayData, interval)
|
|
series.setData(candleData)
|
|
|
|
// Removed fitContent to avoid "zoomed all the way in" look on load
|
|
// chart.timeScale().fitContent()
|
|
chartRef.current = chart
|
|
|
|
} catch (e) {
|
|
console.error("Failed to chart series:", e)
|
|
}
|
|
|
|
window.addEventListener('resize', handleResize)
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', handleResize)
|
|
chart.remove()
|
|
}
|
|
}, [displayData, isPositive])
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-border/50 bg-card/30 p-6 backdrop-blur-sm">
|
|
<div className="mb-6 flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-sm text-muted-foreground font-mono">
|
|
{priceHistory.length > 0 ? new Date(priceHistory[0].timestamp).toLocaleDateString() : 'Today'}
|
|
{' '}-{' '}
|
|
Live
|
|
</h2>
|
|
<button
|
|
onClick={() => setShowUsd(!showUsd)}
|
|
className="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground hover:bg-muted/80 hover:text-foreground"
|
|
>
|
|
Switch to {showUsd ? 'SOL' : 'USD'}
|
|
</button>
|
|
</div>
|
|
<div className="mt-1 flex items-baseline gap-3">
|
|
<span className={`font-mono text-3xl font-semibold ${isPositive ? 'text-[#10b981]' : 'text-[#ef4444]'}`}>
|
|
{showUsd ? (
|
|
'$'
|
|
) : (
|
|
<div
|
|
className="inline-block h-6 w-6 mr-3 mt-1 bg-current"
|
|
style={{
|
|
mask: 'url(/solana.svg) center / contain no-repeat',
|
|
WebkitMask: 'url(/solana.svg) center / contain no-repeat',
|
|
}}
|
|
/>
|
|
)}
|
|
{formatPrice(displayPrice)}
|
|
</span>
|
|
<span className={`flex items-center gap-1 text-sm font-medium ${
|
|
isPositive ? 'text-[#10b981]' : 'text-[#ef4444]'
|
|
}`}>
|
|
{isPositive ? '↑' : '↓'}{Math.abs(priceChange).toFixed(2)}%
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground font-mono mt-1">
|
|
MCAP: {showUsd ? '$' : (
|
|
<div
|
|
className="inline-block h-3 w-3 mr-2 opacity-50 bg-current"
|
|
style={{
|
|
mask: 'url(/solana.svg) center / contain no-repeat',
|
|
WebkitMask: 'url(/solana.svg) center / contain no-repeat',
|
|
}}
|
|
/>
|
|
)}
|
|
{formatPrice(displayPrice * 1_000_000_000)} {showUsd ? 'USD' : 'SOL'}
|
|
{/* !! note: this is a rough estimation based on 1b supply for display only! */}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref={chartContainerRef} className="w-full" />
|
|
</div>
|
|
)
|
|
}
|