diff --git a/component/src/charts/__tests__/base-chart.test.tsx b/component/src/charts/__tests__/base-chart.test.tsx index b2b37bbe..8514118a 100644 --- a/component/src/charts/__tests__/base-chart.test.tsx +++ b/component/src/charts/__tests__/base-chart.test.tsx @@ -35,6 +35,8 @@ vi.mock("echarts/components", () => ({ DataZoomComponent: vi.fn(), AriaComponent: vi.fn(), RadarComponent: vi.fn(), + MarkLineComponent: vi.fn(), + GraphicComponent: vi.fn(), })); describe("BaseChart", () => { diff --git a/component/src/charts/__tests__/format-number.test.ts b/component/src/charts/__tests__/format-number.test.ts new file mode 100644 index 00000000..6c911156 --- /dev/null +++ b/component/src/charts/__tests__/format-number.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { formatNumber, buildTooltipFormatter } from "../chart-utils"; + +describe("formatNumber", () => { + it("returns plain number by default", () => { + expect(formatNumber(1234)).toBe("1234"); + }); + + it("respects decimalPlaces", () => { + expect(formatNumber(3.14159, { decimalPlaces: 2 })).toBe("3.14"); + }); + + it("pads with zeros when decimalPlaces exceeds precision", () => { + expect(formatNumber(5, { decimalPlaces: 2 })).toBe("5.00"); + }); + + it("applies comma formatting", () => { + expect(formatNumber(1234567, { numberFormat: "comma" })).toBe("1,234,567"); + }); + + it("applies comma formatting with decimalPlaces", () => { + expect(formatNumber(1234567.891, { numberFormat: "comma", decimalPlaces: 2 })).toBe("1,234,567.89"); + }); + + it("applies compact notation", () => { + const result = formatNumber(1500000, { numberFormat: "compact" }); + expect(result).toMatch(/1\.5M/i); + }); + + it("applies compact notation with decimalPlaces", () => { + const result = formatNumber(1234, { numberFormat: "compact", decimalPlaces: 1 }); + expect(result).toMatch(/1\.2K/i); + }); + + it("applies percent format", () => { + expect(formatNumber(75, { numberFormat: "percent" })).toBe("75%"); + }); + + it("applies percent format with decimalPlaces", () => { + expect(formatNumber(75.678, { numberFormat: "percent", decimalPlaces: 1 })).toBe("75.7%"); + }); + + it("adds prefix", () => { + expect(formatNumber(100, { prefix: "$" })).toBe("$100"); + }); + + it("adds suffix", () => { + expect(formatNumber(100, { suffix: " items" })).toBe("100 items"); + }); + + it("combines prefix, suffix, decimalPlaces, and comma", () => { + expect(formatNumber(9876.5, { prefix: "$", suffix: "M", numberFormat: "comma", decimalPlaces: 1 })).toBe("$9,876.5M"); + }); + + it("handles zero", () => { + expect(formatNumber(0, { decimalPlaces: 2 })).toBe("0.00"); + }); + + it("handles negative numbers", () => { + expect(formatNumber(-42.567, { decimalPlaces: 1 })).toBe("-42.6"); + }); + + it("returns string values unchanged", () => { + expect(formatNumber("N/A" as unknown as number)).toBe("N/A"); + }); +}); + +describe("buildTooltipFormatter", () => { + it("returns a function", () => { + const formatter = buildTooltipFormatter({}); + expect(typeof formatter).toBe("function"); + }); + + it("formats a single value with config", () => { + const formatter = buildTooltipFormatter({ decimalPlaces: 1, prefix: "$" }); + // ECharts tooltip params shape for axis trigger + const result = formatter({ + seriesName: "Revenue", + value: 1234.56, + name: "Jan", + marker: '', + }); + expect(result).toContain("$1,234.6"); + expect(result).toContain("Revenue"); + }); + + it("handles array params (axis trigger with multiple series)", () => { + const formatter = buildTooltipFormatter({ decimalPlaces: 0 }); + const result = formatter([ + { seriesName: "A", value: 100.7, name: "Jan", marker: "●" }, + { seriesName: "B", value: 200.3, name: "Jan", marker: "●" }, + ]); + expect(result).toContain("101"); + expect(result).toContain("200"); + }); + + it("omits seriesName label when seriesName is undefined", () => { + const formatter = buildTooltipFormatter({}); + const result = formatter({ value: 42, name: "Jan" }); + expect(result).not.toContain("undefined"); + expect(result).toContain(""); + }); + + it("omits seriesName label when seriesName is empty string", () => { + const formatter = buildTooltipFormatter({}); + const result = formatter({ seriesName: "", value: 42, name: "Jan" }); + expect(result).not.toContain(": "); + }); +}); diff --git a/component/src/charts/__tests__/single-value-chart.test.tsx b/component/src/charts/__tests__/single-value-chart.test.tsx index fe89c9bc..5f11bbf9 100644 --- a/component/src/charts/__tests__/single-value-chart.test.tsx +++ b/component/src/charts/__tests__/single-value-chart.test.tsx @@ -123,6 +123,28 @@ describe("SingleValueChart", () => { expect(container.querySelector("[style]")).not.toBeInTheDocument(); }); + // --- decimalPlaces --- + + it("formats value with decimalPlaces", () => { + render(); + expect(screen.getByText("3.14")).toBeInTheDocument(); + }); + + it("pads with zeros when decimalPlaces exceeds precision", () => { + render(); + expect(screen.getByText("5.00")).toBeInTheDocument(); + }); + + it("combines decimalPlaces with numberFormat comma", () => { + render(); + expect(screen.getByText("1,234,567.9")).toBeInTheDocument(); + }); + + it("ignores decimalPlaces of -1 (automatic)", () => { + render(); + expect(screen.getByText("3.14159")).toBeInTheDocument(); + }); + it("handles invalid JSON in colorThresholds gracefully", () => { expect(() => render(), diff --git a/component/src/charts/base-chart.tsx b/component/src/charts/base-chart.tsx index 17589360..eb91db0b 100644 --- a/component/src/charts/base-chart.tsx +++ b/component/src/charts/base-chart.tsx @@ -9,6 +9,8 @@ import { DataZoomComponent, AriaComponent, RadarComponent, + MarkLineComponent, + GraphicComponent, } from "echarts/components"; import { CanvasRenderer } from "echarts/renderers"; import type { EChartsOption } from "echarts"; @@ -35,6 +37,8 @@ echarts.use([ DataZoomComponent, AriaComponent, RadarComponent, + MarkLineComponent, + GraphicComponent, CanvasRenderer, ]); diff --git a/component/src/charts/chart-utils.ts b/component/src/charts/chart-utils.ts index 7fc9ba74..85727305 100644 --- a/component/src/charts/chart-utils.ts +++ b/component/src/charts/chart-utils.ts @@ -4,6 +4,88 @@ import { resolveThresholdColor } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; import { resolveStylingRuleColor } from "./styling-rule"; +// --------------------------------------------------------------------------- +// Number formatting +// --------------------------------------------------------------------------- + +export type NumberFormat = "plain" | "comma" | "compact" | "percent"; + +export interface NumberFormatConfig { + numberFormat?: NumberFormat; + decimalPlaces?: number; + prefix?: string; + suffix?: string; +} + +/** + * Format a numeric value with optional decimal places, locale formatting, + * compact notation, prefix, and suffix. Non-numeric values pass through as-is. + */ +export function formatNumber(value: number | string, config: NumberFormatConfig = {}): string { + if (typeof value !== "number" || !Number.isFinite(value)) return String(value); + + const { numberFormat = "plain", decimalPlaces, prefix = "", suffix = "" } = config; + + let formatted: string; + + switch (numberFormat) { + case "comma": + formatted = decimalPlaces !== undefined + ? value.toLocaleString("en-US", { minimumFractionDigits: decimalPlaces, maximumFractionDigits: decimalPlaces }) + : value.toLocaleString("en-US"); + break; + case "compact": + formatted = Intl.NumberFormat("en", { + notation: "compact", + ...(decimalPlaces !== undefined ? { minimumFractionDigits: decimalPlaces, maximumFractionDigits: decimalPlaces } : {}), + }).format(value); + break; + case "percent": + formatted = decimalPlaces !== undefined + ? `${value.toFixed(decimalPlaces)}%` + : `${value}%`; + break; + default: // "plain" + formatted = decimalPlaces !== undefined ? value.toFixed(decimalPlaces) : String(value); + break; + } + + return `${prefix}${formatted}${suffix}`; +} + +// --------------------------------------------------------------------------- +// ECharts tooltip formatter +// --------------------------------------------------------------------------- + +interface TooltipParam { + seriesName?: string; + name?: string; + value?: number | string | (number | string)[]; + marker?: string; +} + +/** + * Build an ECharts tooltip formatter function that applies consistent number + * formatting across all chart types. Works with both single and array params + * (item trigger vs axis trigger). + */ +export function buildTooltipFormatter(config: NumberFormatConfig): (params: TooltipParam | TooltipParam[]) => string { + // Tooltip always uses comma format for readability unless explicitly set + const tooltipConfig: NumberFormatConfig = { numberFormat: "comma", ...config }; + + return (params: TooltipParam | TooltipParam[]) => { + const items = Array.isArray(params) ? params : [params]; + const header = items[0]?.name ?? ""; + const lines = items.map((p) => { + const raw = Array.isArray(p.value) ? p.value[1] : p.value; + const val = typeof raw === "number" ? formatNumber(raw, tooltipConfig) : String(raw ?? ""); + const label = p.seriesName ? `${p.seriesName}: ` : ""; + return `${p.marker ?? ""} ${label}${val}`; + }); + return header ? `${header}
${lines.join("
")}` : lines.join("
"); + }; +} + /** Detect whether the document is currently in dark mode. */ export function isDark(): boolean { if (typeof document === "undefined") return false; diff --git a/component/src/charts/single-value-chart.tsx b/component/src/charts/single-value-chart.tsx index 76e67aa6..fbb747b0 100644 --- a/component/src/charts/single-value-chart.tsx +++ b/component/src/charts/single-value-chart.tsx @@ -3,24 +3,12 @@ import { cn } from "@/lib/utils"; import { parseColorThresholds, resolveThresholdColor } from "./color-threshold"; import type { StylingRule } from "./styling-rule"; import { resolveStylingRuleColor } from "./styling-rule"; +import { formatNumber } from "./chart-utils"; +import type { NumberFormat } from "./chart-utils"; export type { ColorThreshold } from "./color-threshold"; export type SingleValueFontSize = "sm" | "md" | "lg" | "xl"; -export type SingleValueNumberFormat = "plain" | "comma" | "compact" | "percent"; - -/** Format a numeric value according to the chosen format. */ -function applyNumberFormat(numericValue: number, fmt: SingleValueNumberFormat): string { - switch (fmt) { - case "comma": - return numericValue.toLocaleString(); - case "compact": - return Intl.NumberFormat("en", { notation: "compact" }).format(numericValue); - case "percent": - return `${numericValue}%`; - default: - return String(numericValue); - } -} +export type SingleValueNumberFormat = NumberFormat; const FONT_SIZE_CLASS: Record = { sm: "text-xl", @@ -46,6 +34,8 @@ export interface SingleValueChartProps { fontSize?: SingleValueFontSize; /** Built-in number formatting applied when value is numeric and format is not provided */ numberFormat?: SingleValueNumberFormat; + /** Fixed decimal places (0-6). Set to -1 or omit for automatic. */ + decimalPlaces?: number; /** @deprecated Use stylingRules instead. JSON string of thresholds */ colorThresholds?: string; /** Rule-based styling rules */ @@ -73,6 +63,7 @@ function SingleValueChart({ format, fontSize = "lg", numberFormat = "plain", + decimalPlaces, colorThresholds, stylingRules, paramValues, @@ -96,10 +87,9 @@ function SingleValueChart({ if (typeof value === "number") { if (format) { displayValue = format(value); - } else if (numberFormat !== "plain") { - displayValue = applyNumberFormat(value, numberFormat); } else { - displayValue = value; + const dp = decimalPlaces !== undefined && decimalPlaces >= 0 ? decimalPlaces : undefined; + displayValue = formatNumber(value, { numberFormat, decimalPlaces: dp }); } } else { displayValue = value; diff --git a/component/src/components/composed/chart-options-schema.ts b/component/src/components/composed/chart-options-schema.ts index 384d21eb..7a32c030 100644 --- a/component/src/components/composed/chart-options-schema.ts +++ b/component/src/components/composed/chart-options-schema.ts @@ -73,6 +73,7 @@ const singleValueOptions: ChartOptionDef[] = [ { key: "title", label: "Title", type: "text", default: "", category: "Display", description: "Custom heading shown above the value. Leave blank to hide." }, { key: "prefix", label: "Prefix", type: "text", default: "", category: "Display", description: "Text prepended to the value (e.g. '$', '€')." }, { key: "suffix", label: "Suffix", type: "text", default: "", category: "Display", description: "Text appended to the value (e.g. '%', ' items')." }, + { key: "decimalPlaces", label: "Decimal Places", type: "number", default: -1, category: "Display", description: "Fixed number of decimal places (0-6). Set to -1 for automatic." }, { key: "fontSize", label: "Font Size", diff --git a/component/vitest.setup.ts b/component/vitest.setup.ts index f8443727..48e07097 100644 --- a/component/vitest.setup.ts +++ b/component/vitest.setup.ts @@ -41,6 +41,8 @@ vi.mock("echarts/components", () => ({ DataZoomComponent: vi.fn(), AriaComponent: vi.fn(), RadarComponent: vi.fn(), + MarkLineComponent: vi.fn(), + GraphicComponent: vi.fn(), })); vi.mock("echarts/renderers", () => ({