Lots of frontend changes
This commit is contained in:
parent
ec29631c44
commit
555ba7cf53
38 changed files with 1508 additions and 648 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import type { PricePoint } from '../../types';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import { formatValue, FEATURE_FORMATS } from '../../lib/format';
|
||||
|
||||
interface PriceHistoryChartProps {
|
||||
points: PricePoint[];
|
||||
|
|
@ -8,141 +8,159 @@ interface PriceHistoryChartProps {
|
|||
|
||||
const PADDING = { top: 8, right: 8, bottom: 20, left: 42 };
|
||||
const HEIGHT = 120;
|
||||
const priceFmt = FEATURE_FORMATS['Last known price'];
|
||||
|
||||
export default function PriceHistoryChart({ points }: PriceHistoryChartProps) {
|
||||
const { yearMin, yearMax, priceMin, priceMax, averages, priceTicks } = useMemo(() => {
|
||||
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, priceMin, priceMax, medians, priceTicks } = useMemo(() => {
|
||||
let yMin = Infinity,
|
||||
yMax = -Infinity,
|
||||
pMin = Infinity,
|
||||
pMax = -Infinity;
|
||||
yMax = -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 }>();
|
||||
// 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) {
|
||||
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 arr = byYear.get(yr);
|
||||
if (arr) arr.push(p.price);
|
||||
else byYear.set(yr, [p.price]);
|
||||
}
|
||||
const avgs = Array.from(byYear.entries())
|
||||
.map(([yr, { sum, count }]) => ({ year: yr + 0.5, price: sum / count }))
|
||||
const meds = 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 + 0.5, price: median };
|
||||
})
|
||||
.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 };
|
||||
return {
|
||||
yearMin: yMin,
|
||||
yearMax: yMax,
|
||||
priceMin: pMin,
|
||||
priceMax: pMax,
|
||||
medians: meds,
|
||||
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 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 - priceMin) / (priceMax - priceMin || 1);
|
||||
return PADDING.top + (1 - Math.max(0, Math.min(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 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(' ');
|
||||
const medianPolyline = medians
|
||||
.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"
|
||||
/>
|
||||
))}
|
||||
<div ref={containerRef} style={{ height: HEIGHT }}>
|
||||
{width > 0 && (
|
||||
<svg width={width} height={HEIGHT}>
|
||||
{/* Grid lines */}
|
||||
{priceTicks.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={4}
|
||||
className="fill-teal-500 dark:fill-teal-400"
|
||||
opacity={0.35}
|
||||
/>
|
||||
))}
|
||||
{/* Dots (clamp outliers to visible range) */}
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
{/* 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 */}
|
||||
{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"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +169,6 @@ 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue