355 lines
11 KiB
Svelte
355 lines
11 KiB
Svelte
<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
|
|
);
|
|
|
|
// Out-of-stock spans — shade the time the item couldn't be bought.
|
|
// Each maximal run of in_stock===false becomes a band, extended halfway
|
|
// to its in-stock neighbours so single checks still read as a region.
|
|
const oosBands = $derived.by(() => {
|
|
if (!coords.length) return [];
|
|
const bands: { x: number; w: number }[] = [];
|
|
let start: number | null = null;
|
|
for (let i = 0; i <= coords.length; i++) {
|
|
const oos = i < coords.length && coords[i].in_stock === false;
|
|
if (oos && start === null) start = i;
|
|
if (!oos && start !== null) {
|
|
const end = i - 1;
|
|
const left = start > 0 ? (coords[start - 1].cx + coords[start].cx) / 2 : coords[start].cx;
|
|
const right =
|
|
end < coords.length - 1 ? (coords[end].cx + coords[end + 1].cx) / 2 : coords[end].cx;
|
|
bands.push({ x: left, w: Math.max(right - left, 3) });
|
|
start = null;
|
|
}
|
|
}
|
|
return bands;
|
|
});
|
|
const anyOos = $derived(coords.some((c) => c.in_stock === false));
|
|
|
|
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>
|
|
|
|
<!-- Out-of-stock spans — rose shade behind everything -->
|
|
{#each oosBands as b (b.x)}
|
|
<rect
|
|
x={b.x}
|
|
y={PAD.t}
|
|
width={b.w}
|
|
height={innerH}
|
|
fill="var(--color-rose)"
|
|
opacity="0.1"
|
|
/>
|
|
{/each}
|
|
|
|
<!-- Gridlines + price ticks -->
|
|
{#each gridLines as g (g.v)}
|
|
<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 (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
|
|
>
|
|
{#if anyOos}
|
|
<span class="flex items-center gap-1"
|
|
><span
|
|
class="inline-block h-2 w-3 rounded-sm"
|
|
style="background:var(--color-rose);opacity:0.35"
|
|
></span>out of stock</span
|
|
>
|
|
{/if}
|
|
</span>
|
|
{#if latest}
|
|
<span class:text-mint={onSale} class:text-ink={!onSale}>
|
|
now {fmtMoney(latest.price)}{onSale ? ' ✦' : ''}
|
|
</span>
|
|
{/if}
|
|
</figcaption>
|
|
</figure>
|
|
{/if}
|