pr_01m47d24b0e6n91zwymwxg0vpx/apps/sudo/app/lib/chart.ts
| 1 | /** |
| 2 | * The arithmetic behind sudo's charts, kept apart from the SVG so it can |
| 3 | * be tested under Node. Money is in micros throughout. |
| 4 | */ |
| 5 | import type { MonthFigures } from "@g1t/contracts"; |
| 6 | |
| 7 | const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; |
| 8 | const MONTHS_LONG = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; |
| 9 | |
| 10 | /** `2026-10` → `Oct`; anything else as it came. */ |
| 11 | export function monthShort(month: string): string { |
| 12 | const match = /^(\d{4})-(\d{2})$/.exec(month); |
| 13 | const index = match ? Number(match[2]) - 1 : -1; |
| 14 | return MONTHS[index] ?? month; |
| 15 | } |
| 16 | |
| 17 | /** `2026-10` → `October 2026`. */ |
| 18 | export function monthLong(month: string): string { |
| 19 | const match = /^(\d{4})-(\d{2})$/.exec(month); |
| 20 | const index = match ? Number(match[2]) - 1 : -1; |
| 21 | return match && MONTHS_LONG[index] ? `${MONTHS_LONG[index]} ${match[1]}` : month; |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * The top of an axis for values up to `max`: the next 1, 2, 2.5 or 5 |
| 26 | * times a power of ten, so its ticks are round numbers. A dollar when |
| 27 | * there is nothing to show, so an empty chart still has an axis. |
| 28 | */ |
| 29 | export function niceCeiling(max: number, floor = 1_000_000): number { |
| 30 | const value = Math.max(max, floor); |
| 31 | const power = 10 ** Math.floor(Math.log10(value)); |
| 32 | for (const step of [1, 2, 2.5, 5, 10]) { |
| 33 | if (step * power >= value) return step * power; |
| 34 | } |
| 35 | return 10 * power; |
| 36 | } |
| 37 | |
| 38 | /** Evenly spaced ticks from zero to `top`, inclusive. */ |
| 39 | export function ticks(top: number, count = 4): number[] { |
| 40 | return Array.from({ length: count + 1 }, (_, index) => (top / count) * index); |
| 41 | } |
| 42 | |
| 43 | /** A margin and its share of what was charged; no share when nothing was. */ |
| 44 | export function marginOf(chargedMicros: number, costMicros: number): { micros: number; percent: number | null } { |
| 45 | const micros = chargedMicros - costMicros; |
| 46 | return { micros, percent: chargedMicros > 0 ? Math.round((micros / chargedMicros) * 100) : null }; |
| 47 | } |
| 48 | |
| 49 | /** The change from one figure to the next, as a whole percent; none from zero. */ |
| 50 | export function change(before: number, after: number): number | null { |
| 51 | if (before <= 0) return null; |
| 52 | return Math.round(((after - before) / before) * 100); |
| 53 | } |
| 54 | |
| 55 | export type Rect = { x: number; y: number; width: number; height: number }; |
| 56 | |
| 57 | export type MonthBars = { |
| 58 | month: string; |
| 59 | label: string; |
| 60 | /** Where the month's slot is, for its label and hover target. */ |
| 61 | slot: Rect; |
| 62 | charged: Rect; |
| 63 | cost: Rect; |
| 64 | figures: MonthFigures; |
| 65 | }; |
| 66 | |
| 67 | export type BarChart = { |
| 68 | width: number; |
| 69 | height: number; |
| 70 | /** The plot area, inside the axis labels. */ |
| 71 | plot: Rect; |
| 72 | top: number; |
| 73 | /** Each tick's value and its y. */ |
| 74 | ticks: { value: number; y: number }[]; |
| 75 | bars: MonthBars[]; |
| 76 | }; |
| 77 | |
| 78 | /** |
| 79 | * Two bars a month, charged beside cost, scaled to one axis from zero. |
| 80 | * Bars keep a 2px gap between them and a minimum visible height when |
| 81 | * their value is above zero, so a small month still shows. |
| 82 | */ |
| 83 | export function monthBars( |
| 84 | months: MonthFigures[], |
| 85 | { width = 600, height = 220, left = 52, right = 8, top: padTop = 10, bottom = 26, count = 4 } = {}, |
| 86 | ): BarChart { |
| 87 | const plot = { x: left, y: padTop, width: Math.max(1, width - left - right), height: Math.max(1, height - padTop - bottom) }; |
| 88 | const top = niceCeiling(Math.max(0, ...months.flatMap((month) => [month.chargedMicros, month.costMicros]))); |
| 89 | const baseline = plot.y + plot.height; |
| 90 | const scale = (value: number) => { |
| 91 | const clamped = Math.max(0, value); |
| 92 | const h = (clamped / top) * plot.height; |
| 93 | return clamped > 0 ? Math.max(1.5, h) : 0; |
| 94 | }; |
| 95 | const slotWidth = months.length > 0 ? plot.width / months.length : plot.width; |
| 96 | const gap = 2; |
| 97 | const barWidth = Math.max(2, Math.min(28, slotWidth * 0.3)); |
| 98 | const bars = months.map((figures, index) => { |
| 99 | const slotX = plot.x + index * slotWidth; |
| 100 | const center = slotX + slotWidth / 2; |
| 101 | const chargedHeight = scale(figures.chargedMicros); |
| 102 | const costHeight = scale(figures.costMicros); |
| 103 | return { |
| 104 | month: figures.month, |
| 105 | label: monthShort(figures.month), |
| 106 | slot: { x: slotX, y: plot.y, width: slotWidth, height: plot.height }, |
| 107 | charged: { x: center - gap / 2 - barWidth, y: baseline - chargedHeight, width: barWidth, height: chargedHeight }, |
| 108 | cost: { x: center + gap / 2, y: baseline - costHeight, width: barWidth, height: costHeight }, |
| 109 | figures, |
| 110 | }; |
| 111 | }); |
| 112 | return { |
| 113 | width, |
| 114 | height, |
| 115 | plot, |
| 116 | top, |
| 117 | ticks: ticks(top, count).map((value) => ({ value, y: baseline - (value / top) * plot.height })), |
| 118 | bars, |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * A bar's outline with its top corners rounded and its foot square on |
| 124 | * the baseline. Nothing for an empty bar. |
| 125 | */ |
| 126 | export function barPath({ x, y, width, height }: Rect, radius = 4): string { |
| 127 | if (height <= 0 || width <= 0) return ""; |
| 128 | const r = Math.min(radius, width / 2, height); |
| 129 | const bottom = y + height; |
| 130 | const round = (value: number) => Math.round(value * 100) / 100; |
| 131 | return [ |
| 132 | `M${round(x)} ${round(bottom)}`, |
| 133 | `V${round(y + r)}`, |
| 134 | `Q${round(x)} ${round(y)} ${round(x + r)} ${round(y)}`, |
| 135 | `H${round(x + width - r)}`, |
| 136 | `Q${round(x + width)} ${round(y)} ${round(x + width)} ${round(y + r)}`, |
| 137 | `V${round(bottom)}`, |
| 138 | "Z", |
| 139 | ].join(" "); |
| 140 | } |
| 141 | |
| 142 | /** An axis label: `$0`, `$250`, `$1.5k`, `$20k`, `$1.2M`. */ |
| 143 | export function axisDollars(micros: number): string { |
| 144 | const dollars = micros / 1_000_000; |
| 145 | const trim = (value: number) => String(Number(value.toFixed(1))); |
| 146 | if (dollars >= 1_000_000) return `$${trim(dollars / 1_000_000)}M`; |
| 147 | if (dollars >= 1_000) return `$${trim(dollars / 1_000)}k`; |
| 148 | if (dollars >= 1 || dollars === 0) return `$${trim(dollars)}`; |
| 149 | return `$${dollars.toFixed(2)}`; |
| 150 | } |
| 151 | |
| 152 | /** Each value's share of the largest, from 0 to 1, for a row of bars. */ |
| 153 | export function shares(values: number[]): number[] { |
| 154 | const max = Math.max(0, ...values); |
| 155 | return values.map((value) => (max > 0 ? Math.max(0, value) / max : 0)); |
| 156 | } |