| ... | @@ -18,13 +18,11 @@ interface UsageProviderChartProps { | ... | @@ -18,13 +18,11 @@ interface UsageProviderChartProps { |
| 18 | readonly metric: UsageChartMetric; | 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 | export interface DayColumn { | 22 | export interface DayColumn { |
| 23 | readonly bands: readonly { | 23 | readonly bands: readonly { |
| 24 | readonly provider: UsageProviderKind; | 24 | readonly provider: UsageProviderKind; |
| 25 | readonly value: number; | 25 | readonly value: number; |
| 26 | readonly base: number; | | |
| 27 | readonly top: number; | | |
| 28 | }[]; | 26 | }[]; |
| 29 | readonly total: number; | 27 | readonly total: number; |
| 30 | } | 28 | } |
| ... | @@ -130,28 +128,6 @@ function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): | ... | @@ -130,28 +128,6 @@ function curvePath(segments: readonly CurveSegment[], startCommand: "M" | "L"): |
| 130 | return path; | 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 | * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the | 132 | * Builds a scale whose maximum is a readable 1/2/5 x 10^n step at or above the |
| 157 | * peak. | 133 | * peak. |
| ... | @@ -175,7 +151,12 @@ export function niceScale(peak: number, count: number): { max: number; ticks: re | ... | @@ -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 | * The chart paths and the hover readout both consume this, so the number under | 161 | * The chart paths and the hover readout both consume this, so the number under |
| 181 | * the cursor is by construction the number that was plotted rather than a | 162 | * the cursor is by construction the number that was plotted rather than a |
| ... | @@ -188,14 +169,11 @@ export function buildDayColumns( | ... | @@ -188,14 +169,11 @@ export function buildDayColumns( |
| 188 | ): readonly DayColumn[] { | 169 | ): readonly DayColumn[] { |
| 189 | return days.map((day) => { | 170 | return days.map((day) => { |
| 190 | const entry = byDay.get(day); | 171 | const entry = byDay.get(day); |
| 191 | let stackTop = 0; | 172 | const bands = PROVIDER_ORDER.map((provider) => ({ |
| 192 | const bands = PROVIDER_ORDER.map((provider) => { | 173 | provider, |
| 193 | const value = valueFor(entry, provider, metric); | 174 | value: valueFor(entry, provider, metric), |
| 194 | const base = stackTop; | 175 | })); |
| 195 | stackTop += value; | 176 | return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; |
| 196 | return { provider, value, base, top: stackTop }; | | |
| 197 | }); | | |
| 198 | return { bands, total: stackTop }; | | |
| 199 | }); | 177 | }); |
| 200 | } | 178 | } |
| 201 | | 179 | |
| ... | @@ -215,9 +193,15 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr | ... | @@ -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 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); | 205 | const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); |
| 222 | const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); | 206 | const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); |
| 223 | // Reserve a sliver above the top gridline so the series stroke, which is | 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,30 +209,28 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr |
| 225 | const toY = (value: number) => | 209 | const toY = (value: number) => |
| 226 | max === 0 ? VIEW_HEIGHT : VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP); | 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 | 212 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { |
| 229 | // cumulative top). Band k is the region between boundary k and k+1, both | 213 | const curve = smoothCurve( |
| 230 | // drawn from these shared control points. | 214 | columns.map((column, dayIndex) => ({ |
| 231 | const boundaries = [ | | |
| 232 | stacked.map((_, dayIndex) => ({ x: dayIndex * step, y: toY(0) })), | | |
| 233 | ...PROVIDER_ORDER.map((_, providerIndex) => | | |
| 234 | stacked.map((column, dayIndex) => ({ | | |
| 235 | x: dayIndex * step, | 215 | x: dayIndex * step, |
| 236 | y: toY(column.bands[providerIndex]?.top ?? 0), | 216 | y: toY(column.bands[providerIndex]?.value ?? 0), |
| 237 | })), | 217 | })), |
| 238 | ), | 218 | ); |
| 239 | ].map(smoothCurve); | 219 | const line = curvePath(curve, "M"); |
| 240 | | | |
| 241 | const built = PROVIDER_ORDER.map((provider, providerIndex) => { | | |
| 242 | const top = boundaries[providerIndex + 1] ?? []; | | |
| 243 | const base = boundaries[providerIndex] ?? []; | | |
| 244 | return { | 220 | return { |
| 245 | provider, | 221 | provider, |
| 246 | area: `${curvePath(top, "M")} ${reversedCurvePath(base, "L")} Z`, | 222 | total: columns.reduce((sum, column) => sum + (column.bands[providerIndex]?.value ?? 0), 0), |
| 247 | line: curvePath(top, "M"), | 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 | }, [byDay, days, metric]); | 234 | }, [byDay, days, metric]); |
| 253 | | 235 | |
| 254 | const format = metric === "tokens" ? formatTokens : formatUsd; | 236 | const format = metric === "tokens" ? formatTokens : formatUsd; |
| ... | @@ -314,17 +296,19 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr | ... | @@ -314,17 +296,19 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr |
| 314 | ); | 296 | ); |
| 315 | })} | 297 | })} |
| 316 | | 298 | |
| 317 | {paths.map(({ provider, area, line }) => ( | 299 | {/* Fills first, then every stroke, so no series covers another's line. */} |
| 318 | <g key={provider}> | 300 | {paths.map(({ provider, area }) => ( |
| 319 | <path d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.5} /> | 301 | <path key={provider} d={area} fill={PROVIDER_COLOR[provider]} fillOpacity={0.12} /> |
| 320 | <path | 302 | ))} |
| 321 | d={line} | 303 | {paths.map(({ provider, line }) => ( |
| 322 | fill="none" | 304 | <path |
| 323 | stroke={PROVIDER_COLOR[provider]} | 305 | key={provider} |
| 324 | strokeWidth={1.5} | 306 | d={line} |
| 325 | vectorEffect="non-scaling-stroke" | 307 | fill="none" |
| 326 | /> | 308 | stroke={PROVIDER_COLOR[provider]} |
| 327 | </g> | 309 | strokeWidth={2} |
| | 310 | vectorEffect="non-scaling-stroke" |
| | 311 | /> |
| 328 | ))} | 312 | ))} |
| 329 | | 313 | |
| 330 | {hoverIndex === null ? null : ( | 314 | {hoverIndex === null ? null : ( |