Update chars

This commit is contained in:
Andras Schmelczer 2026-02-07 10:00:13 +00:00
parent 609dd5278c
commit 46585a4b2b
8 changed files with 522 additions and 285 deletions

View file

@ -0,0 +1,169 @@
import { useMemo } from 'react';
import type { PricePoint } from '../types';
import { formatValue } from '../lib/format';
interface PriceHistoryChartProps {
points: PricePoint[];
}
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
const HEIGHT = 120;
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
const { yearMin, yearMax, priceMin, priceMax, averages, priceTicks } = useMemo(() => {
let yMin = Infinity,
yMax = -Infinity,
pMin = Infinity,
pMax = -Infinity;
for (const p of points) {
if (p.year < yMin) yMin = p.year;
if (p.year > yMax) yMax = p.year;
if (p.price < pMin) pMin = p.price;
if (p.price > pMax) pMax = p.price;
}
// Add 5% padding to price range
const pRange = pMax - pMin || 1;
pMin = Math.max(0, pMin - pRange * 0.05);
pMax = pMax + pRange * 0.05;
// Yearly averages
const byYear = new Map<number, { sum: number; count: number }>();
for (const p of points) {
const yr = Math.floor(p.year);
const entry = byYear.get(yr);
if (entry) {
entry.sum += p.price;
entry.count += 1;
} else {
byYear.set(yr, { sum: p.price, count: 1 });
}
}
const avgs = Array.from(byYear.entries())
.map(([yr, { sum, count }]) => ({ year: yr + 0.5, price: sum / count }))
.sort((a, b) => a.year - b.year);
// Price ticks (3-5 nice round numbers)
const ticks = niceTicksForRange(pMin, pMax, 4);
return { yearMin: yMin, yearMax: yMax, priceMin: pMin, priceMax: pMax, averages: avgs, priceTicks: ticks };
}, [points]);
const scaleY = (price: number) => {
const ratio = (price - priceMin) / (priceMax - priceMin || 1);
return PADDING.top + (1 - ratio) * (HEIGHT - PADDING.top - PADDING.bottom);
};
const yearRange = yearMax - yearMin || 1;
// Year labels: every 5 years
const yearStart = Math.ceil(yearMin / 5) * 5;
const yearLabels: number[] = [];
for (let y = yearStart; y <= yearMax; y += 5) yearLabels.push(y);
const VB_W = 1000;
const scaleX = (year: number) => {
const ratio = (year - yearMin) / yearRange;
return PADDING.left + ratio * (VB_W - PADDING.left - PADDING.right);
};
const avgPolyline = averages.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
return (
<svg
viewBox={`0 0 ${VB_W} ${HEIGHT}`}
preserveAspectRatio="none"
className="w-full"
style={{ height: HEIGHT }}
>
{/* Grid lines */}
{priceTicks.map((tick) => (
<line
key={tick}
x1={PADDING.left}
y1={scaleY(tick)}
x2={VB_W - PADDING.right}
y2={scaleY(tick)}
className="stroke-warm-200 dark:stroke-warm-700"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
))}
{/* Dots */}
{points.map((p, i) => (
<circle
key={i}
cx={scaleX(p.year)}
cy={scaleY(p.price)}
r={4}
className="fill-teal-500 dark:fill-teal-400"
opacity={0.35}
/>
))}
{/* Average line */}
{averages.length > 1 && (
<polyline
points={avgPolyline}
fill="none"
className="stroke-teal-600 dark:stroke-teal-400"
strokeWidth={3}
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
)}
{/* Y-axis labels */}
{priceTicks.map((tick) => (
<text
key={`label-${tick}`}
x={PADDING.left - 4}
y={scaleY(tick)}
textAnchor="end"
dominantBaseline="middle"
className="fill-warm-500 dark:fill-warm-400"
style={{ fontSize: 28 }}
>
{formatValue(tick)}
</text>
))}
{/* X-axis year labels */}
{yearLabels.map((yr) => (
<text
key={yr}
x={scaleX(yr)}
y={HEIGHT - 2}
textAnchor="middle"
className="fill-warm-500 dark:fill-warm-400"
style={{ fontSize: 28 }}
>
{yr}
</text>
))}
</svg>
);
}
/** Generate ~count nice round tick values spanning [min, max]. */
function niceTicksForRange(min: number, max: number, count: number): number[] {
const range = max - min;
if (range <= 0) return [min];
const rough = range / count;
// Round to a nice step: 1, 2, 5, 10, 20, 50, 100k, 200k, 500k, etc.
const magnitude = Math.pow(10, Math.floor(Math.log10(rough)));
let step: number;
const normalized = rough / magnitude;
if (normalized <= 1.5) step = magnitude;
else if (normalized <= 3.5) step = 2 * magnitude;
else if (normalized <= 7.5) step = 5 * magnitude;
else step = 10 * magnitude;
const ticks: number[] = [];
const start = Math.ceil(min / step) * step;
for (let t = start; t <= max; t += step) {
ticks.push(t);
}
return ticks;
}