File size: 4,165 Bytes
d6703a1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
<script lang="ts">
	import dayjs from 'dayjs';

	interface Props {
		data: { date: string; models: Record<string, number> }[];
		models: string[];
		colors: string[];
		height?: number;
		period?: 'hour' | 'week' | 'month' | 'year' | 'all';
	}

	let { data, models, colors, height = 300, period = 'week' }: Props = $props();

	let hoveredIdx: number | null = $state(null);
	let mouseX = $state(0);

	let colorMap = $derived(new Map(models.map((n, i) => [n, colors[i % colors.length]])));
	let maxCount = $derived(Math.max(...data.flatMap((d) => Object.values(d.models || {})), 1));

	const pad = { t: 8, r: 0, b: 20, l: 0 };
	const w = 1000;
	let cw = $derived(w - pad.l - pad.r);
	let ch = $derived(height - pad.t - pad.b);

	const getX = (i: number) =>
		data.length <= 1 ? pad.l + cw / 2 : pad.l + (i / (data.length - 1)) * cw;
	const getY = (v: number) => pad.t + ch - (v / maxCount) * ch;

	const path = (m: string) => {
		const pts = data.map((d, i) => `${getX(i)},${getY(d.models?.[m] || 0)}`);
		return pts.length > 1 ? `M${pts.join('L')}` : '';
	};

	const onMove = (e: MouseEvent) => {
		const svg = e.currentTarget as SVGSVGElement;
		const r = svg.getBoundingClientRect();
		mouseX = (e.clientX - r.left) * (w / r.width);
		hoveredIdx = Math.max(
			0,
			Math.min(data.length - 1, Math.round(((mouseX - pad.l) / cw) * (data.length - 1)))
		);
	};

	let hovered = $derived(hoveredIdx !== null ? data[hoveredIdx] : null);
</script>

<div class="relative w-full" style="height:{height}px">
	<svg
		viewBox="0 0 {w} {height - 20}"
		class="h-[calc(100%-20px)] w-full"
		preserveAspectRatio="none"
		onmousemove={onMove}
		onmouseleave={() => (hoveredIdx = null)}
	>
		{#each models as m}
			<path
				d={path(m)}
				fill="none"
				stroke={colorMap.get(m)}
				stroke-width="1.5"
				class={hovered && !hovered.models?.[m] ? 'opacity-20' : ''}
			/>
		{/each}
		{#if hoveredIdx !== null}
			<line
				x1={getX(hoveredIdx)}
				y1={pad.t}
				x2={getX(hoveredIdx)}
				y2={ch + pad.t}
				stroke="#ddd"
				stroke-width="1"
			/>
			{#each models as m}
				{@const v = hovered?.models?.[m] || 0}
				{#if v > 0}
					<circle cx={getX(hoveredIdx)} cy={getY(v)} r="3" fill={colorMap.get(m)} />
				{/if}
			{/each}
		{/if}
	</svg>
	<!-- X-axis labels as HTML -->
	{#if data.length > 1}
		{@const labelCount = Math.min(7, data.length)}
		{@const step = labelCount > 1 ? Math.floor((data.length - 1) / (labelCount - 1)) || 1 : 1}
		{@const isHourly = data[0]?.date?.includes(':')}
		{@const dateFormat = isHourly
			? 'h A'
			: period === 'year' || period === 'all'
				? 'M/D/YY'
				: 'M/D'}
		<div class="flex justify-between px-0.5 text-[10px] text-gray-400">
			{#each Array(labelCount) as _, i}
				{@const idx = i === labelCount - 1 ? data.length - 1 : Math.min(i * step, data.length - 1)}
				{#if data[idx]}
					<span class={i === 0 ? 'text-left' : i === labelCount - 1 ? 'text-right' : 'text-center'}
						>{dayjs(data[idx].date).format(dateFormat)}</span
					>
				{/if}
			{/each}
		</div>
	{/if}
	{#if hovered}
		{@const total = Object.values(hovered.models || {}).reduce((a, b) => a + b, 0)}
		<div
			class="pointer-events-none absolute top-1 text-[11px]"
			style="left:{Math.min(Math.max((mouseX / w) * 100, 8), 92)}%"
		>
			<div
				class="min-w-[140px] -translate-x-1/2 rounded border border-gray-100 bg-white px-2.5 py-1.5 shadow-sm dark:border-gray-800 dark:bg-gray-900"
			>
				<div class="mb-1.5 text-[10px] text-gray-400">
					{#if hovered.date?.includes(':')}
						{dayjs(hovered.date).format('MMM D, h A')}
					{:else}
						{dayjs(hovered.date).format('MMM D, YYYY')}
					{/if}
				</div>
				{#each Object.entries(hovered.models || {})
					.sort(([, a], [, b]) => b - a)
					.slice(0, 5) as [n, c]}
					<div class="flex items-center justify-between gap-2 py-0.5">
						<span class="min-w-0 truncate text-gray-600 dark:text-gray-300">{n}</span>
						<span class="shrink-0 text-gray-900 tabular-nums dark:text-white"
							>{c.toLocaleString()}
							<span class="text-gray-400">({total > 0 ? ((c / total) * 100).toFixed(0) : 0}%)</span
							></span
						>
					</div>
				{/each}
			</div>
		</div>
	{/if}
</div>