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(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(); 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 (
{width > 0 && ( {/* Grid lines */} {priceScale.ticks.map((tick) => ( ))} {/* Dots */} {points.map((p, i) => ( ))} {/* Median line */} {medians.length > 1 && ( )} {/* Y-axis labels */} {priceScale.ticks.map((tick) => ( {formatValue(tick, priceFmt)} ))} {/* X-axis year labels */} {yearLabels.map((yr) => ( {yr} ))} )}
); } 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)); }