Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 2 additions & 15 deletions remotion-composer/src/components/charts/KPIGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { formatKpiValue } from "./format";

interface Metric {
label: string;
Expand Down Expand Up @@ -275,8 +276,7 @@ const KPICardContent: React.FC<KPICardContentProps> = ({
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(
Expand Down Expand Up @@ -353,16 +353,3 @@ const KPICardContent: React.FC<KPICardContentProps> = ({
</>
);
};

/**
* 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);
}
23 changes: 23 additions & 0 deletions remotion-composer/src/components/charts/format.ts
Original file line number Diff line number Diff line change
@@ -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);
}
84 changes: 84 additions & 0 deletions tests/contracts/test_kpi_grid_value_format_contract.py
Original file line number Diff line number Diff line change
@@ -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
Loading