logoSemaphor

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

TypeSourceModifiableExample
SettingsYour plugin manifestYes (user configures in UI)showLegend, accentColor
Card MetadataSemaphor builds from cardNo (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 TypeUse This Format
KPIformatConfig.kpi.primary
KPI comparisonformatConfig.kpi.comparison
Bar, line, area chartsformatConfig.dataLabels or formatConfig.axes.yAxis
Combo chartsformatConfig.axes.yAxis (primary), formatConfig.axes.secondaryYAxis (secondary)
TablesformatConfig.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

FieldPurposeExample
typeFormat style'currency' formats as money
decimalPlacesDecimal precision2 gives 1,234.56
currencyISO currency code'EUR' for euros
localeNumber/currency locale'de-DE' for German formatting
prefixText before value'~' gives ~$1,234
suffixText after value' ARR' gives $1,234 ARR
useSuffixAbbreviate large numberstrue gives 1.2M instead of 1,200,000
multiplyByScale factor100 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.

kpi-utils.ts
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.

components.config.ts
{
  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:

formatted-kpi.tsx
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