Tonight
This commit is contained in:
parent
28323f145e
commit
94f9c0d594
76 changed files with 3238 additions and 1230 deletions
|
|
@ -8,8 +8,15 @@ interface PriceHistoryChartProps {
|
|||
|
||||
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);
|
||||
|
|
@ -25,7 +32,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const { yearMin, yearMax, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
|
||||
const { yearMin, yearMax, priceScale, medians } = useMemo(() => {
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity;
|
||||
for (const p of points) {
|
||||
|
|
@ -33,14 +40,6 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
if (p.year > yMax) yMax = p.year;
|
||||
}
|
||||
|
||||
// Use p5/p95 to clip outliers
|
||||
const sorted = points.map((p) => p.price).sort((a, b) => a - b);
|
||||
const p5 = sorted[Math.floor(sorted.length * 0.05)];
|
||||
const p95 = sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95))];
|
||||
const pRange = p95 - p5 || 1;
|
||||
const pMin = Math.max(0, p5 - pRange * 0.1);
|
||||
const pMax = p95 + pRange * 0.1;
|
||||
|
||||
// Yearly medians (robust to outliers)
|
||||
const byYear = new Map<number, number[]>();
|
||||
for (const p of points) {
|
||||
|
|
@ -73,15 +72,11 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
return { year: pt.year + 0.5, price: sum / count };
|
||||
});
|
||||
|
||||
const ticks = niceTicksForRange(pMin, pMax, 4);
|
||||
|
||||
return {
|
||||
yearMin: yMin,
|
||||
yearMax: yMax,
|
||||
priceMin: pMin,
|
||||
priceMax: pMax,
|
||||
priceScale: getPriceScale(points),
|
||||
medians: meds,
|
||||
priceTicks: ticks,
|
||||
};
|
||||
}, [points]);
|
||||
|
||||
|
|
@ -91,8 +86,8 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
|
||||
const scaleX = (year: number) => PADDING.left + ((year - yearMin) / yearRange) * plotW;
|
||||
const scaleY = (price: number) => {
|
||||
const t = (price - priceMin) / (priceMax - priceMin || 1);
|
||||
return PADDING.top + (1 - Math.max(0, Math.min(1, t))) * plotH;
|
||||
const t = (price - priceScale.min) / (priceScale.max - priceScale.min || 1);
|
||||
return PADDING.top + (1 - t) * plotH;
|
||||
};
|
||||
|
||||
// Year labels: every 5 years
|
||||
|
|
@ -107,7 +102,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
{width > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{priceTicks.map((tick) => (
|
||||
{priceScale.ticks.map((tick) => (
|
||||
<line
|
||||
key={tick}
|
||||
x1={PADDING.left}
|
||||
|
|
@ -119,7 +114,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* Dots (clamp outliers to visible range) */}
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
|
|
@ -143,7 +138,7 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{priceTicks.map((tick) => (
|
||||
{priceScale.ticks.map((tick) => (
|
||||
<text
|
||||
key={`label-${tick}`}
|
||||
x={PADDING.left - 4}
|
||||
|
|
@ -176,6 +171,40 @@ export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
|||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -189,10 +218,17 @@ function niceTicksForRange(min: number, max: number, count: number): number[] {
|
|||
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[] = [];
|
||||
const start = Math.ceil(min / step) * step;
|
||||
for (let t = start; t <= max; t += step) {
|
||||
ticks.push(t);
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue