diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index 83eafbc9a..baed917be 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -3,7 +3,7 @@ import { computeKpiComparison, DEFAULT_COLORS, defaultColorFor, - formatCompactNumber, + formatChartValue, labelize, } from '@nao/shared'; import { @@ -236,7 +236,7 @@ function KpiCards({ chart, rows }: { chart: ParsedChartBlock; rows: Record {chart.series.map((s) => { const raw = lastRow[s.data_key]; - const value = typeof raw === 'number' ? formatCompactNumber(raw) : String(raw ?? ''); + const value = typeof raw === 'number' ? formatChartValue(raw, s.value_format) : String(raw ?? ''); const label = s.label ?? s.data_key; const comparison = computeKpiComparison(sortedRows, chart.xAxisKey, s.data_key, chart.comparisonMode); return ( @@ -508,7 +508,64 @@ const TOOLTIP_SCRIPT_TEMPLATE = ` return escHtml(str.replace(/_/g,' ').replace(/\\b\\w/g,function(c){return c.toUpperCase()})) } function formatCompact(v){var a=Math.abs(v);if(a>=1e9)return (v/1e9).toFixed(1).replace(/[.]0$/,'')+'B';if(a>=1e6)return (v/1e6).toFixed(1).replace(/[.]0$/,'')+'M';if(a>=1e4)return (v/1e3).toFixed(1).replace(/[.]0$/,'')+'K';return v.toLocaleString()} - function formatVal(v){return escHtml(typeof v==='number'?formatCompact(v):String(v!=null?v:''))} + // Faithful port of d3-format's SI-prefix formatting so exported values match the chart. + var SI_PREFIXES=['y','z','a','f','p','n','µ','m','','k','M','G','T','P','E','Z','Y']; + var siPrefixExponent=0; + function formatDecimalParts(x,p){ + var e=p?x.toExponential(p-1):x.toExponential(); + var i=e.indexOf('e'); + if(i<0)return null; + var coefficient=e.slice(0,i); + return [coefficient.length>1?coefficient[0]+coefficient.slice(2):coefficient,+e.slice(i+1)]; + } + function formatPrefixAuto(x,p){ + var d=formatDecimalParts(x,p); + if(!d)return x+''; + var coefficient=d[0],exponent=d[1]; + siPrefixExponent=Math.max(-8,Math.min(8,Math.floor(exponent/3)))*3; + var i=exponent-siPrefixExponent+1,n=coefficient.length; + return i===n?coefficient:i>n?coefficient+new Array(i-n+1).join('0'):i>0?coefficient.slice(0,i)+'.'+coefficient.slice(i):'0.'+new Array(1-i).join('0')+formatDecimalParts(x,Math.max(0,p+i-1))[0]; + } + function formatTrim(s){ + out:for(var n=s.length,i=1,i0=-1,i1;i0)i0=0;break; + } + } + return i0>0?s.slice(0,i0)+s.slice(i1+1):s; + } + function formatSi(v,precision,trim,compact){ + var p=precision===undefined?6:precision; + p=Math.max(1,Math.min(21,p)); + var negative=v<0; + var value=formatPrefixAuto(Math.abs(v),p); + if(trim)value=formatTrim(value); + var unit=SI_PREFIXES[8+siPrefixExponent/3]; + if(compact!=='si'){if(unit==='k')unit='K';if(unit==='G')unit='B'} + return (negative?'-':'')+value+unit; + } + function formatD3Common(v,spec,compact){ + var fixed=/^(,)?(?:\\.([0-9]+))?f$/.exec(spec); + if(fixed){ + var decimals=fixed[2]===undefined?6:Number(fixed[2]); + return fixed[1]?v.toLocaleString(undefined,{minimumFractionDigits:decimals,maximumFractionDigits:decimals}):v.toFixed(decimals); + } + if(spec===',')return v.toLocaleString(); + var si=/^\\.?([0-9]+)?(~)?s$/.exec(spec); + if(si)return formatSi(v,si[1]===undefined?undefined:Number(si[1]),si[2]==='~',compact); + return formatCompact(v); + } + function formatSeriesVal(v,fmt){ + if(typeof v!=='number')return String(v!=null?v:''); + var number=fmt&&fmt.d3_format?formatD3Common(v,fmt.d3_format,fmt.compact):formatCompact(v); + var prefix=fmt&&fmt.prefix?String(fmt.prefix):''; + var suffix=fmt&&fmt.suffix?String(fmt.suffix):''; + if(prefix&&number.charAt(0)==='-')number='-'+prefix+number.slice(1); + else number=prefix+number; + return number+suffix; + } document.querySelectorAll('.nao-chart').forEach(function(container){ var raw=container.getAttribute('data-chart'); @@ -583,6 +640,7 @@ const TOOLTIP_SCRIPT_TEMPLATE = ` function pctShare(v){if(typeof v!=='number'||!seriesTotal)return '0%';var sh=Math.round(v/seriesTotal*1000)/10;return (sh%1===0?sh:sh.toFixed(1))+'%';} var numericValues=[]; var hasTotalSeries=false; + var firstNonTotalSeries=cfg.series.find(function(s){return !s.is_total})||cfg.series[0]; cfg.series.forEach(function(s, si){ // A total series is dropped from 100% stacked rendering, so hide its tooltip row too. if(isPercent&&s.is_total)return; @@ -601,14 +659,14 @@ const TOOLTIP_SCRIPT_TEMPLATE = ` html+='
' +'' +''+rowName+'' - +''+(isPercent?pctShare(val):formatVal(val))+'' + +''+escHtml(isPercent?pctShare(val):formatSeriesVal(val,s.value_format))+'' +'
'; }); if(numericValues.length>1 && !isDualAxis && (isPercent || (!hasTotalSeries && !cfg.hideTotal))){ var total=numericValues.reduce(function(a,b){return a+b},0); html+='
' +'Total' - +''+(isPercent?'100%':escHtml(formatCompact(total)))+'' + +''+escHtml(isPercent?'100%':formatSeriesVal(total,firstNonTotalSeries&&firstNonTotalSeries.value_format))+'' +'
'; } html+=''; diff --git a/apps/backend/tests/story-html-tabs.test.ts b/apps/backend/tests/story-html-tabs.test.ts index 0cd994758..b74d8c3d5 100644 --- a/apps/backend/tests/story-html-tabs.test.ts +++ b/apps/backend/tests/story-html-tabs.test.ts @@ -23,4 +23,16 @@ Some details text expect(html).toContain('Hello overview'); expect(html).toContain('Some details text'); }); + + it('formats KPI values with the series value format', () => { + const html = generateStoryHtml( + { + title: 'Revenue story', + code: ``, + }, + { q1: { data: [{ month: 'July', revenue: 1234.5 }], columns: ['month', 'revenue'] } }, + ); + + expect(html).toContain('$1,234.50 USD'); + }); }); diff --git a/apps/frontend/src/components/settings/usage-chart-card.tsx b/apps/frontend/src/components/settings/usage-chart-card.tsx index 9eaa5802b..2c5678494 100644 --- a/apps/frontend/src/components/settings/usage-chart-card.tsx +++ b/apps/frontend/src/components/settings/usage-chart-card.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import type { displayChart } from '@nao/shared/tools'; import type { TotalUsageRecord, UsageRecord } from '@nao/backend/usage'; import { ChartDisplay } from '@/components/tool-calls/display-chart'; @@ -9,7 +10,7 @@ export interface UsageChartCardProps { isError: boolean; data: UsageRecord[] | TotalUsageRecord[]; chartType: 'bar' | 'stacked_bar' | 'kpi_card'; - series: { data_key: string; color: string; label: string }[]; + series: displayChart.SeriesConfig[]; xAxisLabelFormatter?: (value: string) => string; valueFormatter?: (value: number) => string; titleAccessory?: ReactNode; diff --git a/apps/frontend/src/components/side-panel/story-chart-embed.tsx b/apps/frontend/src/components/side-panel/story-chart-embed.tsx index 2badaef21..53474c869 100644 --- a/apps/frontend/src/components/side-panel/story-chart-embed.tsx +++ b/apps/frontend/src/components/side-panel/story-chart-embed.tsx @@ -127,6 +127,7 @@ export function StoryChartEmbedShell({ color: s.color || undefined, label: s.label, is_total: s.is_total, + value_format: s.value_format, series_type: s.series_type, y_axis: s.y_axis, })), diff --git a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx index ebff2b5f6..2028e7a8a 100644 --- a/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart-edit-dialog.tsx @@ -1,7 +1,7 @@ import { computeKpiComparison, DEFAULT_COLORS } from '@nao/shared'; import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ChartArea, ChartBar, ChartColumn, ChartColumnIncreasing, ChartLine, Plus, Trash2 } from 'lucide-react'; +import { ChartArea, ChartBar, ChartColumn, ChartColumnIncreasing, ChartLine, Plus, Trash2, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Button } from '../ui/button'; @@ -14,6 +14,7 @@ import type { LucideIcon } from 'lucide-react'; import type { UIMessage, UIToolPart } from '@nao/backend/chat'; import { trpc } from '@/main'; import { useAgentContext } from '@/contexts/agent.provider'; +import { cn } from '@/lib/utils'; const CHART_TYPE_OPTIONS: { value: displayChart.ChartType; label: string }[] = [ { value: 'bar', label: 'Bar' }, @@ -51,6 +52,8 @@ const SERIES_TYPE_OPTIONS: { value: displayChart.SeriesType; label: string; icon const Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES = new Set(['pie', 'kpi_card', 'radar']); +type UnitPlacement = 'prefix' | 'suffix'; + type EditableChartInput = Omit & { chart_type: displayChart.ChartType }; /** Maps a 100% stacked type back to its absolute-stacked counterpart, so the type dropdown stays clean. */ @@ -75,6 +78,18 @@ function percentChartType(type: displayChart.ChartType): displayChart.ChartType return type; } +/** Shifts open Format-panel indexes to stay aligned after the series at `removedIndex` is removed. */ +function remapOpenIndexesAfterRemoval(openIndexes: Set, removedIndex: number): Set { + const next = new Set(); + for (const openIndex of openIndexes) { + if (openIndex === removedIndex) { + continue; + } + next.add(openIndex > removedIndex ? openIndex - 1 : openIndex); + } + return next; +} + interface ChartConfigEditDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -103,10 +118,18 @@ export function ChartConfigEditDialog({ const [yAxisRightMinText, setYAxisRightMinText] = useState(toRangeString(config.y_axis_right_min)); const [yAxisRightMaxText, setYAxisRightMaxText] = useState(toRangeString(config.y_axis_right_max)); const [error, setError] = useState(null); + const [openValueFormatIndexes, setOpenValueFormatIndexes] = useState>(new Set()); // Chart palette resolved to hex so a series without an explicit color shows // the same swatch the chart draws for it. Refreshed on open for the theme. const [paletteHexes, setPaletteHexes] = useState(DEFAULT_COLORS); const supportsYAxisRange = !Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES.has(draft.chart_type); + const unsupportedNumberFormat = useMemo( + () => + draft.series + .map((series) => series.value_format?.d3_format) + .find((format) => Boolean(format) && !isExportSafeNumberFormat(format as string)), + [draft.series], + ); const canShowComparisonPill = useMemo( () => draft.chart_type === 'kpi_card' && hasRenderableKpiComparison(data, draft.x_axis_key, draft.series), [draft.chart_type, draft.x_axis_key, draft.series, data], @@ -124,6 +147,7 @@ export function ChartConfigEditDialog({ setYAxisRightMaxText(toRangeString(config.y_axis_right_max)); setPaletteHexes(resolveChartPaletteHexes()); setError(null); + setOpenValueFormatIndexes(new Set()); } }, [open, config]); @@ -136,6 +160,11 @@ export function ChartConfigEditDialog({ const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + if (unsupportedNumberFormat) { + setError(UNSUPPORTED_NUMBER_FORMAT_MESSAGE); + return; + } + const normalized: EditableChartInput = draft.chart_type === 'kpi_card' ? { ...draft, x_axis_key: draft.x_axis_key || '', x_axis_type: draft.x_axis_type ?? null } @@ -169,11 +198,42 @@ export function ChartConfigEditDialog({ })); }; + const updateSeriesValueFormatAt = (index: number, field: 'd3_format' | 'prefix' | 'suffix', value: string) => { + const series = draft.series[index]; + const nextValueFormat = { ...series.value_format, [field]: value || undefined }; + updateSeriesAt(index, { value_format: cleanValueFormat(nextValueFormat) }); + }; + + const setSeriesUnit = (index: number, { unit, placement }: { unit: string; placement: UnitPlacement }) => { + const series = draft.series[index]; + const nextValueFormat = { + ...series.value_format, + prefix: placement === 'prefix' ? unit || undefined : undefined, + suffix: placement === 'suffix' ? unit || undefined : undefined, + }; + updateSeriesAt(index, { value_format: cleanValueFormat(nextValueFormat) }); + }; + + const toggleValueFormat = (index: number) => { + setOpenValueFormatIndexes((previous) => { + const next = new Set(previous); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + const removeSeriesAt = (index: number) => { - setDraft((prev) => ({ - ...prev, - series: prev.series.length <= 1 ? prev.series : prev.series.filter((_, i) => i !== index), - })); + setDraft((prev) => { + if (prev.series.length <= 1) { + return prev; + } + return { ...prev, series: prev.series.filter((_, i) => i !== index) }; + }); + setOpenValueFormatIndexes((previous) => remapOpenIndexesAfterRemoval(previous, index)); }; const addSeries = () => { @@ -349,9 +409,15 @@ export function ChartConfigEditDialog({
{draft.series.map((series, index) => { + const placement: UnitPlacement = series.value_format?.prefix ? 'prefix' : 'suffix'; + const unit = + placement === 'prefix' + ? (series.value_format?.prefix ?? '') + : (series.value_format?.suffix ?? ''); + const isOpen = openValueFormatIndexes.has(index); const row = (
updateSeriesAt(index, { y_axis: value })} /> )} + toggleValueFormat(index)} + /> {row} + {isOpen && ( + + updateSeriesValueFormatAt(index, 'd3_format', value) + } + onUnitChange={(nextUnit, nextPlacement) => + setSeriesUnit(index, { + unit: nextUnit, + placement: nextPlacement, + }) + } + /> + )}
); } @@ -414,6 +501,22 @@ export function ChartConfigEditDialog({ onChange={(value) => updateSeriesAt(index, { series_type: value })} /> {row} + {isOpen && ( + + updateSeriesValueFormatAt(index, 'd3_format', value) + } + onUnitChange={(nextUnit, nextPlacement) => + setSeriesUnit(index, { + unit: nextUnit, + placement: nextPlacement, + }) + } + /> + )}
); })} @@ -518,7 +621,9 @@ export function ChartConfigEditDialog({ - {error &&

{error}

} + {(error || unsupportedNumberFormat) && ( +

{error ?? UNSUPPORTED_NUMBER_FORMAT_MESSAGE}

+ )} @@ -624,6 +729,39 @@ function ColumnSelect({ value, columns, onChange }: ColumnSelectProps) { ); } +interface ClearableInputProps { + value: string; + onChange: (value: string) => void; + onClear: () => void; + placeholder: string; + ariaLabel: string; + className?: string; +} + +function ClearableInput({ value, onChange, onClear, placeholder, ariaLabel, className }: ClearableInputProps) { + return ( +
+ onChange(event.target.value)} + placeholder={placeholder} + aria-label={ariaLabel} + className={cn(className, 'pr-7')} + /> + {value && ( + + )} +
+ ); +} + interface AxisFieldsProps { name: string; showRange: boolean; @@ -744,6 +882,105 @@ function SeriesTypeSelect({ value, onChange }: SeriesTypeSelectProps) { ); } +interface SeriesValueFormatFieldsProps { + d3Format: string; + unit: string; + placement: UnitPlacement; + onD3FormatChange: (value: string) => void; + onUnitChange: (unit: string, placement: UnitPlacement) => void; +} + +function SeriesValueFormatFields({ + d3Format, + unit, + placement, + onD3FormatChange, + onUnitChange, +}: SeriesValueFormatFieldsProps) { + return ( +
+
+
+ Number format + + How to format + +
+ onD3FormatChange('')} + placeholder='e.g. ,.2f' + ariaLabel='d3-format specifier' + className='h-8 rounded-lg text-sm bg-panel' + /> +
+
+ Unit + onUnitChange(value, placement)} + onClear={() => onUnitChange('', placement)} + placeholder='$ or %' + ariaLabel='Value unit' + className='h-8 rounded-lg text-sm bg-panel' + /> +
+
+ Placement + +
+
+ ); +} + +interface ValueFormatToggleProps { + unit: string; + open: boolean; + onClick: () => void; +} + +function ValueFormatToggle({ unit, open, onClick }: ValueFormatToggleProps) { + return ( + + ); +} + interface YAxisSideToggleProps { value: displayChart.YAxisSide; onChange: (value: displayChart.YAxisSide) => void; @@ -807,6 +1044,25 @@ function parseRangeInput(value: string): number | undefined { return Number.isFinite(n) ? n : undefined; } +function cleanValueFormat( + valueFormat: NonNullable, +): displayChart.SeriesConfig['value_format'] { + return valueFormat.d3_format || valueFormat.prefix || valueFormat.suffix ? valueFormat : undefined; +} + +const UNSUPPORTED_NUMBER_FORMAT_MESSAGE = + 'This number format renders differently in story exports. Use formats like ,.2f, .2f, , or .2s.'; + +/** + * Number formats that render identically in the interactive chart (d3-format) and in the + * static story export formatter. Exposing only these in the editor keeps both paths consistent. + */ +const EXPORT_SAFE_NUMBER_FORMATS = [/^(,)?(?:\.\d+)?f$/, /^,$/, /^(?:\.\d+)?~?s$/]; + +function isExportSafeNumberFormat(format: string): boolean { + return format === '' || EXPORT_SAFE_NUMBER_FORMATS.some((pattern) => pattern.test(format)); +} + const HEX_RE = /^#[0-9a-fA-F]{6}$/; function normalizeHexColor(color: string | undefined, fallback: string): string { if (color && HEX_RE.test(color)) { diff --git a/apps/frontend/src/components/tool-calls/display-chart.tsx b/apps/frontend/src/components/tool-calls/display-chart.tsx index 6285e85f2..19782d8ab 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -517,6 +517,9 @@ export const ChartDisplay = memo(function ChartDisplay({ [xAxisKey]: { label: labelize(xAxisKey, dateFormat), }, + [pieValueKey]: { + valueFormat: series[0]?.value_format, + }, }, ); } @@ -526,10 +529,11 @@ export const ChartDisplay = memo(function ChartDisplay({ label: s.label || labelize(s.data_key, dateFormat), color: s.color || Colors[idx % Colors.length], isTotal: s.is_total, + valueFormat: s.value_format, }; return acc; }, {} as ChartConfig); - }, [series, xAxisKey, pieData, isPie, dateFormat]); + }, [series, xAxisKey, pieValueKey, pieData, isPie, dateFormat]); const colorFor = useMemo( () => @@ -628,6 +632,7 @@ export const ChartDisplay = memo(function ChartDisplay({ isDualAxis={isDualAxis} hideTotal={hideTotal} labelFormatter={tooltipLabelFormatter} + nameKey={isPie ? pieValueKey : undefined} valueFormatter={valueFormatter} /> } @@ -661,6 +666,7 @@ export const ChartDisplay = memo(function ChartDisplay({ xAxisTickFontSize, xAxisMaxLabelChars, xAxisKey, + pieValueKey, xAxisType, visibleSeries, colorFor, diff --git a/apps/frontend/src/components/ui/chart.tsx b/apps/frontend/src/components/ui/chart.tsx index 3a61bdb7c..25a282883 100644 --- a/apps/frontend/src/components/ui/chart.tsx +++ b/apps/frontend/src/components/ui/chart.tsx @@ -1,7 +1,8 @@ -import { formatCompactNumber, formatPercentShare, sumPercentStackBase } from '@nao/shared'; +import { formatChartValue, formatCompactNumber, formatPercentShare, sumPercentStackBase } from '@nao/shared'; import * as React from 'react'; import * as RechartsPrimitive from 'recharts'; import type { Payload } from 'recharts/types/component/DefaultLegendContent'; +import type { displayChart } from '@nao/shared/tools'; import { cn } from '@/lib/utils'; @@ -13,6 +14,7 @@ export type ChartConfig = { label?: React.ReactNode; icon?: React.ComponentType; isTotal?: boolean; + valueFormat?: displayChart.ValueFormat; } & ({ color?: string; theme?: never } | { color?: never; theme: Record }); }; @@ -175,6 +177,9 @@ function ChartTooltipContent({ // In 100% stacked mode every category totals 100%, so ignore already-aggregated total series. const showTotal = !isDualAxis && numericValues.length > 1 && (percent || (!hasTotalSeries && !hideTotal)); const formatValue = (value: number) => (percent ? formatPercentShare(value, shareBase) : valueFormatter(value)); + const firstItem = visiblePayload[0]; + const firstItemKey = `${nameKey || firstItem?.name || firstItem?.dataKey || 'value'}`; + const firstItemFormat = getPayloadConfigFromPayload(config, firstItem, firstItemKey)?.valueFormat; return (
{typeof item.value === 'number' - ? formatValue(item.value) + ? percent + ? formatPercentShare(item.value, shareBase) + : formatChartValue(item.value, itemConfig?.valueFormat, { + compact: true, + }) : item.value.toLocaleString()} )} @@ -256,7 +265,7 @@ function ChartTooltipContent({
Total - {percent ? '100%' : valueFormatter(seriesTotal)} + {percent ? '100%' : formatChartValue(seriesTotal, firstItemFormat, { compact: true })}
diff --git a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx index 93ff0ae2a..bc3c29909 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx @@ -3,6 +3,7 @@ import { createFileRoute, Outlet, useNavigate, useRouterState } from '@tanstack/ import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { format } from 'date-fns'; import type { TokenChartDisplayMode, UsageRouteSearch } from '@/components/settings/usage-route-search'; +import type { displayChart } from '@nao/shared/tools'; import { ChatsReplayPage } from '@/components/settings/chats-replay-page'; import { UsageChartCard } from '@/components/settings/usage-chart-card'; import { ReplayFilters, UsageFilters, dateFormats } from '@/components/settings/usage-filters'; @@ -18,6 +19,12 @@ export const Route = createFileRoute('/_sidebar-layout/settings/usage')({ component: UsagePage, }); +const USD_VALUE_FORMAT = { + d3_format: ',.2f', + prefix: '$', + compact: 'financial', +} satisfies displayChart.ValueFormat; + const tokenChartDisplayOptions: { value: TokenChartDisplayMode; label: string }[] = [ { value: 'tokens', label: 'Show in tokens' }, { value: 'dollars', label: 'Show in dollars' }, @@ -212,7 +219,32 @@ function UsageOverview({ chartType='stacked_bar' xAxisLabelFormatter={(value) => format(new Date(value), dateFormats[granularity])} valueFormatter={showCost ? formatUsd : undefined} - series={showCost ? costSeries : tokenSeries} + series={[ + { + data_key: 'inputNoCacheCost', + color: 'var(--chart-1)', + label: 'Input', + value_format: USD_VALUE_FORMAT, + }, + { + data_key: 'inputCacheReadCost', + color: 'var(--chart-2)', + label: 'Input (cache read)', + value_format: USD_VALUE_FORMAT, + }, + { + data_key: 'inputCacheWriteCost', + color: 'var(--chart-3)', + label: 'Input (cache write)', + value_format: USD_VALUE_FORMAT, + }, + { + data_key: 'outputCost', + color: 'var(--chart-4)', + label: 'Output', + value_format: USD_VALUE_FORMAT, + }, + ]} titleAccessory={