From c5171a797be36cf1bd79ece2e17da058e5496fa1 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 22 Jul 2026 18:39:36 +0200 Subject: [PATCH 1/7] Add value formatting to charts ($, %, units) --- apps/backend/src/utils/story-html.tsx | 39 +++- apps/backend/tests/story-html-tabs.test.ts | 12 + .../components/settings/usage-chart-card.tsx | 3 +- .../side-panel/story-chart-embed.tsx | 9 +- .../tool-calls/display-chart-edit-dialog.tsx | 206 ++++++++++++++---- .../components/tool-calls/display-chart.tsx | 2 + apps/frontend/src/components/ui/chart.tsx | 17 +- .../routes/_sidebar-layout.settings.usage.tsx | 35 ++- apps/shared/package.json | 2 + apps/shared/src/chart-builder.tsx | 141 ++++++++++-- apps/shared/src/story-segments.ts | 9 +- apps/shared/src/tools/display-chart.ts | 31 +++ apps/shared/tests/chart-builder.test.tsx | 50 ++++- apps/shared/tests/story-segments.test.ts | 24 ++ package-lock.json | 2 + 15 files changed, 513 insertions(+), 69 deletions(-) diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index 2bd4b92d6..e9eeb0d67 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -1,4 +1,4 @@ -import { bucketPieData, DEFAULT_COLORS, defaultColorFor, formatCompactNumber, labelize } from '@nao/shared'; +import { bucketPieData, DEFAULT_COLORS, defaultColorFor, formatChartValue, labelize } from '@nao/shared'; import { type DateFormatSettings, DEFAULT_DATE_FORMAT_SETTINGS, @@ -218,7 +218,7 @@ function KpiCards({ chart, rows }: { chart: ParsedChartBlock; rows: Record {chart.series.map((s) => { const raw = firstRow[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; return (
@@ -429,7 +429,35 @@ 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:''))} + function formatSi(v,precision,compact){ + var units=['','k','M','G','T','P','E']; + var exponent=Math.min(6,Math.max(0,Math.floor(Math.log10(Math.abs(v))/3))); + var scaled=v/Math.pow(1000,exponent); + var number=precision?Number(scaled.toPrecision(precision)).toString():String(scaled); + var unit=units[exponent]; + if(compact!=='si'){if(unit==='k')unit='K';if(unit==='G')unit='B'} + return number+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]),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'); @@ -503,6 +531,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; @@ -521,14 +550,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 && (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 ac7d633d5..639fd8c63 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 { UsageRecord } from '@nao/backend/usage'; +import type { displayChart } from '@nao/shared/tools'; import { ChartDisplay } from '@/components/tool-calls/display-chart'; import { SettingsCard } from '@/components/ui/settings-card'; @@ -10,7 +11,7 @@ export interface UsageChartCardProps { isError: boolean; data: UsageRecord[]; chartType: 'bar' | 'stacked_bar'; - series: { data_key: string; color: string; label: string }[]; + series: displayChart.SeriesConfig[]; xAxisLabelFormatter: (value: string) => string; filters: React.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 eda82a04d..3d78a89ea 100644 --- a/apps/frontend/src/components/side-panel/story-chart-embed.tsx +++ b/apps/frontend/src/components/side-panel/story-chart-embed.tsx @@ -16,7 +16,13 @@ interface ChartBlock { chartType: string; xAxisKey: string; xAxisType: string | null; - series: Array<{ data_key: string; color: string; label?: string; is_total?: boolean }>; + series: Array<{ + data_key: string; + color: string; + label?: string; + is_total?: boolean; + value_format?: displayChart.ValueFormat; + }>; yAxisMin?: number; yAxisMax?: number; title: string; @@ -119,6 +125,7 @@ export function StoryChartEmbedShell({ chart, availableColumns, children }: Stor color: s.color || undefined, label: s.label, is_total: s.is_total, + value_format: s.value_format, })), y_axis_min: chart.yAxisMin, y_axis_max: chart.yAxisMax, 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 7de12cf5b..82bd2a41b 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,6 +1,6 @@ import { displayChart } from '@nao/shared/tools'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Trash2 } from 'lucide-react'; +import { Plus, Trash2, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Button } from '../ui/button'; @@ -11,6 +11,7 @@ import { Switch } from '../ui/switch'; 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' }, @@ -34,6 +35,8 @@ const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto' const Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES = new Set(['pie', 'kpi_card', 'radar']); +type UnitPlacement = 'prefix' | 'suffix'; + /** Maps a 100% stacked type back to its absolute-stacked counterpart, so the type dropdown stays clean. */ function baseChartType(type: displayChart.ChartType): displayChart.ChartType { if (type === 'stacked_bar_100') { @@ -121,6 +124,22 @@ 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 removeSeriesAt = (index: number) => { setDraft((prev) => ({ ...prev, @@ -284,42 +303,115 @@ export function ChartConfigEditDialog({
- {draft.series.map((series, index) => ( -
- 0 ? availableColumns : [series.data_key]} - onChange={(value) => updateSeriesAt(index, { data_key: value })} - /> - updateSeriesAt(index, { label: e.target.value || undefined })} - placeholder='Label (optional)' - className='h-8 rounded-lg text-sm bg-panel' - /> - updateSeriesAt(index, { color: e.target.value })} - className='h-8 w-8 cursor-pointer overflow-hidden rounded-lg border-none bg-transparent p-0 [&::-moz-color-swatch]:rounded-lg [&::-moz-color-swatch]:border-none [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-lg [&::-webkit-color-swatch]:border-none' - /> - -
- ))} + {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 ?? ''); + + return ( +
+
+ 0 ? availableColumns : [series.data_key] + } + onChange={(value) => updateSeriesAt(index, { data_key: value })} + /> + + updateSeriesAt(index, { label: e.target.value || undefined }) + } + placeholder='Label (optional)' + className='h-8 rounded-lg text-sm bg-panel' + /> + updateSeriesAt(index, { color: e.target.value })} + className='h-8 w-8 cursor-pointer overflow-hidden rounded-lg border-none bg-transparent p-0 [&::-moz-color-swatch]:rounded-lg [&::-moz-color-swatch]:border-none [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-lg [&::-webkit-color-swatch]:border-none' + /> + +
+
+
+
+ Number format + + How to format + +
+ + updateSeriesValueFormatAt(index, 'd3_format', value) + } + onClear={() => updateSeriesValueFormatAt(index, 'd3_format', '')} + placeholder='e.g. ,.2f' + ariaLabel='d3-format specifier' + className='h-8 rounded-lg text-sm bg-panel' + /> +
+
+ Unit + + setSeriesUnit(index, { unit: value, placement }) + } + onClear={() => setSeriesUnit(index, { unit: '', placement })} + placeholder='$ or %' + ariaLabel='Value unit' + className='h-8 rounded-lg text-sm bg-panel' + /> +
+
+ Placement + +
+
+
+ ); + })}
@@ -475,6 +567,40 @@ 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 && ( + + )} +
+ ); +} + function getSelectableColumns(columns: string[]): string[] { return Array.from(new Set(columns.filter((column) => column.length > 0))); } @@ -491,6 +617,12 @@ 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 HEX_RE = /^#[0-9a-fA-F]{6}$/; function normalizeHexColor(color?: 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 5373fdac8..eeb85ed32 100644 --- a/apps/frontend/src/components/tool-calls/display-chart.tsx +++ b/apps/frontend/src/components/tool-calls/display-chart.tsx @@ -426,6 +426,7 @@ export const ChartDisplay = memo(function ChartDisplay({ acc[toKey(category)] = { label: labelize(category, dateFormat), color: Colors[index % Colors.length], + valueFormat: series[0]?.value_format, }; return acc; }, @@ -442,6 +443,7 @@ 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); diff --git a/apps/frontend/src/components/ui/chart.tsx b/apps/frontend/src/components/ui/chart.tsx index 010679449..7976cd232 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, 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 }); }; @@ -166,8 +168,9 @@ function ChartTooltipContent({ ); // In 100% stacked mode every category totals 100%, so ignore already-aggregated total series. const showTotal = numericValues.length > 1 && (percent || (!hasTotalSeries && !hideTotal)); - const formatValue = (value: number) => - percent ? formatPercentShare(value, shareBase) : formatCompactNumber(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()} )} @@ -249,7 +256,7 @@ function ChartTooltipContent({
Total - {percent ? '100%' : formatCompactNumber(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 cbabbba16..d838aed64 100644 --- a/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx +++ b/apps/frontend/src/routes/_sidebar-layout.settings.usage.tsx @@ -5,6 +5,7 @@ import { format, formatDistanceToNow } from 'date-fns'; import { ThumbsDown, ThumbsUp } from 'lucide-react'; import type { Granularity } from '@nao/backend/usage'; import type { LlmProvider } from '@nao/shared/types'; +import type { displayChart } from '@nao/shared/tools'; import type { ChartView } from '@/components/settings/usage-filters'; import { UsageChartCard } from '@/components/settings/usage-chart-card'; import { UsageFilters, dateFormats } from '@/components/settings/usage-filters'; @@ -19,6 +20,12 @@ export const Route = createFileRoute('/_sidebar-layout/settings/usage')({ component: UsagePage, }); +const USD_VALUE_FORMAT = { + d3_format: ',.2f', + prefix: '$', + compact: 'financial', +} satisfies displayChart.ValueFormat; + function UsagePage() { const [granularity, setGranularity] = useState('day'); const [provider, setProvider] = useState('all'); @@ -102,10 +109,30 @@ function UsagePage() { chartType='stacked_bar' xAxisLabelFormatter={(value) => format(new Date(value), dateFormats[granularity])} series={[ - { data_key: 'inputNoCacheCost', color: 'var(--chart-1)', label: 'Input' }, - { data_key: 'inputCacheReadCost', color: 'var(--chart-2)', label: 'Input (cache read)' }, - { data_key: 'inputCacheWriteCost', color: 'var(--chart-3)', label: 'Input (cache write)' }, - { data_key: 'outputCost', color: 'var(--chart-4)', label: 'Output' }, + { + 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, + }, ]} filters={filtersComponent} /> diff --git a/apps/shared/package.json b/apps/shared/package.json index bc7eaff45..c0bb8b451 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -21,9 +21,11 @@ "format": "prettier --write ." }, "dependencies": { + "d3-format": "^3.1.0", "zod": "^4.2.1" }, "devDependencies": { + "@types/d3-format": "^3.0.4", "vitest": "^4.0.18" } } diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index b5d052018..93bc0041a 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -1,3 +1,4 @@ +import { format as d3Format, formatSpecifier } from 'd3-format'; import React from 'react'; import { Area, @@ -61,6 +62,10 @@ const DEFAULT_BACKGROUND_COLOR = 'var(--background, #ffffff)'; * title. Y values are abbreviated by `formatYAxisTick` (e.g. `1.2K`). */ const Y_AXIS_WIDTH = 36; +const VALUE_AXIS_DEFAULT_WIDTH = 40; +const VALUE_AXIS_MAX_WIDTH = 120; +const VALUE_AXIS_CHARACTER_WIDTH = 7; +const VALUE_AXIS_PADDING = 12; export function labelize(key: unknown, dateFormat?: DateFormatSettings | null): string { const str = String(key); @@ -84,6 +89,16 @@ export function formatCompactNumber(value: number): string { return value.toLocaleString(); } +export function formatChartValue( + value: number, + valueFormat?: displayChart.ValueFormat, + opts?: { compact?: boolean }, +): string { + const formatted = formatWithD3(value, valueFormat); + const body = formatted ?? (opts?.compact ? formatCompactNumber(value) : value.toLocaleString()); + return attachValueAffixes(body, valueFormat); +} + /** * Formats a Y axis tick so it stays short enough for the narrow axis band * ({@link Y_AXIS_WIDTH}px) without losing meaningful precision. Abbreviates by @@ -110,6 +125,31 @@ export function formatYAxisTick(value: number): string { return String(Number(abs < 1 ? value.toPrecision(2) : value.toFixed(2))); } +export function computeValueAxisWidth(axisValues: number[], valueFormat?: displayChart.ValueFormat): number { + if (axisValues.length === 0) { + return VALUE_AXIS_DEFAULT_WIDTH; + } + + let minimumValue = axisValues[0]; + let maximumValue = axisValues[0]; + for (const value of axisValues) { + minimumValue = Math.min(minimumValue, value); + maximumValue = Math.max(maximumValue, value); + } + + const candidates = [minimumValue, maximumValue, 0]; + if (maximumValue > 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))); } @@ -137,9 +177,9 @@ export function formatPercentShare(value: number, total: number): string { return `${Number.isInteger(rounded) ? rounded : rounded.toFixed(1)}%`; } -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 { @@ -356,7 +396,12 @@ function buildKpiCard(props: ResolvedProps) { return ( {series.map((s) => ( - + ))} ); @@ -366,11 +411,19 @@ function KpiCardContainer({ children }: { children: React.ReactNode }) { return
{children}
; } -function KpiCard({ value, displayName }: { value: unknown; displayName: string }) { +function KpiCard({ + value, + displayName, + valueFormat, +}: { + value: unknown; + displayName: string; + valueFormat?: displayChart.ValueFormat; +}) { let formattedValue = ''; if (typeof value === 'number') { - formattedValue = formatCompactNumber(value); + formattedValue = formatChartValue(value, valueFormat); } else if (typeof value === 'string') { formattedValue = value; } @@ -397,6 +450,16 @@ function renderValueYAxis(isPercent = false) { ); } +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, @@ -448,6 +511,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 ( @@ -456,11 +521,12 @@ function buildBarChart(props: ResolvedProps) { renderValueYAxis(true) ) : ( formatValueYAxisTick(value, chartLevelFormat)} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, true)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -478,7 +544,11 @@ function buildBarChart(props: ResolvedProps) { isAnimationActive={false} > {showDataLabels && !isStacked && ( - + formatDataLabel(value, s.value_format)} + {...DATA_LABEL_PROPS} + /> )} {stackTotalLabel && i === stackTotalLabelIndex && } @@ -550,6 +620,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 ( @@ -570,11 +642,12 @@ function buildAreaChart(props: ResolvedProps) { renderValueYAxis(true) ) : ( formatValueYAxisTick(value, chartLevelFormat)} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, zeroBaseline)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -601,10 +674,12 @@ function buildAreaChart(props: ResolvedProps) { function buildScatterChart(props: ResolvedProps) { 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 ( @@ -618,11 +693,12 @@ function buildScatterChart(props: ResolvedProps) { minTickGap={12} /> formatValueYAxisTick(value, chartLevelFormat)} domain={resolveYAxisDomain(yAxisMin, yAxisMax, axisValues, false)} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} /> @@ -641,12 +717,16 @@ function buildScatterChart(props: ResolvedProps) { function buildRadarChart(props: ResolvedProps) { 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 }) { @@ -782,7 +867,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); @@ -790,7 +875,7 @@ function renderPointLabel(labeledIndices: Set) { return null; } - const label = formatDataLabel(value); + const label = formatDataLabel(value, valueFormat); if (!label) { return null; } @@ -854,6 +939,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); @@ -868,7 +954,7 @@ function renderStackTotalLabel(data: Record[], series: displayC return ( - {formatCompactNumber(total)} + {formatChartValue(total, valueFormat, { compact: true })} ); }; @@ -917,3 +1003,30 @@ function toFiniteNumber(value: unknown): number | null { } return null; } + +function getChartLevelValueFormat(series: displayChart.SeriesConfig[]): displayChart.ValueFormat | undefined { + return series.find((item) => !item.is_total)?.value_format; +} + +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/story-segments.ts b/apps/shared/src/story-segments.ts index c48e8d296..3e2dcd0b0 100644 --- a/apps/shared/src/story-segments.ts +++ b/apps/shared/src/story-segments.ts @@ -1,12 +1,19 @@ import { buildStoryTableBlock } from './chart-block'; import { type ColumnConditionalFormats, sanitizeConditionalFormats } from './conditional-formatting'; +import type { ValueFormat } from './tools/display-chart'; export interface ParsedChartBlock { queryId: string; chartType: string; xAxisKey: string; xAxisType: string | null; - series: Array<{ data_key: string; color: string; label?: string; is_total?: boolean }>; + series: Array<{ + data_key: string; + color: string; + label?: string; + is_total?: boolean; + value_format?: ValueFormat; + }>; yAxisMin?: number; yAxisMax?: number; title: string; diff --git a/apps/shared/src/tools/display-chart.ts b/apps/shared/src/tools/display-chart.ts index 74495589f..8a043de5b 100644 --- a/apps/shared/src/tools/display-chart.ts +++ b/apps/shared/src/tools/display-chart.ts @@ -17,10 +17,40 @@ export const ChartTypeEnum = z.enum([ 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 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( @@ -188,6 +218,7 @@ export const OutputSchema = z.object({ export type ChartType = z.infer; export type XAxisType = z.infer; +export type ValueFormat = z.infer; export type SeriesConfig = z.infer; export type ColorScaleRule = z.infer; export type ThresholdRule = z.infer; diff --git a/apps/shared/tests/chart-builder.test.tsx b/apps/shared/tests/chart-builder.test.tsx index 7eff853b5..9112f8d07 100644 --- a/apps/shared/tests/chart-builder.test.tsx +++ b/apps/shared/tests/chart-builder.test.tsx @@ -1,7 +1,55 @@ import type { ReactElement } from 'react'; import { describe, expect, it } from 'vitest'; -import { buildChart } from '../src/chart-builder'; +import { buildChart, computeValueAxisWidth, formatChartValue } 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 a9856006d..e27a0af6b 100644 --- a/apps/shared/tests/story-segments.test.ts +++ b/apps/shared/tests/story-segments.test.ts @@ -64,6 +64,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('splitCodeIntoSegments chart total visibility', () => { diff --git a/package-lock.json b/package-lock.json index 9b401f389..5e3384a86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -333,9 +333,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" } }, From 0f1bc46d1d44dcbbe3eeb9ee4308cf5b0a03d96f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Jul 2026 08:13:47 +0000 Subject: [PATCH 2/7] Fixed Cubic complaints --- .../tool-calls/display-chart-edit-dialog.tsx | 99 +++++++++---------- .../components/tool-calls/display-chart.tsx | 8 +- apps/shared/src/chart-builder.tsx | 11 ++- 3 files changed, 64 insertions(+), 54 deletions(-) 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 82bd2a41b..a013d9a52 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 @@ -35,8 +35,6 @@ const X_AXIS_TYPE_OPTIONS: { value: NonNullable | 'auto' const Y_AXIS_RANGE_UNSUPPORTED_CHART_TYPES = new Set(['pie', 'kpi_card', 'radar']); -type UnitPlacement = 'prefix' | 'suffix'; - /** Maps a 100% stacked type back to its absolute-stacked counterpart, so the type dropdown stays clean. */ function baseChartType(type: displayChart.ChartType): displayChart.ChartType { if (type === 'stacked_bar_100') { @@ -84,6 +82,13 @@ export function ChartConfigEditDialog({ const [yAxisMaxText, setYAxisMaxText] = useState(toRangeString(config.y_axis_max)); const [error, setError] = useState(null); 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], + ); useEffect(() => { if (open) { @@ -103,6 +108,11 @@ export function ChartConfigEditDialog({ const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + if (unsupportedNumberFormat) { + setError(UNSUPPORTED_NUMBER_FORMAT_MESSAGE); + return; + } + const parsed = displayChart.ChartInputSchema.safeParse(draft); if (!parsed.success) { setError(parsed.error.issues[0]?.message ?? 'Invalid chart configuration.'); @@ -130,16 +140,6 @@ export function ChartConfigEditDialog({ 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 removeSeriesAt = (index: number) => { setDraft((prev) => ({ ...prev, @@ -304,12 +304,6 @@ 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 ?? ''); - return (
@@ -347,7 +341,7 @@ export function ChartConfigEditDialog({
-
+
Number format @@ -367,46 +361,35 @@ export function ChartConfigEditDialog({ } onClear={() => updateSeriesValueFormatAt(index, 'd3_format', '')} placeholder='e.g. ,.2f' - ariaLabel='d3-format specifier' + ariaLabel='number format' className='h-8 rounded-lg text-sm bg-panel' />
- Unit + Prefix - setSeriesUnit(index, { unit: value, placement }) + updateSeriesValueFormatAt(index, 'prefix', value) } - onClear={() => setSeriesUnit(index, { unit: '', placement })} - placeholder='$ or %' - ariaLabel='Value unit' + onClear={() => updateSeriesValueFormatAt(index, 'prefix', '')} + placeholder='e.g. $' + ariaLabel='value prefix' className='h-8 rounded-lg text-sm bg-panel' />
- Placement - + onClear={() => updateSeriesValueFormatAt(index, 'suffix', '')} + placeholder='e.g. %' + ariaLabel='value suffix' + className='h-8 rounded-lg text-sm bg-panel' + />
@@ -464,7 +447,9 @@ export function ChartConfigEditDialog({
- {error &&

{error}

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

{error ?? UNSUPPORTED_NUMBER_FORMAT_MESSAGE}

+ )} @@ -589,8 +574,7 @@ function ClearableInput({ value, onChange, onClear, placeholder, ariaLabel, clas {value && (
{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 ?? ''); + return (
@@ -341,7 +359,7 @@ export function ChartConfigEditDialog({
-
+
Number format @@ -361,35 +379,46 @@ export function ChartConfigEditDialog({ } onClear={() => updateSeriesValueFormatAt(index, 'd3_format', '')} placeholder='e.g. ,.2f' - ariaLabel='number format' + ariaLabel='d3-format specifier' className='h-8 rounded-lg text-sm bg-panel' />
- Prefix + Unit - updateSeriesValueFormatAt(index, 'prefix', value) + setSeriesUnit(index, { unit: value, placement }) } - onClear={() => updateSeriesValueFormatAt(index, 'prefix', '')} - placeholder='e.g. $' - ariaLabel='value prefix' + onClear={() => setSeriesUnit(index, { unit: '', placement })} + placeholder='$ or %' + ariaLabel='Value unit' className='h-8 rounded-lg text-sm bg-panel' />
- Suffix - - updateSeriesValueFormatAt(index, 'suffix', value) + Placement +
From 2d6c387e4b1291d4d20b1c731ac453d3144af8c9 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 23 Jul 2026 12:24:57 +0200 Subject: [PATCH 5/7] Add new docs link to number format field --- .../src/components/tool-calls/display-chart-edit-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 82bd2a41b..5cfd10c90 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 @@ -352,7 +352,7 @@ export function ChartConfigEditDialog({
Number format Date: Fri, 24 Jul 2026 12:26:50 +0200 Subject: [PATCH 6/7] Improve UX of value formatting dialog --- .../tool-calls/display-chart-edit-dialog.tsx | 215 +++++++++++++----- apps/shared/src/chart-builder.tsx | 10 +- 2 files changed, 157 insertions(+), 68 deletions(-) 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 20bd15721..f9250245e 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 @@ -106,6 +106,7 @@ 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); @@ -134,6 +135,7 @@ export function ChartConfigEditDialog({ setYAxisRightMaxText(toRangeString(config.y_axis_right_max)); setPaletteHexes(resolveChartPaletteHexes()); setError(null); + setOpenValueFormatIndexes(new Set()); } }, [open, config]); @@ -200,6 +202,18 @@ export function ChartConfigEditDialog({ 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, @@ -385,9 +399,10 @@ export function ChartConfigEditDialog({ 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, + }) + } + /> + )}
); } @@ -450,68 +486,22 @@ export function ChartConfigEditDialog({ onChange={(value) => updateSeriesAt(index, { series_type: value })} /> {row} -
-
- - - updateSeriesValueFormatAt(index, 'd3_format', value) - } - onClear={() => updateSeriesValueFormatAt(index, 'd3_format', '')} - placeholder='e.g. ,.2f' - ariaLabel='d3-format specifier' - className='h-8 rounded-lg text-sm bg-panel' - /> -
-
- Unit - - setSeriesUnit(index, { unit: value, placement }) - } - onClear={() => setSeriesUnit(index, { unit: '', placement })} - placeholder='$ or %' - ariaLabel='Value unit' - className='h-8 rounded-lg text-sm bg-panel' - /> -
-
- Placement - -
-
+ {isOpen && ( + + updateSeriesValueFormatAt(index, 'd3_format', value) + } + onUnitChange={(nextUnit, nextPlacement) => + setSeriesUnit(index, { + unit: nextUnit, + placement: nextPlacement, + }) + } + /> + )}
); })} @@ -877,6 +867,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; diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index 8b2af1ceb..ab1d37a83 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -518,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 ( @@ -687,7 +687,6 @@ function buildBarChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, - valueFormatter, } = props; const isStacked = displayChart.isStackedChartType(chartType); const isPercent = displayChart.isPercentStackedChartType(chartType); @@ -809,7 +808,6 @@ function buildAreaChart(props: ResolvedProps) { yAxisMin, yAxisMax, showDataLabels, - valueFormatter, } = props; const gradientIdPrefix = props.gradientIdPrefix ?? ''; const gradientIdFor = (index: number) => `${gradientIdPrefix}grad-${index}`; @@ -917,6 +915,8 @@ 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 areaSeries = series.filter((s) => comboSeriesType(s, chartType) === 'area'); @@ -951,7 +951,7 @@ function buildComboChart(props: ResolvedProps) { tickLine={false} axisLine={false} minTickGap={12} - tickFormatter={formatYAxisTick} + tickFormatter={(value: number) => formatValueYAxisTick(value, leftFormat)} domain={leftDomain} allowDataOverflow={yAxisMin !== undefined || yAxisMax !== undefined} label={axisLabel(yAxisLabel, 'left')} @@ -965,7 +965,7 @@ function buildComboChart(props: ResolvedProps) { tickLine={false} axisLine={false} minTickGap={12} - tickFormatter={formatYAxisTick} + tickFormatter={(value: number) => formatValueYAxisTick(value, rightFormat)} domain={rightDomain} allowDataOverflow={yAxisRightMin !== undefined || yAxisRightMax !== undefined} label={axisLabel(yAxisRightLabel, 'right')} From 32e6ec9dd9edc8af62e8038612ca982c4b3a6533 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 24 Jul 2026 12:55:13 +0000 Subject: [PATCH 7/7] Fixed Cubic complaints --- .../tool-calls/display-chart-edit-dialog.tsx | 23 +++++++++++++++---- apps/shared/src/chart-builder.tsx | 16 +++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) 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 f9250245e..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 @@ -78,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; @@ -215,10 +227,13 @@ export function ChartConfigEditDialog({ }; 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 = () => { diff --git a/apps/shared/src/chart-builder.tsx b/apps/shared/src/chart-builder.tsx index ab1d37a83..9b497dc4f 100644 --- a/apps/shared/src/chart-builder.tsx +++ b/apps/shared/src/chart-builder.tsx @@ -919,6 +919,8 @@ function buildComboChart(props: ResolvedProps) { 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(); @@ -947,6 +949,7 @@ function buildComboChart(props: ResolvedProps) { {leftSeries.length > 0 && ( 0 && ( [], + 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,