added chart
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
import type { PricePoint } from './lists.svelte';
|
||||
|
||||
let {
|
||||
history,
|
||||
target = null,
|
||||
currency = 'EUR'
|
||||
}: { history: PricePoint[]; target?: number | null; currency?: string | null } = $props();
|
||||
|
||||
// Geometry — a wide, short celestial band. viewBox units; scales to container.
|
||||
const W = 600;
|
||||
const H = 180;
|
||||
const PAD = { t: 16, r: 14, b: 24, l: 46 };
|
||||
const innerW = W - PAD.l - PAD.r;
|
||||
const innerH = H - PAD.t - PAD.b;
|
||||
|
||||
// Chronological, oldest → newest, so the line reads left → right.
|
||||
const points = $derived(
|
||||
[...history].sort((a, b) => +new Date(a.fetched_at) - +new Date(b.fetched_at))
|
||||
);
|
||||
|
||||
const cur = $derived(currency ?? 'EUR');
|
||||
|
||||
const stats = $derived.by(() => {
|
||||
if (points.length === 0) return null;
|
||||
const prices = points.map((p) => p.price);
|
||||
let min = Math.min(...prices);
|
||||
let max = Math.max(...prices);
|
||||
// Fold the target into the vertical range so its line is always visible.
|
||||
if (target != null) {
|
||||
min = Math.min(min, target);
|
||||
max = Math.max(max, target);
|
||||
}
|
||||
if (min === max) {
|
||||
// Flat history — give it breathing room so it doesn't sit on an edge.
|
||||
const pad = Math.max(1, Math.abs(min) * 0.1);
|
||||
min -= pad;
|
||||
max += pad;
|
||||
}
|
||||
const ts = points.map((p) => +new Date(p.fetched_at));
|
||||
const tMin = Math.min(...ts);
|
||||
const tMax = Math.max(...ts);
|
||||
return { min, max, tMin, tMax };
|
||||
});
|
||||
|
||||
function x(t: number): number {
|
||||
if (!stats) return PAD.l;
|
||||
const span = stats.tMax - stats.tMin || 1;
|
||||
return PAD.l + ((t - stats.tMin) / span) * innerW;
|
||||
}
|
||||
function y(price: number): number {
|
||||
if (!stats) return PAD.t;
|
||||
const span = stats.max - stats.min || 1;
|
||||
return PAD.t + (1 - (price - stats.min) / span) * innerH;
|
||||
}
|
||||
|
||||
// Plotted coordinates for each price check.
|
||||
const coords = $derived(
|
||||
points.map((p) => ({ ...p, cx: x(+new Date(p.fetched_at)), cy: y(p.price) }))
|
||||
);
|
||||
|
||||
const linePath = $derived(
|
||||
coords.length
|
||||
? coords.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.cx.toFixed(1)} ${c.cy.toFixed(1)}`).join(' ')
|
||||
: ''
|
||||
);
|
||||
// Area = the line, then down to the baseline and back — fills under the curve.
|
||||
const areaPath = $derived(
|
||||
coords.length
|
||||
? `${linePath} L ${coords[coords.length - 1].cx.toFixed(1)} ${(H - PAD.b).toFixed(1)} ` +
|
||||
`L ${coords[0].cx.toFixed(1)} ${(H - PAD.b).toFixed(1)} Z`
|
||||
: ''
|
||||
);
|
||||
|
||||
// Extremes — gild the lowest (the prophecy), mark the highest in rose.
|
||||
const lowIdx = $derived(
|
||||
coords.length ? coords.reduce((lo, c, i) => (c.price < coords[lo].price ? i : lo), 0) : -1
|
||||
);
|
||||
const highIdx = $derived(
|
||||
coords.length ? coords.reduce((hi, c, i) => (c.price > coords[hi].price ? i : hi), 0) : -1
|
||||
);
|
||||
|
||||
const targetY = $derived(target != null && stats ? y(target) : null);
|
||||
const latest = $derived(coords.length ? coords[coords.length - 1] : null);
|
||||
const onSale = $derived(latest != null && target != null && latest.price <= target);
|
||||
|
||||
// Three horizontal gridlines (min / mid / max) for a sense of scale.
|
||||
const gridLines = $derived.by(() => {
|
||||
if (!stats) return [];
|
||||
return [0, 0.5, 1].map((f) => {
|
||||
const v = stats.min + (stats.max - stats.min) * f;
|
||||
return { v, gy: y(v) };
|
||||
});
|
||||
});
|
||||
|
||||
// Hover — snap to the nearest point in time.
|
||||
let hover = $state<number | null>(null);
|
||||
let svgEl: SVGSVGElement | null = $state(null);
|
||||
|
||||
function onMove(e: PointerEvent) {
|
||||
if (!svgEl || coords.length === 0) return;
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
const px = ((e.clientX - rect.left) / rect.width) * W;
|
||||
let best = 0;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < coords.length; i++) {
|
||||
const d = Math.abs(coords[i].cx - px);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
hover = best;
|
||||
}
|
||||
|
||||
function fmtMoney(v: number) {
|
||||
return `${cur} ${v.toFixed(2)}`;
|
||||
}
|
||||
function fmtDay(s: string) {
|
||||
return new Date(s).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
|
||||
}
|
||||
function fmtFull(s: string) {
|
||||
return new Date(s).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
const active = $derived(hover != null ? coords[hover] : null);
|
||||
// Keep the tooltip from spilling off either edge.
|
||||
const tipX = $derived(active ? Math.min(Math.max(active.cx, PAD.l + 56), W - PAD.r - 56) : 0);
|
||||
const uid = 'pc-' + Math.random().toString(36).slice(2, 8);
|
||||
</script>
|
||||
|
||||
{#if coords.length === 0}
|
||||
<p class="text-xs text-mute">no price checks yet — hit refresh to start tracking.</p>
|
||||
{:else}
|
||||
<figure class="m-0">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<svg
|
||||
bind:this={svgEl}
|
||||
viewBox="0 0 {W} {H}"
|
||||
class="block w-full select-none overflow-visible"
|
||||
role="img"
|
||||
aria-label="price history chart"
|
||||
onpointermove={onMove}
|
||||
onpointerleave={() => (hover = null)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="{uid}-area" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--color-iris)" stop-opacity="0.35" />
|
||||
<stop offset="60%" stop-color="var(--color-iris)" stop-opacity="0.06" />
|
||||
<stop offset="100%" stop-color="var(--color-iris)" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient id="{uid}-line" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="var(--color-holo)" />
|
||||
<stop offset="50%" stop-color="var(--color-iris)" />
|
||||
<stop offset="100%" stop-color="var(--color-rose)" />
|
||||
</linearGradient>
|
||||
<filter id="{uid}-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3.5" result="b" />
|
||||
<feMerge>
|
||||
<feMergeNode in="b" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Gridlines + price ticks -->
|
||||
{#each gridLines as g}
|
||||
<line
|
||||
x1={PAD.l}
|
||||
y1={g.gy}
|
||||
x2={W - PAD.r}
|
||||
y2={g.gy}
|
||||
stroke="var(--color-smoke)"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="2 5"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<text x={PAD.l - 8} y={g.gy + 3.5} text-anchor="end" class="fill-mute" font-size="10">
|
||||
{g.v.toFixed(0)}
|
||||
</text>
|
||||
{/each}
|
||||
|
||||
<!-- Target line — the price you've been praying for -->
|
||||
{#if targetY != null}
|
||||
<line
|
||||
x1={PAD.l}
|
||||
y1={targetY}
|
||||
x2={W - PAD.r}
|
||||
y2={targetY}
|
||||
stroke="var(--color-mint)"
|
||||
stroke-width="1.25"
|
||||
stroke-dasharray="6 4"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<text x={W - PAD.r} y={targetY - 5} text-anchor="end" class="fill-mint" font-size="10">
|
||||
target {target?.toFixed(0)}
|
||||
</text>
|
||||
{/if}
|
||||
|
||||
<!-- Area under the curve -->
|
||||
<path d={areaPath} fill="url(#{uid}-area)" />
|
||||
|
||||
<!-- The price line itself, haloed -->
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="url(#{uid}-line)"
|
||||
stroke-width="2.25"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
filter="url(#{uid}-glow)"
|
||||
/>
|
||||
|
||||
<!-- Per-check dots; sold-out checks ringed in rose -->
|
||||
{#each coords as c, i}
|
||||
<circle
|
||||
cx={c.cx}
|
||||
cy={c.cy}
|
||||
r={i === lowIdx || i === highIdx ? 4 : 2.5}
|
||||
fill={i === lowIdx
|
||||
? 'var(--color-gold)'
|
||||
: i === highIdx
|
||||
? 'var(--color-rose)'
|
||||
: 'var(--color-iris)'}
|
||||
stroke={c.in_stock === false ? 'var(--color-rose)' : 'var(--color-void)'}
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<!-- Hover crosshair + emphasised point -->
|
||||
{#if active}
|
||||
<line
|
||||
x1={active.cx}
|
||||
y1={PAD.t}
|
||||
x2={active.cx}
|
||||
y2={H - PAD.b}
|
||||
stroke="var(--color-ink)"
|
||||
stroke-width="1"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<circle cx={active.cx} cy={active.cy} r="5.5" fill="none" stroke="var(--color-ink)" stroke-width="1.5" />
|
||||
<g transform="translate({tipX}, {Math.max(active.cy - 16, PAD.t + 8)})">
|
||||
<rect x="-54" y="-26" width="108" height="34" rx="6" fill="var(--color-ash)" stroke="var(--color-smoke)" />
|
||||
<text x="0" y="-12" text-anchor="middle" class="fill-ink" font-size="11" font-weight="700">
|
||||
{fmtMoney(active.price)}
|
||||
</text>
|
||||
<text x="0" y="2" text-anchor="middle" class="fill-mute" font-size="9">
|
||||
{fmtFull(active.fetched_at)}
|
||||
</text>
|
||||
</g>
|
||||
{/if}
|
||||
|
||||
<!-- X axis endpoints -->
|
||||
<text x={PAD.l} y={H - 6} text-anchor="start" class="fill-mute" font-size="10">
|
||||
{fmtDay(points[0].fetched_at)}
|
||||
</text>
|
||||
{#if points.length > 1}
|
||||
<text x={W - PAD.r} y={H - 6} text-anchor="end" class="fill-mute" font-size="10">
|
||||
{fmtDay(points[points.length - 1].fetched_at)}
|
||||
</text>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<figcaption class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span class="flex items-center gap-3 text-mute">
|
||||
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-gold)"></span>low {fmtMoney(coords[lowIdx].price)}</span>
|
||||
<span class="flex items-center gap-1"><span class="inline-block size-2 rounded-full" style="background:var(--color-rose)"></span>high {fmtMoney(coords[highIdx].price)}</span>
|
||||
</span>
|
||||
{#if latest}
|
||||
<span class:text-mint={onSale} class:text-ink={!onSale}>
|
||||
now {fmtMoney(latest.price)}{onSale ? ' ✦' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
</figcaption>
|
||||
</figure>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user