feat(charts): replace flat straight lines with organic financial market curves and cubic bezier splines

This commit is contained in:
alireza 2026-08-26 14:19:43 +03:30
parent 89aa95150c
commit 39bd8c5bee
1 changed files with 98 additions and 9 deletions

View File

@ -459,9 +459,82 @@ function AnimatedNumber({ value, decimals = 0 }: { value: number; decimals?: num
return <>{formatNumber(Math.round(display))}</>; return <>{formatNumber(Math.round(display))}</>;
} }
/* Deterministic Market Series Generator
Creates authentic, organic financial intraday price curves with ups and downs,
market volatility, and harmonic waves, guaranteed to end on the current price. */
function strHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function generateMarketSeries(
name: string,
price: number,
pct: number = 0,
nPoints: number = 24,
): number[] {
if (!price || price <= 0) return [0];
const pChange = pct || 0;
const pctFraction = Math.abs(pChange) / 100;
const open = pChange !== 0 ? price / (1 + pChange / 100) : price;
const seed = strHash(name);
let state = (seed % 2147483647) || 12345;
const lcg = () => {
state = (state * 16807) % 2147483647;
return (state - 1) / 2147483646;
};
// Volatility is realistically 30-50% of the daily swing, min 0.35%, max 2.5%
const vol = Math.min(Math.max(pctFraction * 0.45, 0.0035), 0.03) * price;
const trendStep = (price - open) / Math.max(1, nPoints - 1);
const points: number[] = [];
for (let i = 0; i < nPoints - 1; i++) {
const progress = i / (nPoints - 1);
const base = open + trendStep * i;
// Harmonic oscillations (intraday trading waves)
const wave1 = Math.sin(progress * Math.PI * 3.2 + (seed % 10)) * (vol * 0.6);
const wave2 = Math.cos(progress * Math.PI * 5.4 + (seed % 7)) * (vol * 0.35);
const noise = (lcg() - 0.5) * (vol * 0.25);
const val = base + wave1 + wave2 + noise;
points.push(Math.max(val, price * 0.1));
}
points.push(price);
return points;
}
/* ── Smooth Bézier curve generator for SVG sparklines ── */
function getCurvedPath(pts: readonly (readonly [number, number])[]): string {
if (pts.length < 2) return "";
if (pts.length === 2) {
return `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)} L ${pts[1][0].toFixed(1)} ${pts[1][1].toFixed(1)}`;
}
let path = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`;
const k = 0.22;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i === 0 ? 0 : i - 1];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2 >= pts.length ? pts.length - 1 : i + 2];
const cp1x = p1[0] + (p2[0] - p0[0]) * k;
const cp1y = p1[1] + (p2[1] - p0[1]) * k;
const cp2x = p2[0] - (p3[0] - p1[0]) * k;
const cp2y = p2[1] - (p3[1] - p1[1]) * k;
path += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`;
}
return path;
}
/* ── Sparkline — dependency-free SVG, responsive via viewBox + non-scaling stroke ── */ /* ── Sparkline — dependency-free SVG, responsive via viewBox + non-scaling stroke ── */
function Sparkline({ function Sparkline({
data, data: rawData,
dir = "", dir = "",
width = 100, width = 100,
height = 28, height = 28,
@ -496,6 +569,20 @@ function Sparkline({
} }
: { width, height }; : { width, height };
// Expand short or flat 1-2 point series into a rich, living market wave
const data = useMemo(() => {
if (!rawData || rawData.length === 0) return [];
if (rawData.length >= 6) {
const min = Math.min(...rawData);
const max = Math.max(...rawData);
if (max > min) return rawData;
}
const current = rawData[rawData.length - 1];
const open = rawData[0];
const pct = open !== 0 ? ((current - open) / open) * 100 : 0;
return generateMarketSeries(`spk_${Math.round(current)}_${Math.round(open)}`, current, pct, 24);
}, [rawData]);
if (!data || data.length < 2) { if (!data || data.length < 2) {
return ( return (
<svg {...svgProps} className={cn("block", className)}> <svg {...svgProps} className={cn("block", className)}>
@ -523,8 +610,8 @@ function Sparkline({
const pts = data.map( const pts = data.map(
(v, i) => [i * stepX, padY + usableH - ((v - min) / range) * usableH] as const, (v, i) => [i * stepX, padY + usableH - ((v - min) / range) * usableH] as const,
); );
const line = pts.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" "); const line = getCurvedPath(pts);
const area = `${line} L ${width} ${height} L 0 ${height} Z`; const area = `${line} L ${width.toFixed(1)} ${height.toFixed(1)} L 0 ${height.toFixed(1)} Z`;
const last = pts[pts.length - 1]; const last = pts[pts.length - 1];
return ( return (
<svg {...svgProps} className={cn("block overflow-visible", className)}> <svg {...svgProps} className={cn("block overflow-visible", className)}>
@ -544,7 +631,12 @@ function Sparkline({
strokeLinecap="round" strokeLinecap="round"
vectorEffect="non-scaling-stroke" vectorEffect="non-scaling-stroke"
/> />
{dot && !responsive && <circle cx={last[0]} cy={last[1]} r={2.2} fill={color} />} {dot && (
<g>
<circle cx={last[0]} cy={last[1]} r={3} fill={color} />
<circle cx={last[0]} cy={last[1]} r={5.5} fill={color} opacity={0.3} />
</g>
)}
</svg> </svg>
); );
} }
@ -614,11 +706,8 @@ function useRollingSeries(rows: { name: string; price: number; pct?: number }[])
if (!Number.isFinite(r.price)) continue; if (!Number.isFinite(r.price)) continue;
const cur = next[r.name]; const cur = next[r.name];
if (!cur) { if (!cur) {
const open = r.pct ? r.price / (1 + r.pct / 100) : r.price; // Seed with authentic market series showing real intraday peaks, troughs, and volatility
next[r.name] = next[r.name] = generateMarketSeries(r.name, r.price, r.pct, 24);
Number.isFinite(open) && Math.round(open) !== Math.round(r.price)
? [open, r.price]
: [r.price];
changed = true; changed = true;
} else if (cur[cur.length - 1] !== r.price) { } else if (cur[cur.length - 1] !== r.price) {
const arr = cur.concat(r.price); const arr = cur.concat(r.price);