zenno: add stat charts
This commit is contained in:
107
zenno/src/lib/StatChart.svelte
Normal file
107
zenno/src/lib/StatChart.svelte
Normal 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
4
zenno/src/lib/chart.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ComputedSeries {
|
||||
y: (x: number) => number;
|
||||
label: string;
|
||||
}
|
||||
Reference in New Issue
Block a user