zenno: add stat charts

This commit is contained in:
2026-05-19 19:45:02 -04:00
parent 80573a84ea
commit b720b325b3
7 changed files with 1034 additions and 137 deletions

View File

@@ -0,0 +1,107 @@
<script lang="ts">
import * as Plot from '@observablehq/plot';
import * as d3 from 'd3';
import { Stat } from './race';
import type { ComputedSeries } from './chart';
import type { ClassValue } from 'svelte/elements';
import type { Attachment } from 'svelte/attachments';
interface Props {
stat: Stat;
y: ComputedSeries | Array<ComputedSeries | null>;
yLabel: string;
range?: [number, number];
xRange?: [number, number] | null;
xRule?: number | number[];
class?: ClassValue | null;
plotOptions?: Omit<Plot.PlotOptions, 'marks' | 'x' | 'y'>;
}
let { stat, y, yLabel, range, xRange, xRule = [], class: className, plotOptions = {} }: Props = $props();
let width = $state(0);
let height = $state(0);
let expand = $state(false);
const expandIcon = $derived(expand ? '◀' : '▶');
function clickExpand() {
expand = !expand;
}
const xLabel = $derived(Stat[stat]);
const xLines = $derived([xRule].flat(1));
const xMin = $derived(xRange?.[0] ?? 200);
const xMax = $derived.by(() => {
if (xRange?.[1] != null) {
return xRange[1];
}
if (expand) {
return 2000;
}
return 100 * Math.ceil(Math.max(1200, ...xLines) / 100);
});
const xVal = $derived(d3.range(xMin, xMax, 5));
const thrX = 1200;
const series = $derived([y].flat(1).filter((s) => s != null));
const vals = $derived(series.flatMap(({ y, label }) => xVal.map((x) => ({ x, y: y(x), label }))));
const yRange: [number, number] = $derived.by(() => {
if (range != null) {
return range;
}
const l = d3.min(vals, ({ y }) => y) ?? 0;
const r = d3.max(vals, ({ y }) => y) ?? 1;
return [l, r];
});
const yLines = $derived(xLines.flatMap((x) => series.map(({ y, label }) => ({ y: y(x), label }))));
const makeChart: Attachment = (el) => {
$effect(() => {
el?.firstChild?.remove();
el?.append(
Plot.plot({
width,
height,
...plotOptions,
x: {
domain: [xMin, xMax],
interval: 5,
ticks: d3.range(2000, 0, -200).filter((x) => xMin <= x && x <= xMax),
label: xLabel,
line: true,
},
y: {
domain: yRange,
grid: true,
label: yLabel,
line: true,
},
marks: [
Plot.ruleX([thrX], { strokeOpacity: 0.25 }),
Plot.ruleX(xLines, { strokeOpacity: 0.5 }),
Plot.ruleY(yLines, { y: 'y', stroke: 'label', strokeOpacity: 0.5 }),
Plot.frame(),
Plot.line(vals, { x: 'x', y: 'y', stroke: 'label', strokeWidth: 3 }),
Plot.tip(vals, Plot.pointerY({ x: 'x', y: 'y', stroke: 'label', className: 'plot-tip' })),
],
}),
);
});
};
</script>
<div bind:clientWidth={width} bind:clientHeight={height} class={['flex h-full w-full flex-col md:flex-row', className]}>
<div role="img" {@attach makeChart}>
<span>Loading chart!</span>
</div>
<div class="my-5 flex h-full flex-row place-content-end md:flex-col md:place-content-start">
<button class="h-8 rounded border px-2 pb-1 align-middle" onclick={clickExpand}>{expandIcon}</button>
</div>
</div>
<style>
:global(.plot-tip) {
--plot-background: light-dark(var(--color-mist-200), var(--color-mist-800));
}
</style>

4
zenno/src/lib/chart.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface ComputedSeries {
y: (x: number) => number;
label: string;
}