Skip to main content

Overview

Scales in Cristalyse transform data values to visual positions or dimensions on a chart. They handle both continuous and categorical data, ensuring intuitive and precise data representation.

Linear Scales

Linear scales map continuous data to a range, such as pixels:
CristalyseChart()
  .data(data)
  .mapping(x: 'time', y: 'value')
  .scaleXContinuous(min: 0, max: 100) // X-axis with fixed visual range
  .scaleYContinuous(min: 0, max: 200) // Y-axis with fixed visual range
  .geomLine()
  .build()

Configuration

  • Domain: Data range mapped to chart space.
  • Range: Pixel space where the data is plotted.
  • Limits: Optional min/max parameters to constrain the displayed scale range.
  • Invertible: Values can map back to the data space.

Scale Limits and Data Filtering

Use min and max parameters to control scale behavior:
// Data-driven scaling (default behavior)
CristalyseChart()
  .data(temperatureData) // Values: [15.2, 22.8, 19.5, 16.1]
  .mapping(x: 'hour', y: 'temp')
  .scaleYContinuous() // Scale automatically fits data range
  .geomLine()
  .build()

// Fixed range scaling with limits
CristalyseChart()
  .data(temperatureData)
  .mapping(x: 'hour', y: 'temp')
  .scaleYContinuous(min: 10, max: 30) // Fixed 10-30°C range
  .geomLine()
  .build()
Use limits for consistent ranges across multiple charts and to set explicit meaningful baselines (like 0 for revenue). Without limits, the behavior varies by geometry. Geometries that are value comparisons (Bar Charts, Area Charts) have zero baseline by default. For other geometries, the scale domain is fit to the actual data range (15.2-22.8°C). With limits, the scale domain uses the specified range (10-30°C), including overriding a zero baseline for e.g. Bar Charts and Area Charts. Data points beyond the limits still render proportionally beyond the visual range.

Ordinal Scales

Ordinal scales map categorical data to discrete locations:
CristalyseChart()
  .data(data)
  .mapping(x: 'category', y: 'value')
  .scaleXOrdinal() // X-axis for categories
  .geomBar()
  .build()

Configuration

  • Domain: List of categories.
  • Range: Space for categories to be displayed.
  • BandWidth: Space allocated to each category.

Color Scales

Color scales are used to encode data using color gradients:
CristalyseChart()
  .data(data)
  .mapping(x: 'x', y: 'y', color: 'intensity')
  .geomPoint()
  .theme(
    ChartTheme.defaultTheme().copyWith(
      colorPalette: [Colors.blue, Colors.red], // Gradient
    ),
  )
  .build()

Size Scales

Size scales map data values to sizes, useful for scatter plots:
CristalyseChart()
  .data(data)
  .mapping(x: 'x', y: 'y', size: 'value')
  .geomPoint()
  .build()

Label Formatting

Transform axis labels by passing a callback to the labels parameter on any axis scale that takes any number and returns a string.

Direct NumberFormat Usage

For simple cases, you can use NumberFormat from Dart’s intl package:
import 'package:intl/intl.dart';

// Revenue chart with currency formatting
CristalyseChart()
  .data(salesData)
  .mapping(x: 'quarter', y: 'revenue')
  .scaleYContinuous(labels: NumberFormat.simpleCurrency().format) // $1,234.56
  .build()

// Conversion rate chart with percentage formatting
CristalyseChart()
  .data(conversionData)
  .mapping(x: 'month', y: 'rate')
  .scaleYContinuous(labels: NumberFormat.percentPattern().format) // 23%
  .build()

// User growth chart with compact formatting
CristalyseChart()
  .data(userGrowthData)
  .mapping(x: 'date', y: 'users')
  .scaleYContinuous(labels: NumberFormat.compact().format) // 1.2K, 1.5M
  .build()

Custom Callbacks for Advanced Cases

When you need custom logic beyond NumberFormat, use a factory pattern to create a callback based on that logic.
// Create formatter once based on passed locale, reuse callback
static String Function(num) createCurrencyFormatter({String locale = 'en_US'}) {
  final formatter = NumberFormat.simpleCurrency(locale: locale); // Created once
  return (num value) => formatter.format(value); // Reused callback
}

// Usage
final currencyLabels = createCurrencyFormatter();
// uses default here, but could internationalize

// Revenue chart with currency formatting
CristalyseChart()
  .data(salesData)
  .mapping(x: 'quarter', y: 'revenue')
  .scaleYContinuous(labels: currencyLabels) // pass callback
  .build()

Conditional Formatting (Value-Based Logic)

// Time duration formatting (seconds to human readable)
static String Function(num) createDurationFormatter() {
  return (num seconds) {
    final roundedSeconds = seconds.round(); // Round to nearest second first
    
    if (roundedSeconds >= 3600) {
      final hours = roundedSeconds / 3600;
      if (hours == hours.round()) {
        return '${hours.round()}h';  // Clean: "1h", "2h", "24h"
      }
      return '${hours.toStringAsFixed(1)}h';  // Decimal: "1.5h", "2.3h"
    } else if (roundedSeconds >= 60) {
      final minutes = (roundedSeconds / 60).round();
      return '${minutes}m';  // "1m", "30m", "59m"
    }
    return '${roundedSeconds}s';  // "5s", "30s", "59s"
  };
}

final usageLabels = createDurationFormatter();

CristalyseChart()
  .data(usageData)
  .mapping(x: 'day', y: 'usage')
  .scaleYContinuous(labels: usageLabels) // pass callback
  .build()

Chart-Optimized Formatting (Clean Integers)

// Implement chart-friendly integer/decimal distinction w/ NumberFormat
static String Function(num) createChartCurrencyFormatter() {
  final formatter = NumberFormat.simpleCurrency(locale: 'en_US');
  return (num value) {
    if (value == value.roundToDouble()) {
      return formatter.format(value).replaceAll('.00', ''); // $42
    }
    return formatter.format(value); // $42.50
  };
}

final currencyLabels = createChartCurrencyFormatter();

CristalyseChart()
  .data(salesData)
  .mapping(x: 'quarter', y: 'revenue')
  .scaleYContinuous(labels: currencyLabels)
  .build()

Business Logic Formatting

// Handle negative values differently (P&L charts)
static String Function(num) createProfitLossFormatter() {
  final formatter = NumberFormat.compact(); // Created once
  return (num value) {
    final abs = value.abs();
    final formatted = formatter.format(abs); // Reuse formatter
    return value >= 0 ? formatted : '($formatted)';
  };
}

final currencyLabels = createProfitLossFormatter();

CristalyseChart()
  .data(salesData)
  .mapping(x: 'quarter', y: 'revenue')
  .scaleYContinuous(labels: currencyLabels)
  .build()

// Custom units (basis points for finance - converts decimal rates to bp)
static String Function(num) createBasisPointFormatter() {
  return (num value) => '${(value * 10000).toStringAsFixed(0)}bp';
}

final bpLabels = createBasisPointFormatter();

CristalyseChart()
  .data(yieldData) // Data has decimal rates like 0.0025, 0.0150
  .mapping(x: 'maturity', y: 'yield_rate') // yield_rate is decimal (0.0025 = 25bp)
  .scaleYContinuous(labels: bpLabels) // Converts 0.0025 → "25bp"
  .build()

Scale Customization

Customize scales to fit your data representation needs:
CristalyseChart()
  .data(data)
  .mapping(x: 'day', y: 'hours')
  .scaleXOrdinal()
  .scaleYContinuous(min: 0, max: 24, labels: (v) => '${v}h') // Custom hour labels
  .build()

Best Practices

Scaling Data

  • Choose linear scales for time-series and numerical data.
  • Use ordinal scales for categorical data like labels or segments.
  • Opt for consistent scale units across charts.

Performance

  • Ensure domain and range values are correctly configured.
  • Minimize dynamic scale recalculation for better performance.
  • Use scale inversions in interactive applications for better feedback.

Advanced Usage

Dual Axis Charts

Implement dual axes for complex comparisons:
CristalyseChart()
  .data(dualAxisData)
  .mapping(x: 'month', y: 'revenue')
  .mappingY2('conversion_rate')
  .scaleYContinuous(min: 0, max: 100)
  .scaleY2Continuous(min: 0, max: 1)
  .geomBar(yAxis: YAxis.primary)
  .geomLine(yAxis: YAxis.secondary)
  .build()

Conditional Scales

Dynamically adjust scales based on data:
CristalyseChart()
  .data(dynamicData)
  .mapping(x: 'date', y: 'value')
  .scaleXContinuous(min: startDate, max: endDate)
  .scaleYContinuous(min: minValue, max: maxValue)
  .geomArea()
  .build()

Time-Series Analysis

Vivid depiction of temporal trends and changes over time.

Categorical Comparison

Clear comparison of discrete categories using ordinal scaling.

Revenue vs. Conversion

Dual-axis chart illustrating financial performance metrics.

Dynamic Data Scaling

Interactive scales adapting to real-time data changes.

Next Steps

Technical Details

Review the source code behind scale transformations and extend as needed within the scale.dart file.
I