234 lines
6.9 KiB
TypeScript
234 lines
6.9 KiB
TypeScript
import { useMemo, useRef, useState, useEffect } from 'react';
|
|
import type { PricePoint } from '../../types';
|
|
import { formatValue } from '../../lib/format';
|
|
|
|
interface PriceHistoryChartProps {
|
|
points: PricePoint[];
|
|
}
|
|
|
|
const PADDING = { top: 8, right: 24, bottom: 20, left: 42 };
|
|
const HEIGHT = 120;
|
|
const PRICE_SCALE_TOP_PERCENTILE = 95;
|
|
const priceFmt = { prefix: '£' };
|
|
|
|
interface PriceScale {
|
|
min: number;
|
|
max: number;
|
|
ticks: number[];
|
|
}
|
|
|
|
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [width, setWidth] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
const observer = new ResizeObserver((entries) => {
|
|
const w = entries[0].contentRect.width;
|
|
if (w > 0) setWidth(w);
|
|
});
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
|
let yMin = Infinity,
|
|
yMax = -Infinity;
|
|
for (const p of points) {
|
|
if (p.year < yMin) yMin = p.year;
|
|
if (p.year > yMax) yMax = p.year;
|
|
}
|
|
|
|
// Yearly medians (robust to outliers)
|
|
const byYear = new Map<number, number[]>();
|
|
for (const p of points) {
|
|
const yr = Math.floor(p.year);
|
|
const arr = byYear.get(yr);
|
|
if (arr) arr.push(p.price);
|
|
else byYear.set(yr, [p.price]);
|
|
}
|
|
const yearlyMedians = Array.from(byYear.entries())
|
|
.map(([yr, prices]) => {
|
|
prices.sort((a, b) => a - b);
|
|
const mid = Math.floor(prices.length / 2);
|
|
const median = prices.length % 2 ? prices[mid] : (prices[mid - 1] + prices[mid]) / 2;
|
|
return { year: yr, price: median };
|
|
})
|
|
.sort((a, b) => a.year - b.year);
|
|
|
|
// 3-year rolling average
|
|
const meds = yearlyMedians.map((pt, i) => {
|
|
let sum = pt.price;
|
|
let count = 1;
|
|
for (let j = i - 1; j >= 0 && pt.year - yearlyMedians[j].year <= 1; j--) {
|
|
sum += yearlyMedians[j].price;
|
|
count++;
|
|
}
|
|
for (let j = i + 1; j < yearlyMedians.length && yearlyMedians[j].year - pt.year <= 1; j++) {
|
|
sum += yearlyMedians[j].price;
|
|
count++;
|
|
}
|
|
return { year: pt.year + 0.5, price: sum / count };
|
|
});
|
|
|
|
return {
|
|
yearMin: yMin,
|
|
yearMax: yMax,
|
|
priceScale: getPriceScale(points),
|
|
medians: meds,
|
|
};
|
|
}, [points]);
|
|
|
|
const plotW = width - PADDING.left - PADDING.right;
|
|
const plotH = HEIGHT - PADDING.top - PADDING.bottom;
|
|
const yearRange = yearMax - yearMin || 1;
|
|
|
|
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
|
|
const scaleY = (price: number) => {
|
|
const t = (price - priceScale.min) / (priceScale.max - priceScale.min || 1);
|
|
return PADDING.top + (1 - t) * plotH;
|
|
};
|
|
|
|
// 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 medianPolyline = medians.map((a) => `${scaleX(a.year)},${scaleY(a.price)}`).join(' ');
|
|
|
|
return (
|
|
<div ref={containerRef} style={{ height: HEIGHT }}>
|
|
{width > 0 && (
|
|
<svg width={width} height={HEIGHT}>
|
|
{/* Grid lines */}
|
|
{priceScale.ticks.map((tick) => (
|
|
<line
|
|
key={tick}
|
|
x1={PADDING.left}
|
|
y1={scaleY(tick)}
|
|
x2={width - PADDING.right}
|
|
y2={scaleY(tick)}
|
|
className="stroke-warm-200 dark:stroke-warm-700"
|
|
strokeWidth={1}
|
|
/>
|
|
))}
|
|
|
|
{/* Dots */}
|
|
{points.map((p, i) => (
|
|
<circle
|
|
key={i}
|
|
cx={scaleX(p.year)}
|
|
cy={scaleY(p.price)}
|
|
r={3}
|
|
className="fill-teal-500 dark:fill-teal-400"
|
|
opacity={0.35}
|
|
/>
|
|
))}
|
|
|
|
{/* Median line */}
|
|
{medians.length > 1 && (
|
|
<polyline
|
|
points={medianPolyline}
|
|
fill="none"
|
|
className="stroke-teal-600 dark:stroke-teal-400"
|
|
strokeWidth={2}
|
|
strokeLinejoin="round"
|
|
/>
|
|
)}
|
|
|
|
{/* Y-axis labels */}
|
|
{priceScale.ticks.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"
|
|
fontSize={10}
|
|
>
|
|
{formatValue(tick, priceFmt)}
|
|
</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"
|
|
fontSize={10}
|
|
>
|
|
{yr}
|
|
</text>
|
|
))}
|
|
</svg>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function getPriceScale(points: PricePoint[]): PriceScale {
|
|
const prices = points
|
|
.map((p) => p.price)
|
|
.filter(Number.isFinite)
|
|
.sort((a, b) => a - b);
|
|
if (prices.length === 0) {
|
|
return { min: 0, max: 1, ticks: [0, 1] };
|
|
}
|
|
|
|
const min = prices[0];
|
|
const scaleTop = percentile(prices, PRICE_SCALE_TOP_PERCENTILE);
|
|
const range = scaleTop - min;
|
|
const padding = range > 0 ? range * 0.1 : Math.max(Math.abs(scaleTop) * 0.1, 1);
|
|
const paddedMin = Math.max(0, min - padding);
|
|
const paddedMax = scaleTop + padding;
|
|
const ticks = niceTicksForRange(paddedMin, paddedMax, 4);
|
|
|
|
return {
|
|
min: ticks[0] ?? paddedMin,
|
|
max: ticks[ticks.length - 1] ?? paddedMax,
|
|
ticks,
|
|
};
|
|
}
|
|
|
|
function percentile(sorted: number[], p: number): number {
|
|
const clamped = Math.max(0, Math.min(100, p));
|
|
const rank = ((sorted.length - 1) * clamped) / 100;
|
|
const lower = Math.floor(rank);
|
|
const upper = Math.ceil(rank);
|
|
if (lower === upper) return sorted[lower];
|
|
const weight = rank - lower;
|
|
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
|
}
|
|
|
|
/** 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;
|
|
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 start =
|
|
min >= 0 ? Math.max(0, Math.floor(min / step) * step) : Math.floor(min / step) * step;
|
|
const end = Math.ceil(max / step) * step;
|
|
|
|
const ticks: number[] = [];
|
|
for (let t = start; t <= end + step / 2; t += step) {
|
|
ticks.push(cleanTick(t));
|
|
}
|
|
return ticks;
|
|
}
|
|
|
|
function cleanTick(value: number): number {
|
|
return Number(value.toPrecision(12));
|
|
}
|