mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 13:54:10 -04:00
first pass analytics redo
This commit is contained in:
1 parent
b656fd04c0
commit
45ffa39f2a
69 files changed
+6400
-1138
No files matched your search
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
interface Series {
|
||||
name: string;
|
||||
color: string;
|
||||
points: { date: string; value: number | null }[];
|
||||
step?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
series: Series[];
|
||||
height?: number;
|
||||
ySuffix?: string;
|
||||
yMin?: number;
|
||||
yMax?: number;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
let {
|
||||
series,
|
||||
height = 180,
|
||||
ySuffix = '',
|
||||
yMin,
|
||||
yMax,
|
||||
showLegend = true
|
||||
}: Props = $props();
|
||||
|
||||
const W = 600;
|
||||
const pad = { l: 36, r: 10, t: 10, b: 22 };
|
||||
const innerW = W - pad.l - pad.r;
|
||||
const innerH = $derived(height - pad.t - pad.b);
|
||||
|
||||
const allValues = $derived(
|
||||
series.flatMap((s) => s.points.map((p) => p.value)).filter((v): v is number => v !== null)
|
||||
);
|
||||
const domain = $derived.by(() => {
|
||||
const lo = yMin ?? (allValues.length ? Math.min(...allValues) : 0);
|
||||
const hi = yMax ?? (allValues.length ? Math.max(...allValues) : 1);
|
||||
const span = hi - lo || 1;
|
||||
return { lo: yMin ?? lo - span * 0.1, hi: yMax ?? hi + span * 0.1 };
|
||||
});
|
||||
|
||||
const pointCount = $derived(
|
||||
Math.max(1, ...series.map((s) => s.points.length))
|
||||
);
|
||||
|
||||
function xAt(i: number): number {
|
||||
if (pointCount <= 1) return pad.l + innerW / 2;
|
||||
return pad.l + (i / (pointCount - 1)) * innerW;
|
||||
}
|
||||
function yAt(v: number): number {
|
||||
const t = (v - domain.lo) / (domain.hi - domain.lo || 1);
|
||||
return pad.t + innerH - t * innerH;
|
||||
}
|
||||
|
||||
/** Break a series into path segments so nulls create gaps. */
|
||||
const paths = $derived(
|
||||
series.map((s) => {
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
s.points.forEach((p, i) => {
|
||||
if (p.value === null) {
|
||||
if (current.length) segments.push(current.join(' '));
|
||||
current = [];
|
||||
return;
|
||||
}
|
||||
current.push(`${xAt(i).toFixed(1)},${yAt(p.value).toFixed(1)}`);
|
||||
});
|
||||
if (current.length) segments.push(current.join(' '));
|
||||
return { name: s.name, color: s.color, segments, step: s.step };
|
||||
})
|
||||
);
|
||||
|
||||
const firstLabel = $derived(series[0]?.points[0]?.date?.slice(5) ?? '');
|
||||
const lastLabel = $derived(
|
||||
series[0]?.points[series[0].points.length - 1]?.date?.slice(5) ?? ''
|
||||
);
|
||||
|
||||
// --- Hover / tooltip state -------------------------------------------------
|
||||
let wrapperEl: HTMLDivElement | undefined = $state();
|
||||
let hoverIndex = $state<number | null>(null);
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!wrapperEl || pointCount <= 0) return;
|
||||
const rect = wrapperEl.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
const scale = rect.width / W;
|
||||
const svgX = (event.clientX - rect.left) / scale;
|
||||
if (pointCount <= 1) {
|
||||
hoverIndex = 0;
|
||||
return;
|
||||
}
|
||||
const t = (svgX - pad.l) / innerW;
|
||||
const i = Math.round(t * (pointCount - 1));
|
||||
hoverIndex = Math.min(pointCount - 1, Math.max(0, i));
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
hoverIndex = null;
|
||||
}
|
||||
|
||||
/** Rows shown in the tooltip for the currently hovered index. */
|
||||
const hoverRows = $derived.by(() => {
|
||||
const i = hoverIndex;
|
||||
if (i === null) return [];
|
||||
return series.map((s) => ({
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
value: s.points[i]?.value ?? null
|
||||
}));
|
||||
});
|
||||
|
||||
const hoveredDate = $derived.by(() => {
|
||||
if (hoverIndex === null) return null;
|
||||
for (const s of series) {
|
||||
const point = s.points[hoverIndex];
|
||||
if (point) return point.date;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const hasHoverValues = $derived(hoverRows.some((r) => r.value !== null));
|
||||
|
||||
const tooltipLeft = $derived(
|
||||
hoverIndex === null ? 50 : Math.min(85, Math.max(15, (xAt(hoverIndex) / W) * 100))
|
||||
);
|
||||
|
||||
function formatValue(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
const rounded = Number.isInteger(value) ? value : Number(value.toFixed(1));
|
||||
return `${rounded.toLocaleString()}${ySuffix}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-full touch-pan-y"
|
||||
bind:this={wrapperEl}
|
||||
role="presentation"
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerleave={clearHover}
|
||||
onpointercancel={clearHover}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${height}`}
|
||||
class="w-full text-gray-400"
|
||||
style={`height:${height}px`}
|
||||
role="img"
|
||||
>
|
||||
<!-- gridlines -->
|
||||
{#each [0, 0.5, 1] as t}
|
||||
<line
|
||||
x1={pad.l}
|
||||
x2={W - pad.r}
|
||||
y1={pad.t + innerH * t}
|
||||
y2={pad.t + innerH * t}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.15"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
{/each}
|
||||
<text x={4} y={pad.t + 4} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||
{domain.hi.toFixed(0)}{ySuffix}
|
||||
</text>
|
||||
<text x={4} y={pad.t + innerH} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||
{domain.lo.toFixed(0)}{ySuffix}
|
||||
</text>
|
||||
|
||||
{#each paths as s (s.name)}
|
||||
{#each s.segments as d}
|
||||
<polyline
|
||||
points={d}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
opacity={hoverIndex === null ? 1 : 0.45}
|
||||
style="transition:opacity 120ms ease"
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#if hoverIndex !== null}
|
||||
<!-- crosshair -->
|
||||
<line
|
||||
x1={xAt(hoverIndex)}
|
||||
x2={xAt(hoverIndex)}
|
||||
y1={pad.t}
|
||||
y2={pad.t + innerH}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.5"
|
||||
stroke-dasharray="3 3"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<!-- highlighted points -->
|
||||
{#each hoverRows as row (row.name)}
|
||||
{#if row.value !== null}
|
||||
<circle
|
||||
cx={xAt(hoverIndex)}
|
||||
cy={yAt(row.value)}
|
||||
r="3.5"
|
||||
fill={row.color}
|
||||
stroke="#0b0b12"
|
||||
stroke-width="1.5"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<text x={pad.l} y={height - 6} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||
{firstLabel}
|
||||
</text>
|
||||
<text
|
||||
x={W - pad.r}
|
||||
y={height - 6}
|
||||
text-anchor="end"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.8"
|
||||
font-size="9"
|
||||
>
|
||||
{lastLabel}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{#if hoverIndex !== null && hoveredDate !== null && hasHoverValues}
|
||||
<div
|
||||
class="pointer-events-none absolute top-1 z-10 -translate-x-1/2 rounded-lg border border-white/15 bg-gray-900/95 px-2.5 py-1.5 text-[11px] leading-tight text-gray-100 shadow-lg backdrop-blur-sm"
|
||||
style={`left:${tooltipLeft}%`}
|
||||
>
|
||||
<p class="mb-1 font-semibold text-gray-300">{hoveredDate}</p>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each hoverRows as row (row.name)}
|
||||
<li class="flex items-center justify-between gap-3">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" style={`background:${row.color}`}></span>
|
||||
{row.name}
|
||||
</span>
|
||||
<span class="font-semibold tabular-nums text-gray-100">{formatValue(row.value)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showLegend && series.length > 1}
|
||||
<div class="mt-1 flex flex-wrap gap-3 text-[11px] text-gray-300">
|
||||
{#each series as s (s.name)}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full" style={`background:${s.color}`}></span>
|
||||
{s.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in new issue
Block a user