mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
255 lines
6.7 KiB
Svelte
255 lines
6.7 KiB
Svelte
<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>
|