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} + )} Save @@ -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 + onUnitChange(unit, value as UnitPlacement)} + > + + + + + Prefix + Suffix + + + + + ); +} + +interface ValueFormatToggleProps { + unit: string; + open: boolean; + onClick: () => void; +} + +function ValueFormatToggle({ unit, open, onClick }: ValueFormatToggleProps) { + return ( + + {unit ? ( + {unit} + ) : ( + $ + )} + + ); +} + 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={ 0) { + candidates.push(niceAxisMax(maximumValue)); + } + if (minimumValue < 0) { + candidates.push(-niceAxisMax(Math.abs(minimumValue))); + } + + const maximumLabelLength = Math.max(...candidates.map((value) => formatValueYAxisTick(value, valueFormat).length)); + const estimatedWidth = maximumLabelLength * VALUE_AXIS_CHARACTER_WIDTH + VALUE_AXIS_PADDING; + return Math.min(VALUE_AXIS_MAX_WIDTH, Math.max(Y_AXIS_WIDTH, estimatedWidth)); +} + function abbreviate(abs: number, unit: number): string { return String(Number((abs / unit).toFixed(2))); } @@ -242,9 +282,9 @@ function parseDateMs(value: unknown): number | null { return Number.isNaN(ms) ? null : ms; } -export function formatDataLabel(value: unknown): string { +export function formatDataLabel(value: unknown, valueFormat?: displayChart.ValueFormat): string { const number = toFiniteNumber(value); - return number == null ? '' : formatCompactNumber(number); + return number == null ? '' : formatChartValue(number, valueFormat, { compact: true }); } export function defaultColorFor(_key: string, index: number): string { @@ -478,7 +518,7 @@ export function niceAxisMax(dataMax: number, tickCount = 5): number { } function buildKpiCard(props: ResolvedProps) { - const { data, series, valueFormatter } = props; + const { data, series } = props; return ( @@ -488,7 +528,7 @@ function buildKpiCard(props: ResolvedProps) { value={data[data.length - 1]?.[s.data_key]} displayName={s.label ?? s.data_key} comparison={computeKpiComparison(data, props.xAxisKey, s.data_key, props.comparisonMode)} - valueFormatter={valueFormatter} + valueFormat={s.value_format} /> ))} @@ -503,17 +543,17 @@ function KpiCard({ value, displayName, comparison, - valueFormatter = formatCompactNumber, + valueFormat, }: { value: unknown; displayName: string; comparison: KpiComparison | null; - valueFormatter?: (value: number) => string; + valueFormat?: displayChart.ValueFormat; }) { let formattedValue = ''; if (typeof value === 'number') { - formattedValue = valueFormatter(value); + formattedValue = formatChartValue(value, valueFormat); } else if (typeof value === 'string') { formattedValue = value; } @@ -572,6 +612,16 @@ function renderValueYAxis(isPercent = false, valueFormatter = formatYAxisTick) { ); } +function formatValueYAxisTick(value: number, valueFormat?: displayChart.ValueFormat): string { + if (!valueFormat) { + return formatYAxisTick(value); + } + if (valueFormat.d3_format) { + return formatChartValue(value, valueFormat, { compact: true }); + } + return attachValueAffixes(formatYAxisTick(value), valueFormat); +} + function renderCategoryXAxis({ xAxisKey, xAxisType, @@ -637,7 +687,6 @@ function buildBarChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, - valueFormatter, } = props; const isStacked = displayChart.isStackedChartType(chartType); const isPercent = displayChart.isPercentStackedChartType(chartType); @@ -646,6 +695,8 @@ function buildBarChart(props: ResolvedProps) { const { renderedSeries, stackTotalLabel, stackTotalLabelIndex } = getDataLabelSetup(props, isStacked); const seriesKeys = renderedSeries.map((s) => s.data_key); const separatorColor = props.backgroundColor ?? DEFAULT_BACKGROUND_COLOR; + const chartLevelFormat = getChartLevelValueFormat(series); + const valueAxisWidth = computeValueAxisWidth(axisValues, chartLevelFormat); return ( @@ -654,11 +705,12 @@ function buildBarChart(props: ResolvedProps) { renderValueYAxis(true) ) : ( formatValueYAxisTick(value, chartLevelFormat)} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, true)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -685,7 +737,11 @@ function buildBarChart(props: ResolvedProps) { animationDuration={CHART_ANIMATION_DURATION_MS} > {showDataLabels && !isStacked && ( - + formatDataLabel(value, s.value_format)} + {...DATA_LABEL_PROPS} + /> )} {stackTotalLabel && i === stackTotalLabelIndex && } @@ -752,7 +808,6 @@ function buildAreaChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, - valueFormatter, } = props; const gradientIdPrefix = props.gradientIdPrefix ?? ''; const gradientIdFor = (index: number) => `${gradientIdPrefix}grad-${index}`; @@ -763,6 +818,8 @@ function buildAreaChart(props: ResolvedProps) { const axisValues = isStacked ? collectStackedAxisValues(data, dataKeys) : collectAxisValues(data, dataKeys); const { renderedSeries, stackTotalLabel, stackTotalLabelIndex } = getDataLabelSetup(props, isStacked); const pointLabelContent = showDataLabels && !isStacked ? buildPointLabelContentBySeries(data, series) : new Map(); + const chartLevelFormat = getChartLevelValueFormat(series); + const valueAxisWidth = computeValueAxisWidth(axisValues, chartLevelFormat); return ( @@ -783,11 +840,12 @@ function buildAreaChart(props: ResolvedProps) { renderValueYAxis(true) ) : ( formatValueYAxisTick(value, chartLevelFormat)} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, zeroBaseline)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -857,8 +915,12 @@ function buildComboChart(props: ResolvedProps) { const leftSeries = series.filter((s) => comboAxisSide(s) === 'left'); const rightSeries = series.filter((s) => comboAxisSide(s) === 'right'); + const leftFormat = getChartLevelValueFormat(leftSeries); + const rightFormat = getChartLevelValueFormat(rightSeries); const leftDomain = resolveComboAxisDomain(data, leftSeries, yAxisMin, yAxisMax); const rightDomain = resolveComboAxisDomain(data, rightSeries, yAxisRightMin, yAxisRightMax); + const leftAxisWidth = computeComboAxisWidth(data, leftSeries, leftFormat); + const rightAxisWidth = computeComboAxisWidth(data, rightSeries, rightFormat); const areaSeries = series.filter((s) => comboSeriesType(s, chartType) === 'area'); const pointLabelContent = showDataLabels ? buildPointLabelContentBySeries(data, series) : new Map(); @@ -887,11 +949,12 @@ function buildComboChart(props: ResolvedProps) { {leftSeries.length > 0 && ( formatValueYAxisTick(value, leftFormat)} domain={leftDomain} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} label={axisLabel(yAxisLabel, 'left')} @@ -900,12 +963,13 @@ function buildComboChart(props: ResolvedProps) { {rightSeries.length > 0 && ( formatValueYAxisTick(value, rightFormat)} domain={rightDomain} allowDataOverflow={yAxisRightMin !== undefined || yAxisRightMax !== undefined} label={axisLabel(yAxisRightLabel, 'right')} @@ -946,6 +1010,18 @@ function resolveComboAxisDomain( return resolveYAxisDomain(explicitMin, explicitMax, values, true); } +function computeComboAxisWidth( + data: Record[], + axisSeries: displayChart.SeriesConfig[], + valueFormat?: displayChart.ValueFormat, +) { + const values = collectAxisValues( + data, + axisSeries.map((s) => s.data_key), + ); + return computeValueAxisWidth(values, valueFormat); +} + function renderComboSeries( series: displayChart.SeriesConfig, index: number, @@ -1017,23 +1093,13 @@ function axisLabel(label: string | undefined, side: displayChart.YAxisSide) { } function buildScatterChart(props: ResolvedProps) { - const { - data, - xAxisKey, - xAxisType, - series, - colorFor, - showGrid, - children, - margin, - yAxisMin, - yAxisMax, - valueFormatter, - } = props; + const { data, xAxisKey, xAxisType, series, colorFor, showGrid, children, margin, yAxisMin, yAxisMax } = props; + const chartLevelFormat = getChartLevelValueFormat(series); const axisValues = collectAxisValues( data, series.map((s) => s.data_key), ); + const valueAxisWidth = computeValueAxisWidth(axisValues, chartLevelFormat); return ( @@ -1048,11 +1114,12 @@ function buildScatterChart(props: ResolvedProps) { height={CATEGORY_XAXIS_HEIGHT} /> formatValueYAxisTick(value, chartLevelFormat)} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, false)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -1071,13 +1138,17 @@ function buildScatterChart(props: ResolvedProps) { } function buildRadarChart(props: ResolvedProps) { - const { data, xAxisKey, series, colorFor, children, margin, valueFormatter } = props; + const { data, xAxisKey, series, colorFor, children, margin } = props; + const chartLevelFormat = getChartLevelValueFormat(series); return ( - + formatValueYAxisTick(value, chartLevelFormat)} + /> {children} {series.map((s, i) => ( [], series: displayChart.SeriesConfig[]) { - return new Map(series.map((item) => [item.data_key, renderPointLabel(getLabeledIndices(data, item.data_key))])); + return new Map( + series.map((item) => [ + item.data_key, + renderPointLabel(getLabeledIndices(data, item.data_key), item.value_format), + ]), + ); } function DataLabelText({ x, y, children }: { x: number; y: number; children: React.ReactNode }) { @@ -1215,7 +1291,7 @@ function DataLabelText({ x, y, children }: { x: number; y: number; children: Rea ); } -function renderPointLabel(labeledIndices: Set) { +function renderPointLabel(labeledIndices: Set, valueFormat?: displayChart.ValueFormat) { return ({ x, y, value, index }: PointLabelProps) => { const labelX = toFiniteNumber(x); const labelY = toFiniteNumber(y); @@ -1223,7 +1299,7 @@ function renderPointLabel(labeledIndices: Set) { return null; } - const label = formatDataLabel(value); + const label = formatDataLabel(value, valueFormat); if (!label) { return null; } @@ -1287,6 +1363,7 @@ function getMaxValueIndex(data: Record[], dataKey: string): num } function renderStackTotalLabel(data: Record[], series: displayChart.SeriesConfig[]) { + const valueFormat = getChartLevelValueFormat(series); return ({ x, y, width, index }: StackTotalLabelProps) => { const labelX = getCenteredLabelX(x, width); const labelY = toFiniteNumber(y); @@ -1301,7 +1378,7 @@ function renderStackTotalLabel(data: Record[], series: displayC return ( - {formatCompactNumber(total)} + {formatChartValue(total, valueFormat, { compact: true })} ); }; @@ -1350,3 +1427,39 @@ function toFiniteNumber(value: unknown): number | null { } return null; } + +function getChartLevelValueFormat(series: displayChart.SeriesConfig[]): displayChart.ValueFormat | undefined { + const formats = series.filter((item) => !item.is_total).map((item) => item.value_format); + const firstFormat = formats[0]; + const allShareFormat = formats.every( + (format) => + format?.d3_format === firstFormat?.d3_format && + format?.compact === firstFormat?.compact && + format?.prefix === firstFormat?.prefix && + format?.suffix === firstFormat?.suffix, + ); + return allShareFormat ? firstFormat : undefined; +} + +function formatWithD3(value: number, valueFormat?: displayChart.ValueFormat): string | null { + if (!valueFormat?.d3_format) { + return null; + } + try { + const specifier = formatSpecifier(valueFormat.d3_format); + const formatted = d3Format(valueFormat.d3_format)(value); + if (specifier.type !== 's' || valueFormat.compact === 'si') { + return formatted; + } + return formatted.replace(/k$/, 'K').replace(/G$/, 'B'); + } catch { + return null; + } +} + +function attachValueAffixes(value: string, valueFormat?: displayChart.ValueFormat): string { + const hasNegativeSign = value.startsWith('-') || value.startsWith('−'); + const body = hasNegativeSign ? value.slice(1) : value; + const sign = hasNegativeSign ? '-' : ''; + return `${sign}${valueFormat?.prefix ?? ''}${body}${valueFormat?.suffix ?? ''}`; +} diff --git a/apps/shared/src/tools/display-chart.ts b/apps/shared/src/tools/display-chart.ts index 477e3c157..12126053b 100644 --- a/apps/shared/src/tools/display-chart.ts +++ b/apps/shared/src/tools/display-chart.ts @@ -28,6 +28,33 @@ const ChartTypeSchema = z.union([ChartTypeEnum, CustomChartTypeSchema]); export const XAxisTypeEnum = z.enum(['date', 'number', 'category']); +export const ValueFormatSchema = z.object({ + d3_format: z + .string() + .describe( + 'd3-format specifier applied to the number as-is, such as ",.2f", ".1f", ",.0f", or ".2s". Do not use d3\'s "%" type: for a percentage already stored as 42.5, use { d3_format: ".1f", suffix: "%" } so the value is not multiplied by 100.', + ) + .optional(), + compact: z + .enum(['financial', 'si']) + .describe( + 'How d3 SI-prefix "s" output is displayed. Defaults to "financial", which maps k to K and G to B while leaving M and T unchanged. Use "si" for scientific units such as bytes so d3 letters remain unchanged.', + ) + .optional(), + prefix: z + .string() + .describe( + 'Free text placed before the number. Use for any currency symbol, such as "$", "€", "¥", or "£". Example USD money: { d3_format: ",.2f", prefix: "$", compact: "financial" }.', + ) + .optional(), + suffix: z + .string() + .describe( + 'Free text placed after the number. Use for percentages and any unit. Examples: { d3_format: ".1f", suffix: "%" } for 42.5 as 42.5%; { d3_format: ",.0f", suffix: " V" } for volts; { d3_format: ".2s", suffix: "B", compact: "si" } for bytes.', + ) + .optional(), +}); + export const ComparisonModeEnum = z.enum(['percentage', 'variation', 'absolute', 'none']); export type ComparisonMode = z.infer; @@ -41,6 +68,9 @@ export const SeriesConfigSchema = z.object({ data_key: z.string().describe('Column name from SQL result to plot.'), color: z.string().describe('CSS color (defaults to theme colors).').optional(), label: z.string().describe('Label to display in the legend.').optional(), + value_format: ValueFormatSchema.describe( + 'Controls how this series\' numeric values render on the axis, tooltip, data labels, and KPI card. The number is formatted as-is: use prefix for any currency symbol and suffix for percentages or units; never use d3\'s "%" type because it multiplies by 100. Examples: USD { d3_format: ",.2f", prefix: "$", compact: "financial" }; percentage stored as 42.5 { d3_format: ".1f", suffix: "%" }; volts { d3_format: ",.0f", suffix: " V" }; bytes { d3_format: ".2s", suffix: "B", compact: "si" }.', + ).optional(), is_total: z .boolean() .describe( @@ -269,6 +299,7 @@ export const OutputSchema = z.object({ export type ChartType = z.infer; export type XAxisType = z.infer; +export type ValueFormat = z.infer; export type SeriesType = z.infer; export type YAxisSide = z.infer; export type SeriesConfig = z.infer; diff --git a/apps/shared/tests/chart-builder.test.tsx b/apps/shared/tests/chart-builder.test.tsx index e15f42154..0b551c645 100644 --- a/apps/shared/tests/chart-builder.test.tsx +++ b/apps/shared/tests/chart-builder.test.tsx @@ -1,7 +1,61 @@ import type { ReactElement } from 'react'; import { describe, expect, it } from 'vitest'; -import { buildChart, computeKpiComparison, describePreviousPeriod } from '../src/chart-builder'; +import { + buildChart, + computeValueAxisWidth, + formatChartValue, + computeKpiComparison, + describePreviousPeriod, +} from '../src/chart-builder'; + +describe('formatChartValue', () => { + it('uses locale formatting by default', () => { + expect(formatChartValue(1234)).toBe('1,234'); + }); + + it('formats currency and places the prefix after the negative sign', () => { + expect( + formatChartValue(-1200, { + d3_format: '.2s', + prefix: '$', + }), + ).toBe('-$1.2K'); + }); + + it('adds a percentage suffix without multiplying the value', () => { + expect(formatChartValue(42.5, { d3_format: '.1f', suffix: '%' })).toBe('42.5%'); + }); + + it('maps SI billions to financial notation by default', () => { + expect(formatChartValue(2_500_000_000, { d3_format: '.2s' })).toBe('2.5B'); + }); + + it('keeps SI notation when requested', () => { + expect(formatChartValue(2_500_000_000, { d3_format: '.2s', compact: 'si' })).toBe('2.5G'); + }); + + it('falls back gracefully for an invalid d3 specifier', () => { + expect(formatChartValue(1234, { d3_format: 'invalid', prefix: '$' }, { compact: true })).toBe('$1,234'); + }); +}); + +describe('computeValueAxisWidth', () => { + it('keeps short labels near the minimum width', () => { + expect(computeValueAxisWidth([1, 10])).toBe(36); + expect(computeValueAxisWidth([])).toBe(40); + }); + + it('expands for formatted currency labels and clamps extreme widths', () => { + const valueFormat = { d3_format: ',.0f', prefix: '$' }; + const plainWidth = computeValueAxisWidth([1, 10]); + const currencyWidth = computeValueAxisWidth([0, 980_000], valueFormat); + + expect(currencyWidth).toBeGreaterThan(plainWidth); + expect(currencyWidth).toBe(82); + expect(computeValueAxisWidth([0, Number.MAX_SAFE_INTEGER], valueFormat)).toBe(120); + }); +}); describe('buildChart', () => { it('uses stack totals for stacked bar fallback bounds', () => { diff --git a/apps/shared/tests/story-segments.test.ts b/apps/shared/tests/story-segments.test.ts index 837cc6288..9fa43435c 100644 --- a/apps/shared/tests/story-segments.test.ts +++ b/apps/shared/tests/story-segments.test.ts @@ -453,6 +453,30 @@ describe('splitCodeIntoSegments chart series', () => { expect(seriesOf(code)).toEqual([{ data_key: 'rev', color: 'var(--chart-1)', label: 'a "quoted" label' }]); }); + + it('round-trips a series value format', () => { + const code = buildStoryChartBlock({ + query_id: 'q1', + chart_type: 'bar', + x_axis_key: 'month', + series: [ + { + data_key: 'rev', + color: 'var(--chart-1)', + value_format: { d3_format: ',.2f', compact: 'financial', prefix: '$', suffix: ' USD' }, + }, + ], + title: 'Revenue', + }); + + expect(seriesOf(code)).toEqual([ + { + data_key: 'rev', + color: 'var(--chart-1)', + value_format: { d3_format: ',.2f', compact: 'financial', prefix: '$', suffix: ' USD' }, + }, + ]); + }); }); describe('parseChartBlock x-axis requirements', () => { diff --git a/package-lock.json b/package-lock.json index 76d8bd305..9255c9250 100644 --- a/package-lock.json +++ b/package-lock.json @@ -735,9 +735,11 @@ "apps/shared": { "name": "@nao/shared", "dependencies": { + "d3-format": "^3.1.0", "zod": "^4.2.1" }, "devDependencies": { + "@types/d3-format": "^3.0.4", "vitest": "^4.0.18" } },
{error}
{error ?? UNSUPPORTED_NUMBER_FORMAT_MESSAGE}