From 16607bd4d5150e3f32c7a8dfb6431951c74f15fa Mon Sep 17 00:00:00 2001 From: Jonathan Chavez Date: Mon, 9 Nov 2026 17:53:16 +0100 Subject: [PATCH] fix(kpi_grid): show decimal KPI values at their own precision The count-up rounded every value to an integer, so 1.76 showed "2" and 0.4 showed "0". remotion's spring also settles just under 1, so 12.5 stayed at "12" on every frame. The counter now keeps the target's decimal places. Integers and K/M values render exactly as before. Co-Authored-By: Claude Opus 5.5 --- .../src/components/charts/KPIGrid.tsx | 17 +--- .../src/components/charts/format.ts | 23 +++++ .../test_kpi_grid_value_format_contract.py | 84 +++++++++++++++++++ 3 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 remotion-composer/src/components/charts/format.ts create mode 100644 tests/contracts/test_kpi_grid_value_format_contract.py diff --git a/remotion-composer/src/components/charts/KPIGrid.tsx b/remotion-composer/src/components/charts/KPIGrid.tsx index 6c16a7ad67..c6f0ec7d21 100644 --- a/remotion-composer/src/components/charts/KPIGrid.tsx +++ b/remotion-composer/src/components/charts/KPIGrid.tsx @@ -5,6 +5,7 @@ import { useCurrentFrame, useVideoConfig, } from "remotion"; +import { formatKpiValue } from "./format"; interface Metric { label: string; @@ -275,8 +276,7 @@ const KPICardContent: React.FC = ({ config: { damping: 18, stiffness: 60 }, }); - const displayValue = Math.round(metric.value * countProgress); - const formattedValue = formatDisplayValue(displayValue, metric.value); + const formattedValue = formatKpiValue(metric.value, countProgress); // Change indicator animation const changeOpacity = interpolate( @@ -353,16 +353,3 @@ const KPICardContent: React.FC = ({ ); }; - -/** - * Format the animated counter value to match the scale of the target value. - */ -function formatDisplayValue(current: number, target: number): string { - if (target >= 1_000_000) { - return `${(current / 1_000_000).toFixed(1)}M`; - } - if (target >= 1_000) { - return `${(current / 1_000).toFixed(1)}K`; - } - return String(current); -} diff --git a/remotion-composer/src/components/charts/format.ts b/remotion-composer/src/components/charts/format.ts new file mode 100644 index 0000000000..b1690ee22f --- /dev/null +++ b/remotion-composer/src/components/charts/format.ts @@ -0,0 +1,23 @@ +// Number formatting for the chart components. +// +// A KPI keeps the precision it was written with. Rounding the count-up to an integer +// showed 1.76 as "2", and remotion's spring settles at 0.9999999999999998, so 12.5 +// stayed at "12" on every frame. + +/** Decimal places a value is written with; 20 is the limit of toFixed. */ +export const decimalsOf = (n: number): number => { + if (!Number.isFinite(n) || Number.isInteger(n)) return 0; + const [mantissa, exponent] = String(n).split("e"); + const fraction = (mantissa.split(".")[1] ?? "").length; + return Math.min(Math.max(fraction - Number(exponent ?? 0), 0), 20); +}; + +/** KPI counter at `progress` (0..1). Scale and digits come from the target, so they never jump. */ +export function formatKpiValue(target: number, progress: number): string { + const decimals = decimalsOf(target); + const factor = 10 ** decimals; + const current = Math.round(target * progress * factor) / factor; + if (target >= 1_000_000) return `${(current / 1_000_000).toFixed(1)}M`; + if (target >= 1_000) return `${(current / 1_000).toFixed(1)}K`; + return current.toFixed(decimals); +} diff --git a/tests/contracts/test_kpi_grid_value_format_contract.py b/tests/contracts/test_kpi_grid_value_format_contract.py new file mode 100644 index 0000000000..9758383b5e --- /dev/null +++ b/tests/contracts/test_kpi_grid_value_format_contract.py @@ -0,0 +1,84 @@ +"""KPIGrid's count-up shows each value at the precision it was written with.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +FORMAT = Path(__file__).resolve().parents[2] / "remotion-composer/src/components/charts/format.ts" +# Where remotion's spring() settles for KPIGrid's two configs: just under 1. +SPRING_ENDS = (0.9999999999999998, 0.9999999999999999) + + +def _format(calls: list[tuple[float, float]]) -> list[str]: + """Run the production formatter in Node, not a copy of it.""" + node = shutil.which("node") + if not node: + pytest.skip("Node unavailable") + script = ( + f"import {{ formatKpiValue }} from {json.dumps(FORMAT.as_uri())};" + f"console.log(JSON.stringify({json.dumps(calls)}.map(([t, p]) => formatKpiValue(t, p))));" + ) + proc = subprocess.run( + [node, "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", script], + capture_output=True, + text=True, + ) + if proc.returncode != 0 and "bad option" in proc.stderr: + pytest.skip("This Node cannot strip TypeScript types") + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.parametrize( + "target, shown", + [ + (12.5, "12.5"), + (1.76, "1.76"), + (0.72, "0.72"), + (0.4, "0.4"), + (42, "42"), + (14_000, "14.0K"), + (2_500_000, "2.5M"), + ], +) +def test_final_value_keeps_its_precision(target, shown) -> None: + assert _format([(target, end) for end in SPRING_ENDS] + [(target, 1)]) == [shown] * 3 + + +def test_count_up_keeps_the_target_digits_and_scale() -> None: + assert _format([(1.76, 0), (1.76, 0.5), (14_000, 0.5), (2_500_000, 0.5)]) == [ + "0.00", "0.88", "7.0K", "1.3M", + ] + + +def test_rendered_card_shows_the_decimals(tmp_path) -> None: + """12.5, 1.76 and 0.72 used to render exactly like 12, 2 and 1.""" + image = pytest.importorskip("PIL.Image") + chops = pytest.importorskip("PIL.ImageChops") + composer = FORMAT.parents[3] + if not (shutil.which("npx") and (composer / "node_modules").exists()): + pytest.skip("Remotion is not installed") + bundle = tmp_path / "bundle" + subprocess.run( + ["npx", "remotion", "bundle", "src/index.tsx", f"--out-dir={bundle}", "--log=error"], + cwd=composer, check=True, capture_output=True, + ) + + def still(values: list[float]): + cut = { + "id": "k", "in_seconds": 0, "out_seconds": 6, "type": "kpi_grid", "title": "KPI", + "chartData": [{"label": f"M{i}", "value": v} for i, v in enumerate(values)], + } + props = tmp_path / "props.json" + props.write_text(json.dumps({"cuts": [cut], "theme": "clean-professional"})) + png = tmp_path / f"{len(list(tmp_path.glob('*.png')))}.png" + subprocess.run( + ["npx", "remotion", "still", str(bundle), "Explainer", str(png), f"--props={props}", + "--frame=120", "--log=error"], + cwd=composer, check=True, capture_output=True, + ) + return image.open(png).convert("RGB") + + assert chops.difference(still([12.5, 1.76, 0.72]), still([12, 2, 1])).getbbox() is not None