g1t/apps/sudo/app/lib/chart.ts
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales | 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 | } |