Semaphor
Custom Visuals

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 inputs with a comparison, Semaphor provides a metadata entry for each metric output. Import the types from react-semaphor/analytics-protocol instead of recreating them in your visual.

import type {
  MetricComparisonMetadataEntry,
  MetricComparisonMetadataMap,
} from 'react-semaphor/analytics-protocol';

type MetricComparisonMetadataEntry = {
  type: 'previous_period' | 'previous_year' | 'target';
  displayName?: string;
  displayLabel?: string;
  output: MetricComparisonOutput;
};

// Keyed by output.outputId, never by a display label.
type MetricComparisonMetadataMap = Record<
  string,
  MetricComparisonMetadataEntry
>;

The output descriptor contains the stable result keys, resolved windows, request source (explicit or inherited), and typed availability. Do not infer comparison columns from prefixes such as current_ or comparison_.

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 'previous_year':
      return 'vs Previous Year';
    case 'target':
      return 'vs Target';
    default:
      return '';
  }
}

Showing resolved comparison windows

Comparison windows use half-open bounds: startInclusive is included and endExclusive is not. A custom visual should not present endExclusive as an inclusive end date.

export function KPIWithComparison({ data, cardMetadata }: SingleInputVisualProps) {
  const comparisonMeta = cardMetadata?.kpiConfig?.comparisonMetadata;
  const metadata = Object.values(comparisonMeta ?? {})[0];

  const label = getComparisonLabel(metadata);
  const window = metadata?.output.comparisonWindow;
  const periodLabel = window
    ? `(${window.startInclusive} – before ${window.endExclusive})`
    : '';

  return (
    <div className="text-sm text-muted-foreground">
      {label} {periodLabel}
    </div>
  );
}

Evaluating comparison values

Use evaluateMetricComparison for delta, percent change, ratio, direction, and sentiment. This keeps custom visuals consistent with built-in cards for missing history, zero and negative baselines, and favorable direction.

import { evaluateMetricComparison } from 'react-semaphor/analytics-protocol';

const evaluation = evaluateMetricComparison({
  currentValue,
  comparisonValue,
  polarity:
    cardMetadata?.kpiConfig?.options?.comparisonPolarity ??
    'higher_is_better',
});

Displaying percent change with color

export function PercentChangeIndicator({
  evaluation,
}: {
  evaluation: ReturnType<typeof evaluateMetricComparison>;
}) {
  if (evaluation.percentChange === null) return null;

  const colorClass = evaluation.sentiment === 'neutral'
    ? 'text-muted-foreground'
    : evaluation.sentiment === 'favorable'
      ? 'text-green-600'
      : 'text-red-600';

  const arrow =
    evaluation.direction === 'increase'
      ? '↑'
      : evaluation.direction === 'decrease'
        ? '↓'
        : '→';

  return (
    <span className={`flex items-center gap-1 ${colorClass}`}>
      {arrow}
      {Math.abs(evaluation.percentChange * 100).toFixed(1)}%
    </span>
  );
}

If metadata.output.availability.status is unavailable, keep the current value visible and use the provided reason for an unavailable state. Do not substitute comparison value zero.

See Metric Comparisons for the shared behavior and visual eligibility rules.


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';
import { evaluateMetricComparison } from 'react-semaphor/analytics-protocol';

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;

  // Extract the KPI segments and evaluate through the shared contract.
  const currentValue = Number(
    data.find((row) => row.segment === 'current')?.value,
  );
  const comparisonValue = Number(
    data.find((row) => row.segment === 'comparison')?.value,
  );
  const evaluation = evaluateMetricComparison({
    currentValue: Number.isFinite(currentValue) ? currentValue : null,
    comparisonValue: Number.isFinite(comparisonValue)
      ? comparisonValue
      : null,
    polarity:
      cardMetadata?.kpiConfig?.options?.comparisonPolarity ??
      'higher_is_better',
  });
  const changePercent =
    evaluation.percentChange === null
      ? null
      : evaluation.percentChange * 100;
  const sentimentClass =
    evaluation.sentiment === 'favorable'
      ? 'text-green-600'
      : evaluation.sentiment === 'unfavorable'
        ? 'text-red-600'
        : 'text-muted-foreground';

  // 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 */}
        {evaluation.comparisonValue !== null && changePercent !== null && (
          <div className="flex items-center gap-2 mt-2 text-sm">
            <span className={sentimentClass}>
              {evaluation.direction === 'increase'
                ? '↑'
                : evaluation.direction === 'decrease'
                  ? '↓'
                  : '→'}{' '}
              {Math.abs(changePercent).toFixed(1)}%
            </span>
            <span className="text-muted-foreground">
              vs {formatValue(comparisonValue, comparisonFormat ?? primaryFormat)}
            </span>
          </div>
        )}
      </div>
    </div>
  );
}

Next Steps

On this page