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", () => ({