forked from ourworld_web/www_engage_os
251 lines
8.1 KiB
TypeScript
251 lines
8.1 KiB
TypeScript
'use client'
|
|
|
|
import { useId, useRef, useState } from 'react'
|
|
import clsx from 'clsx'
|
|
import { motion, useInView, useMotionValue } from 'framer-motion'
|
|
|
|
import { AppScreen } from '@/components/AppScreen'
|
|
|
|
const prices = [
|
|
997.56, 944.34, 972.25, 832.4, 888.76, 834.8, 805.56, 767.38, 861.21, 669.6,
|
|
694.39, 721.32, 694.03, 610.1, 502.2, 549.56, 611.03, 583.4, 610.14, 660.6,
|
|
752.11, 721.19, 638.89, 661.7, 694.51, 580.3, 638.0, 613.3, 651.64, 560.51,
|
|
611.45, 670.68, 752.56,
|
|
]
|
|
const maxPrice = Math.max(...prices)
|
|
const minPrice = Math.min(...prices)
|
|
|
|
function Chart({
|
|
className,
|
|
activePointIndex,
|
|
onChangeActivePointIndex,
|
|
width: totalWidth,
|
|
height: totalHeight,
|
|
paddingX = 0,
|
|
paddingY = 0,
|
|
gridLines = 6,
|
|
...props
|
|
}: React.ComponentPropsWithoutRef<'svg'> & {
|
|
activePointIndex: number | null
|
|
onChangeActivePointIndex: (index: number | null) => void
|
|
width: number
|
|
height: number
|
|
paddingX?: number
|
|
paddingY?: number
|
|
gridLines?: number
|
|
}) {
|
|
let width = totalWidth - paddingX * 2
|
|
let height = totalHeight - paddingY * 2
|
|
|
|
let id = useId()
|
|
let svgRef = useRef<React.ElementRef<'svg'>>(null)
|
|
let pathRef = useRef<React.ElementRef<'path'>>(null)
|
|
let isInView = useInView(svgRef, { amount: 0.5, once: true })
|
|
let pathWidth = useMotionValue(0)
|
|
let [interactionEnabled, setInteractionEnabled] = useState(false)
|
|
|
|
let path = ''
|
|
let points: Array<{ x: number; y: number }> = []
|
|
|
|
for (let index = 0; index < prices.length; index++) {
|
|
let x = paddingX + (index / (prices.length - 1)) * width
|
|
let y =
|
|
paddingY +
|
|
(1 - (prices[index] - minPrice) / (maxPrice - minPrice)) * height
|
|
points.push({ x, y })
|
|
path += `${index === 0 ? 'M' : 'L'} ${x.toFixed(4)} ${y.toFixed(4)}`
|
|
}
|
|
|
|
return (
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
|
|
className={clsx(className, 'overflow-visible')}
|
|
{...(interactionEnabled
|
|
? {
|
|
onPointerLeave: () => onChangeActivePointIndex(null),
|
|
onPointerMove: (event) => {
|
|
let x = event.nativeEvent.offsetX
|
|
let closestPointIndex: number | null = null
|
|
let closestDistance = Infinity
|
|
for (
|
|
let pointIndex = 0;
|
|
pointIndex < points.length;
|
|
pointIndex++
|
|
) {
|
|
let point = points[pointIndex]
|
|
let distance = Math.abs(point.x - x)
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance
|
|
closestPointIndex = pointIndex
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
onChangeActivePointIndex(closestPointIndex)
|
|
},
|
|
}
|
|
: {})}
|
|
{...props}
|
|
>
|
|
<defs>
|
|
<clipPath id={`${id}-clip`}>
|
|
<path d={`${path} V ${height + paddingY} H ${paddingX} Z`} />
|
|
</clipPath>
|
|
<linearGradient id={`${id}-gradient`} x1="0" x2="0" y1="0" y2="1">
|
|
<stop offset="0%" stopColor="#13B5C8" />
|
|
<stop offset="100%" stopColor="#13B5C8" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
{[...Array(gridLines - 1).keys()].map((index) => (
|
|
<line
|
|
key={index}
|
|
stroke="#a3a3a3"
|
|
opacity="0.1"
|
|
x1="0"
|
|
y1={(totalHeight / gridLines) * (index + 1)}
|
|
x2={totalWidth}
|
|
y2={(totalHeight / gridLines) * (index + 1)}
|
|
/>
|
|
))}
|
|
<motion.rect
|
|
y={paddingY}
|
|
width={pathWidth}
|
|
height={height}
|
|
fill={`url(#${id}-gradient)`}
|
|
clipPath={`url(#${id}-clip)`}
|
|
opacity="0.5"
|
|
/>
|
|
<motion.path
|
|
ref={pathRef}
|
|
d={path}
|
|
fill="none"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
initial={{ pathLength: 0 }}
|
|
transition={{ duration: 1 }}
|
|
{...(isInView ? { stroke: '#06b6d4', animate: { pathLength: 1 } } : {})}
|
|
onUpdate={({ pathLength }) => {
|
|
if (pathRef.current && typeof pathLength === 'number') {
|
|
pathWidth.set(
|
|
pathRef.current.getPointAtLength(
|
|
pathLength * pathRef.current.getTotalLength(),
|
|
).x,
|
|
)
|
|
}
|
|
}}
|
|
onAnimationComplete={() => setInteractionEnabled(true)}
|
|
/>
|
|
{activePointIndex !== null && (
|
|
<>
|
|
<line
|
|
x1="0"
|
|
y1={points[activePointIndex].y}
|
|
x2={totalWidth}
|
|
y2={points[activePointIndex].y}
|
|
stroke="#06b6d4"
|
|
strokeDasharray="1 3"
|
|
/>
|
|
<circle
|
|
r="4"
|
|
cx={points[activePointIndex].x}
|
|
cy={points[activePointIndex].y}
|
|
fill="#fff"
|
|
strokeWidth="2"
|
|
stroke="#06b6d4"
|
|
/>
|
|
</>
|
|
)}
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function AppDemo() {
|
|
let [activePointIndex, setActivePointIndex] = useState<number | null>(null)
|
|
let activePriceIndex = activePointIndex ?? prices.length - 1
|
|
let activeValue = prices[activePriceIndex]
|
|
let previousValue = prices[activePriceIndex - 1]
|
|
let percentageChange =
|
|
activePriceIndex === 0
|
|
? null
|
|
: ((activeValue - previousValue) / previousValue) * 100
|
|
|
|
return (
|
|
<AppScreen>
|
|
<AppScreen.Body>
|
|
<div className="p-4">
|
|
<div className="flex gap-2">
|
|
<div className="text-xs/6 text-gray-500">Tailwind Labs, Inc.</div>
|
|
<div className="text-sm text-gray-900">$CSS</div>
|
|
<svg viewBox="0 0 24 24" className="ml-auto h-6 w-6" fill="none">
|
|
<path
|
|
d="M5 12a7 7 0 1 1 14 0 7 7 0 0 1-14 0ZM12 9v6M15 12H9"
|
|
stroke="#171717"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div className="mt-3 border-t border-gray-200 pt-5">
|
|
<div className="flex items-baseline gap-2">
|
|
<div className="text-2xl tracking-tight text-gray-900 tabular-nums">
|
|
{activeValue.toFixed(2)}
|
|
</div>
|
|
<div className="text-sm text-gray-900">USD</div>
|
|
{percentageChange && (
|
|
<div
|
|
className={clsx(
|
|
'ml-auto text-sm tracking-tight tabular-nums',
|
|
percentageChange >= 0 ? 'text-cyan-500' : 'text-gray-500',
|
|
)}
|
|
>
|
|
{`${
|
|
percentageChange >= 0 ? '+' : ''
|
|
}${percentageChange.toFixed(2)}%`}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="mt-6 flex gap-4 text-xs text-gray-500">
|
|
<div>1D</div>
|
|
<div>5D</div>
|
|
<div className="font-semibold text-cyan-600">1M</div>
|
|
<div>6M</div>
|
|
<div>1Y</div>
|
|
<div>5Y</div>
|
|
</div>
|
|
<div className="mt-3 rounded-lg bg-gray-50 ring-1 ring-black/5 ring-inset">
|
|
<Chart
|
|
width={286}
|
|
height={208}
|
|
paddingX={16}
|
|
paddingY={32}
|
|
activePointIndex={activePointIndex}
|
|
onChangeActivePointIndex={setActivePointIndex}
|
|
/>
|
|
</div>
|
|
<div className="mt-4 rounded-lg bg-cyan-500 px-4 py-2 text-center text-sm font-semibold text-white">
|
|
Trade
|
|
</div>
|
|
<div className="mt-3 divide-y divide-gray-100 text-sm">
|
|
<div className="flex justify-between py-1">
|
|
<div className="text-gray-500">Open</div>
|
|
<div className="font-medium text-gray-900">6,387.55</div>
|
|
</div>
|
|
<div className="flex justify-between py-1">
|
|
<div className="text-gray-500">Closed</div>
|
|
<div className="font-medium text-gray-900">6,487.09</div>
|
|
</div>
|
|
<div className="flex justify-between py-1">
|
|
<div className="text-gray-500">Low</div>
|
|
<div className="font-medium text-gray-900">6,322.01</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppScreen.Body>
|
|
</AppScreen>
|
|
)
|
|
}
|