| ... | ... | @@ -18,13 +18,11 @@ interface UsageProviderChartProps { |
| 18 | 18 | readonly metric: UsageChartMetric; |
| 19 | 19 | } |
| 20 | 20 | |
| 21 | | /** One day's stacked bands, shared by the paths and the hover readout. */ |
| 21 | /** One day's per-provider values, shared by the paths and the hover readout. */ |
| 22 | 22 | export interface DayColumn { |
| 23 | 23 | readonly bands: readonly { |
| 24 | 24 | readonly provider: UsageProviderKind; |
| 25 | 25 | readonly value: number; |
| 26 | | readonly base: number; |
| 27 | | readonly top: number; |
| 28 | 26 | }[]; |
| 29 | 27 | readonly total: number; |
| 30 | 28 | } |
| ... | ... | @@ -130,28 +128,6 @@ function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): |
| 130 | 128 | return path; |
| 131 | 129 | } |
| 132 | 130 | |
| 133 | | /** |
| 134 | | * The same curve walked end to start. A cubic reverses exactly by swapping its |
| 135 | | * control points, so this traces the identical geometry. |
| 136 | | * |
| 137 | | * Bands must use this rather than re-smoothing their base points in reverse: |
| 138 | | * the tangent clamp in `monotoneTangents` runs left to right, so smoothing is |
| 139 | | * not perfectly symmetric under reversal, and independently smoothed edges of |
| 140 | | * adjacent bands could hairline-gap or overlap. Sharing one curve per stack |
| 141 | | * boundary makes that geometrically impossible. |
| 142 | | */ |
| 143 | | function reversedCurvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): string { |
| 144 | | const last = segments[segments.length - 1]; |
| 145 | | if (last === undefined) return ""; |
| 146 | | let path = `${startCommand}${last.to.x.toFixed(2)},${last.to.y.toFixed(2)}`; |
| 147 | | for (let index = segments.length - 1; index >= 0; index -= 1) { |
| 148 | | const segment = segments[index]; |
| 149 | | if (segment === undefined) continue; |
| 150 | | path += ` C${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.from.x.toFixed(2)},${segment.from.y.toFixed(2)}`; |
| 151 | | } |
| 152 | | return path; |
| 153 | | } |
| 154 | | |
| 155 | 131 | /** |
| 156 | 132 | * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the |
| 157 | 133 | * peak. |
| ... | ... | @@ -175,7 +151,12 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re |
| 175 | 151 | } |
| 176 | 152 | |
| 177 | 153 | /** |
| 178 | | * Turns the merged daily totals into stacked bands, one column per day. |
| 154 | * Turns the merged daily totals into one column per day. |
| 155 | * |
| 156 | * Values are absolute, not cumulative: the series are layered from a shared |
| 157 | * zero baseline rather than stacked. A stacked chart puts whichever provider is |
| 158 | * drawn last permanently above the other, which reads as "that one is bigger" |
| 159 | * even on days where it is not. |
| 179 | 160 | * |
| 180 | 161 | * The chart paths and the hover readout both consume this, so the number under |
| 181 | 162 | * the cursor is by construction the number that was plotted rather than a |
| ... | ... | @@ -188,14 +169,11 @@ export function buildDayColumns( |
| 188 | 169 | ): readonly DayColumn[] { |
| 189 | 170 | return days.map((day) => { |
| 190 | 171 | const entry = byDay.get(day); |
| 191 | | let stackTop = 0; |
| 192 | | const bands = PROVIDER_ORDER.map((provider) => { |
| 193 | | const value = valueFor(entry, provider, metric); |
| 194 | | const base = stackTop; |
| 195 | | stackTop += value; |
| 196 | | return { provider, value, base, top: stackTop }; |
| 197 | | }); |
| 198 | | return { bands, total: stackTop }; |
| 172 | const bands = PROVIDER_ORDER.map((provider) => ({ |
| 173 | provider, |
| 174 | value: valueFor(entry, provider, metric), |
| 175 | })); |
| 176 | return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; |
| 199 | 177 | }); |
| 200 | 178 | } |
| 201 | 179 | |
| ... | ... | @@ -215,9 +193,15 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr |
| 215 | 193 | }; |
| 216 | 194 | } |
| 217 | 195 | |
| 218 | | const stacked = buildDayColumns(days, byDay, metric); |
| 196 | const columns = buildDayColumns(days, byDay, metric); |
| 219 | 197 | |
| 220 | | const peak = stacked.reduce((max, column) => Math.max(max, column.total), 0); |
| 198 | // The scale tops out at the largest single provider-day, not the largest |
| 199 | // sum: layered series each measure from zero, so a combined peak would |
| 200 | // leave the plot permanently half empty. |
| 201 | const peak = columns.reduce( |
| 202 | (max, column) => column.bands.reduce((inner, band) => Math.max(inner, band.value), max), |
| 203 | 0, |
| 204 | ); |
| 221 | 205 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); |
| 222 | 206 | const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); |
| 223 | 207 | // Reserve a sliver above the top gridline so the series stroke, which is |
| ... | ... | @@ -225,30 +209,28 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr |
| 225 | 209 | const toY = (value: number) => |
| 226 | 210 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); |
| 227 | 211 | |
| 228 | | // One smoothed curve per stack boundary (baseline, then each provider's |
| 229 | | // cumulative top). Band k is the region between boundary k and k+1, both |
| 230 | | // drawn from these shared control points. |
| 231 | | const boundaries = [ |
| 232 | | stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })), |
| 233 | | ...PROVIDER_ORDER.map((_, providerIndex) => |
| 234 | | stacked.map((column, dayIndex) => ({ |
| 212 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { |
| 213 | const curve = smoothCurve( |
| 214 | columns.map((column, dayIndex) => ({ |
| 235 | 215 | x: dayIndex * step, |
| 236 | | y: toY(column.bands[providerIndex]?.top ?? 0), |
| 216 | y: toY(column.bands[providerIndex]?.value ?? 0), |
| 237 | 217 | })), |
| 238 | | ), |
| 239 | | ].map(smoothCurve); |
| 240 | | |
| 241 | | const built = PROVIDER_ORDER.map((provider, providerIndex) => { |
| 242 | | const top = boundaries[providerIndex + 1] ?? []; |
| 243 | | const base = boundaries[providerIndex] ?? []; |
| 218 | ); |
| 219 | const line = curvePath(curve, "M"); |
| 244 | 220 | return { |
| 245 | 221 | provider, |
| 246 | | area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`, |
| 247 | | line: curvePath(top, "M"), |
| 222 | total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), |
| 223 | area: line === "" ? "" : `${line} L${VIEW_WIDTH},${VIEW_HEIGHT} L0,${VIEW_HEIGHT} Z`, |
| 224 | line, |
| 248 | 225 | }; |
| 249 | 226 | }); |
| 250 | 227 | |
| 251 | | return { paths: built, ticks: tickValues, stepX: step, toY, series: stacked }; |
| 228 | // Paint the heavier series first so the lighter one is never buried under |
| 229 | // it. The fills are faint enough that the order barely shows, but the |
| 230 | // strokes are drawn in a second pass regardless, so neither can be hidden. |
| 231 | const ordered = [...built].sort((a, b) => b.total - a.total); |
| 232 | |
| 233 | return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; |
| 252 | 234 | }, [byDay, days, metric]); |
| 253 | 235 | |
| 254 | 236 | const format = metric === "tokens" ? formatTokens : formatUsd; |
| ... | ... | @@ -314,17 +296,19 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr |
| 314 | 296 | ); |
| 315 | 297 | })} |
| 316 | 298 | |
| 317 | | {paths.map(({ provider, area, line }) => ( |
| 318 | | <g key={provider}> |
| 319 | | <path d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.5} /> |
| 320 | | <path |
| 321 | | d={line} |
| 322 | | fill="none" |
| 323 | | stroke={PROVIDER_COLOR[provider]} |
| 324 | | strokeWidth={1.5} |
| 325 | | vectorEffect="non-scaling-stroke" |
| 326 | | /> |
| 327 | | </g> |
| 299 | {/* Fills first, then every stroke, so no series covers another's line. */} |
| 300 | {paths.map(({ provider, area }) => ( |
| 301 | <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} /> |
| 302 | ))} |
| 303 | {paths.map(({ provider, line }) => ( |
| 304 | <path |
| 305 | key={provider} |
| 306 | d={line} |
| 307 | fill="none" |
| 308 | stroke={PROVIDER_COLOR[provider]} |
| 309 | strokeWidth={2} |
| 310 | vectorEffect="non-scaling-stroke" |
| 311 | /> |
| 328 | 312 | ))} |
| 329 | 313 | |
| 330 | 314 | {hoverIndex === null ? null : ( |