Formatting & Metadata
Apply number formatting and access card metadata in custom visuals
Custom visuals receive two types of configuration from Semaphor: settings (user-configurable options you define) and card metadata (read-only context Semaphor builds from the source card). Understanding the difference helps you build visuals that respect user configuration.
Settings vs Card Metadata
| Type | Source | Modifiable | Example |
|---|---|---|---|
| Settings | Your plugin manifest | Yes (user configures in UI) | showLegend, accentColor |
| Card Metadata | Semaphor builds from card | No (read-only) | title, formatConfig, kpiConfig |
Settings are options you define in components.config.ts. Users configure them in Semaphor's visual editor.
Card metadata comes from the source card's configuration. It includes the card's title, description, type, and any number formatting the user configured in the native Semaphor editor.
export function MyKPI({ data, settings, cardMetadata }: SingleInputVisualProps) {
// Settings: defined by you, configured by user in your plugin's UI
const showTrend = settings?.showTrend === 'true';
// Card metadata: read-only context from the source card
const title = cardMetadata?.title ?? 'Metric';
const formatOptions = cardMetadata?.formatConfig?.kpi?.primary;
// ...
}FormatConfig Structure
The formatConfig object contains number formatting rules configured by users in Semaphor's visual editor. Semaphor extracts this from the source card and passes it to your visual.
type CustomVisualFormatConfig = {
// KPI-specific formatting
kpi?: {
primary?: FormatOptions; // Primary metric value
comparison?: FormatOptions; // Comparison/delta value
colorRanges?: ColorRange[]; // Conditional coloring rules
};
// Chart axis formatting
axes?: {
xAxis?: FormatOptions;
yAxis?: FormatOptions;
secondaryYAxis?: FormatOptions; // For combo charts
};
// Chart data label formatting
dataLabels?: FormatOptions;
// Table column formatting
tables?: {
columns?: Array<{
id?: string;
label?: string;
position?: number;
numberFormat?: ColumnNumberFormat | FormatOptions;
}>;
columnMap?: Record<string, { numberFormat?: ColumnNumberFormat | FormatOptions }>;
comparison?: FormatOptions;
defaultNumberFormat?: FormatOptions;
};
// Legacy format (for backward compatibility)
legacy?: {
formatNumber?: LegacyFormatNumber;
numberAxisFormat?: {
decimalPlaces?: number;
suffix?: string;
currency?: string;
locale?: string;
};
};
};Which format to use
| Card Type | Use This Format |
|---|---|
| KPI | formatConfig.kpi.primary |
| KPI comparison | formatConfig.kpi.comparison |
| Bar, line, area charts | formatConfig.dataLabels or formatConfig.axes.yAxis |
| Combo charts | formatConfig.axes.yAxis (primary), formatConfig.axes.secondaryYAxis (secondary) |
| Tables | formatConfig.tables.columns or formatConfig.tables.columnMap |
FormatOptions Type
The FormatOptions type defines how numbers should be formatted.
type FormatOptions = {
type?: 'auto' | 'number' | 'currency' | 'percent' | 'scientific' | 'date';
decimalPlaces?: number;
currency?: string; // ISO currency code: 'USD', 'EUR', 'GBP'
locale?: string; // BCP 47 locale: 'en-US', 'de-DE'
prefix?: string; // Text before value: '~', 'approx '
suffix?: string; // Text after value: ' ARR', ' units'
useSuffix?: boolean; // Abbreviate with K, M, B
negativeInParentheses?: boolean;
multiplyBy?: number; // Multiply before formatting (useful for percentages)
dateFormat?: string; // For date type: 'MMM d, yyyy'
};Field reference
| Field | Purpose | Example |
|---|---|---|
type | Format style | 'currency' formats as money |
decimalPlaces | Decimal precision | 2 gives 1,234.56 |
currency | ISO currency code | 'EUR' for euros |
locale | Number/currency locale | 'de-DE' for German formatting |
prefix | Text before value | '~' gives ~$1,234 |
suffix | Text after value | ' ARR' gives $1,234 ARR |
useSuffix | Abbreviate large numbers | true gives 1.2M instead of 1,200,000 |
multiplyBy | Scale factor | 100 for decimal-to-percent |
Formatting Examples
Here's what different format configurations produce:
Currency
const format: FormatOptions = {
type: 'currency',
currency: 'USD',
decimalPlaces: 2,
locale: 'en-US',
};
// 1234.5 → "$1,234.50"Percent
const format: FormatOptions = {
type: 'percent',
decimalPlaces: 1,
multiplyBy: 100, // If value is 0.452
};
// 0.452 → "45.2%"Compact numbers
const format: FormatOptions = {
type: 'number',
useSuffix: true,
decimalPlaces: 1,
};
// 1234567 → "1.2M"
// 5432 → "5.4K"
// 1234567890 → "1.2B"With prefix and suffix
const format: FormatOptions = {
type: 'currency',
currency: 'USD',
prefix: '~',
suffix: ' ARR',
useSuffix: true,
};
// 1200000 → "~$1.2M ARR"European locale
const format: FormatOptions = {
type: 'currency',
currency: 'EUR',
locale: 'de-DE',
decimalPlaces: 2,
};
// 1234.5 → "1.234,50 €"Using formatKPIValue Helper
The quickstart template includes a formatKPIValue helper that handles common formatting scenarios. You can copy this into your project or use it as reference.
import type { FormatOptions, LegacyFormatNumber } from './config-types';
type KpiFormatConfig = FormatOptions | LegacyFormatNumber | undefined;
export function formatKPIValue(
value: number | undefined,
formatConfig?: KpiFormatConfig,
): string {
if (value === undefined || Number.isNaN(value)) return '';
const config = formatConfig || {};
const locale = config.locale || 'en-US';
const decimalPlaces = typeof config.decimalPlaces === 'number'
? config.decimalPlaces
: 0;
const prefix = config.prefix || '';
const suffix = config.suffix || '';
const currency = config.currency;
const type = config.type;
const multiplyBy = typeof config.multiplyBy === 'number'
? config.multiplyBy
: undefined;
// Apply multiplier (e.g., for percentages stored as decimals)
const numberValue = multiplyBy !== undefined
? value * multiplyBy
: value;
// Determine Intl.NumberFormat style
let style: Intl.NumberFormatOptions['style'] = 'decimal';
if (type === 'currency' || currency) {
style = currency ? 'currency' : 'decimal';
} else if (type === 'percent') {
style = 'percent';
}
const formatted = new Intl.NumberFormat(locale, {
style,
currency: style === 'currency' ? currency : undefined,
minimumFractionDigits: decimalPlaces,
maximumFractionDigits: decimalPlaces,
}).format(numberValue);
return `${prefix}${formatted}${suffix}`;
}Using the helper
import { formatKPIValue } from './kpi-utils';
export function RevenueKPI({ data, cardMetadata }: SingleInputVisualProps) {
const primaryFormat = cardMetadata?.formatConfig?.kpi?.primary;
const value = data[0]?.revenue as number;
return (
<div className="text-4xl font-bold">
{formatKPIValue(value, primaryFormat)}
</div>
);
}Building Your Own Formatter
For more control, build a formatter using the native Intl.NumberFormat API.
function formatValue(
value: number,
format?: FormatOptions,
): string {
if (typeof value !== 'number' || isNaN(value)) return '-';
const options: Intl.NumberFormatOptions = {};
const locale = format?.locale ?? 'en-US';
// Style
if (format?.type === 'currency' && format?.currency) {
options.style = 'currency';
options.currency = format.currency;
} else if (format?.type === 'percent') {
options.style = 'percent';
}
// Decimals
if (format?.decimalPlaces !== undefined) {
options.minimumFractionDigits = format.decimalPlaces;
options.maximumFractionDigits = format.decimalPlaces;
}
// Compact notation (K, M, B)
if (format?.useSuffix) {
options.notation = 'compact';
options.compactDisplay = 'short';
}
// Apply multiplier before formatting
const adjustedValue = format?.multiplyBy
? value * format.multiplyBy
: value;
const formatted = new Intl.NumberFormat(locale, options).format(adjustedValue);
// Add prefix/suffix
const prefix = format?.prefix ?? '';
const suffix = format?.suffix ?? '';
return `${prefix}${formatted}${suffix}`;
}Date Formatting Utilities
KPI comparisons often include date ranges. The quickstart template provides date formatting utilities that handle timezone correctly.
KPI comparison dates are logical calendar dates resolved on the backend. Always format in UTC to prevent browser timezone shifting (e.g., "2024-01-14" displaying as "Jan 13").
formatDateRange
Formats a date range as "Jan 1, 2024 - Jan 31, 2024":
import { parseISO } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
function parseDateAsUtc(dateString: string) {
const trimmed = dateString.trim();
const hasTime = /[T ]\d{2}:\d{2}/.test(trimmed);
const hasZone = /([zZ]|[+-]\d{2}(:?\d{2})?)$/.test(trimmed);
if (!hasTime) {
return parseISO(`${trimmed}T00:00:00Z`);
}
const normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T');
if (!hasZone) {
return parseISO(`${normalized}Z`);
}
return parseISO(normalized);
}
export function formatDateRange(start: string, end: string): string {
try {
const startDate = parseDateAsUtc(start);
const endDate = parseDateAsUtc(end);
return `${formatInTimeZone(startDate, 'UTC', 'MMM d, yyyy')} - ${formatInTimeZone(endDate, 'UTC', 'MMM d, yyyy')}`;
} catch {
return `${start} - ${end}`;
}
}formatDateShort
Formats with year de-duplication when both dates are in the same year:
export function formatDateShort(start: string, end: string): string {
try {
const startDate = parseDateAsUtc(start);
const endDate = parseDateAsUtc(end);
if (startDate.getUTCFullYear() === endDate.getUTCFullYear()) {
// Same year: "Jan 1 - Jan 31, 2024"
return `${formatInTimeZone(startDate, 'UTC', 'MMM d')} - ${formatInTimeZone(endDate, 'UTC', 'MMM d, yyyy')}`;
}
// Different years: full format
return formatDateRange(start, end);
} catch {
return `${start} - ${end}`;
}
}Color Ranges
Color ranges let users define conditional coloring based on value thresholds. Use them to highlight good/bad performance.
type ColorRange = {
start: number; // Minimum value (inclusive)
end: number; // Maximum value (inclusive)
color: string; // CSS color value
};Applying color ranges
function getColorForValue(
value: number,
colorRanges?: ColorRange[],
): string | undefined {
if (!colorRanges || colorRanges.length === 0) return undefined;
for (const range of colorRanges) {
if (value >= range.start && value <= range.end) {
return range.color;
}
}
return undefined;
}Example usage
export function ColoredKPI({ data, cardMetadata }: SingleInputVisualProps) {
const colorRanges = cardMetadata?.formatConfig?.kpi?.colorRanges;
const value = data[0]?.score as number;
const color = getColorForValue(value, colorRanges);
return (
<span
className="text-4xl font-bold"
style={{ color: color ?? 'inherit' }}
>
{value}
</span>
);
}Typical color range configuration
Users might configure ranges like:
const colorRanges = [
{ start: 0, end: 50, color: '#ef4444' }, // Red for 0-50
{ start: 51, end: 80, color: '#f59e0b' }, // Amber for 51-80
{ start: 81, end: 100, color: '#22c55e' }, // Green for 81-100
];Comparison Metadata
For KPI cards with comparisons, Semaphor provides metadata about the comparison type and period.
type ComparisonMetadataEntry = {
type: 'previous_period' | 'same_period_last_year' | 'start_vs_end' | 'target';
displayName?: string;
displayLabel?: string;
currentPeriod?: { start: string; end: string };
comparisonPeriod?: { start: string; end: string };
};
type ComparisonMetadataMap = Record<string, ComparisonMetadataEntry>;Getting comparison labels
export function getComparisonLabel(
metadata?: { type?: string; displayLabel?: string }
): string {
if (!metadata) return '';
// Use custom label if provided
if (metadata.displayLabel) return metadata.displayLabel;
// Fall back to type-based label
switch (metadata.type) {
case 'previous_period':
return 'vs Previous Period';
case 'same_period_last_year':
return 'vs Same Period Last Year';
case 'start_vs_end':
return 'Change Over Period';
case 'target':
return 'vs Target';
default:
return '';
}
}Showing comparison with date range
export function KPIWithComparison({ data, cardMetadata }: SingleInputVisualProps) {
const comparisonMeta = cardMetadata?.kpiConfig?.comparisonMetadata;
const valueKey = Object.keys(comparisonMeta ?? {})[0];
const metadata = valueKey ? comparisonMeta?.[valueKey] : undefined;
const label = getComparisonLabel(metadata);
const periodLabel = metadata?.comparisonPeriod
? `(${formatDateShort(metadata.comparisonPeriod.start, metadata.comparisonPeriod.end)})`
: '';
return (
<div className="text-sm text-muted-foreground">
{label} {periodLabel}
</div>
);
}Calculating Percent Change
For visuals showing comparisons, calculate and display percent change:
export function getPercentChange(
current: number | undefined,
comparison: number | undefined,
isLowerBetter = false,
): {
value: number | null;
isPositive: boolean;
isBetter: boolean;
isNeutral: boolean;
} {
if (current === undefined || comparison === undefined || comparison === 0) {
return { value: null, isPositive: true, isBetter: true, isNeutral: true };
}
const change = ((current - comparison) / comparison) * 100;
const isNeutral = change === 0;
const isPositive = change > 0;
const isBetter = isNeutral ? true : (isLowerBetter ? !isPositive : isPositive);
return { value: change, isPositive, isBetter, isNeutral };
}Displaying percent change with color
export function PercentChangeIndicator({
current,
comparison,
lowerIsBetter = false,
}: {
current: number;
comparison: number;
lowerIsBetter?: boolean;
}) {
const { value, isPositive, isBetter, isNeutral } = getPercentChange(
current,
comparison,
lowerIsBetter,
);
if (value === null) return null;
const colorClass = isNeutral
? 'text-muted-foreground'
: isBetter
? 'text-green-600'
: 'text-red-600';
return (
<span className={`flex items-center gap-1 ${colorClass}`}>
{isPositive ? '↑' : '↓'}
{Math.abs(value).toFixed(1)}%
</span>
);
}Title and Description Auto-Prefill
Settings named title automatically prefill from the card's title. Users can override this in your plugin's settings panel.
{
name: 'Branded KPI',
component: 'BrandedKPI',
componentType: 'chart',
settings: {
// This auto-fills from cardMetadata.title
title: {
title: 'Title',
defaultValue: 'Metric',
ui: 'input',
},
},
}In your component, prefer settings.title (user override) over cardMetadata.title:
export function BrandedKPI({ data, settings, cardMetadata }: SingleInputVisualProps) {
// settings.title takes precedence (user can override)
// Falls back to cardMetadata.title if not set
const title = (settings?.title as string) || cardMetadata?.title || 'Metric';
return <h2>{title}</h2>;
}Complete Example: Formatted KPI Card
Here's a complete KPI component using all the formatting features:
import { SingleInputVisualProps, FormatOptions } from '../../config-types';
function formatValue(value: number, format?: FormatOptions): string {
if (typeof value !== 'number' || isNaN(value)) return '-';
const locale = format?.locale ?? 'en-US';
const options: Intl.NumberFormatOptions = {};
if (format?.type === 'currency' && format?.currency) {
options.style = 'currency';
options.currency = format.currency;
} else if (format?.type === 'percent') {
options.style = 'percent';
}
if (format?.decimalPlaces !== undefined) {
options.minimumFractionDigits = format.decimalPlaces;
options.maximumFractionDigits = format.decimalPlaces;
}
if (format?.useSuffix) {
options.notation = 'compact';
}
const adjustedValue = format?.multiplyBy ? value * format.multiplyBy : value;
const formatted = new Intl.NumberFormat(locale, options).format(adjustedValue);
return `${format?.prefix ?? ''}${formatted}${format?.suffix ?? ''}`;
}
function getColorForValue(value: number, ranges?: Array<{ start: number; end: number; color: string }>): string | undefined {
if (!ranges) return undefined;
for (const range of ranges) {
if (value >= range.start && value <= range.end) return range.color;
}
return undefined;
}
export function FormattedKPI({ data, settings, cardMetadata, theme }: SingleInputVisualProps) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
No data available
</div>
);
}
// Title: settings override, then card metadata, then default
const title = (settings?.title as string) || cardMetadata?.title || 'Metric';
const description = cardMetadata?.description;
// Get formatting configuration
const primaryFormat = cardMetadata?.formatConfig?.kpi?.primary;
const comparisonFormat = cardMetadata?.formatConfig?.kpi?.comparison;
const colorRanges = cardMetadata?.formatConfig?.kpi?.colorRanges;
// KPI options
const lowerIsBetter = cardMetadata?.kpiConfig?.options?.lowerIsBetter ?? false;
// Extract values
const row = data[0];
const columns = Object.keys(row);
const currentValue = Number(row[columns[0]]) || 0;
const comparisonValue = columns[1] ? Number(row[columns[1]]) : undefined;
// Calculate change
const hasComparison = comparisonValue !== undefined && comparisonValue !== 0;
const changePercent = hasComparison
? ((currentValue - comparisonValue!) / comparisonValue!) * 100
: null;
const isPositive = changePercent !== null && changePercent > 0;
const isBetter = changePercent === null || changePercent === 0
? true
: lowerIsBetter ? !isPositive : isPositive;
// Get conditional color
const valueColor = getColorForValue(currentValue, colorRanges) ?? theme?.colors?.[0];
return (
<div className="flex flex-col h-full p-6">
{/* Header */}
<div className="mb-4">
<h2 className="text-lg font-semibold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
{/* Primary value */}
<div className="flex-1 flex flex-col justify-center">
<span
className="text-5xl font-bold"
style={{ color: valueColor }}
>
{formatValue(currentValue, primaryFormat)}
</span>
{/* Comparison */}
{hasComparison && changePercent !== null && (
<div className="flex items-center gap-2 mt-2 text-sm">
<span className={isBetter ? 'text-green-600' : 'text-red-600'}>
{isPositive ? '↑' : '↓'} {Math.abs(changePercent).toFixed(1)}%
</span>
<span className="text-muted-foreground">
vs {formatValue(comparisonValue!, comparisonFormat ?? primaryFormat)}
</span>
</div>
)}
</div>
</div>
);
}Next Steps
- Single-Input Visuals - Build visuals with one data source
- Multi-Input Visuals - Combine multiple data sources
- API Reference - Complete props and types documentation