graphic icon indicating copy to clipboard operation
graphic copied to clipboard

Format x axis labels accordingly visible range, zoom

Open komakur opened this issue 5 months ago • 4 comments

How to format x axis labels based on TimeScale according to the zoom and interval?

Say currently visible range of data is 5 years, so instead of showing labels like 23 Jul, 6 Dec, 22 Apr, 4 Sept, 19 Jan, I want to show 2020, 2021, 2022, 2023, 2024, 2025. Ideally when I zoom in, I need the labels to react to the current zoom, and become more detailed.

PS I think it was already mentioned https://github.com/entronad/graphic/issues/83

komakur avatar Jul 22 '25 12:07 komakur

@entronad I think that if formatter had visible range param List<DateTime> this would be easily achieved

komakur avatar Jul 23 '25 15:07 komakur

Thank you for raising this question. This is indeed a common requirement for time series charts, and it relates to the dynamic ticks issue mentioned in #83.

Currently, the TimeScale formatter is static and doesn't have direct access to the current scale range or zoom level. However, there are a couple of approaches you can use to achieve dynamic label formatting:

Approach 1: Rebuild Chart on Zoom with Dynamic Formatter

You can listen to scale update gestures and rebuild the chart with an appropriate formatter based on the current range:

class _MyChartState extends State<MyChart> {
  DateTime? _minRange;
  DateTime? _maxRange;
  
  String? Function(DateTime) _getFormatter() {
    if (_minRange != null && _maxRange != null) {
      final rangeDays = _maxRange!.difference(_minRange!).inDays;
      
      if (rangeDays > 365 * 2) {
        // Show years for range > 2 years
        return (time) => DateFormat('yyyy').format(time);
      } else if (rangeDays > 60) {
        // Show month-year for range > 2 months
        return (time) => DateFormat('MMM yyyy').format(time);
      } else if (rangeDays > 7) {
        // Show day-month for range > 1 week
        return (time) => DateFormat('d MMM').format(time);
      } else {
        // Show full date for smaller ranges
        return (time) => DateFormat('d MMM, HH:mm').format(time);
      }
    }
    return (time) => DateFormat('MMM d').format(time);
  }

  @override
  Widget build(BuildContext context) {
    return Chart(
      data: yourData,
      variables: {
        'time': Variable(
          accessor: (datum) => datum.time,
          scale: TimeScale(
            formatter: _getFormatter(),
          ),
        ),
        // ... other variables
      },
      selections: {
        'zoom': IntervalSelection(
          on: {GestureType.scaleUpdate},
          dim: Dim.x,
        ),
      },
      // Listen to selection changes to get the zoomed range
      selectionUpdater: {
        'zoom': {GestureType.scaleUpdate: (_, selection, __) {
          if (selection != null && selection.isNotEmpty) {
            setState(() {
              // Extract min/max from selection and update formatter
              // You'll need to convert the selection values back to DateTime
            });
          }
          return selection;
        }},
      },
      // ... rest of chart configuration
    );
  }
}

Approach 2: Use Dynamic Tick Count

Another approach is to adjust the tickCount based on the zoom level, which will automatically reduce label density:

TimeScale(
  tickCount: _getTickCount(), // Adjust based on zoom level
  formatter: (time) {
    // Your formatter logic here
  },
)

Limitations

The current implementation doesn't provide the formatter function with direct access to the scale's min/max range or the current zoom level. The formatter signature is String? Function(DateTime), which only receives the individual tick value.

For a more elegant solution, the library would need to extend the formatter signature to include scale context, similar to how labelMapper in axes receives index and total count information. This would be a valuable enhancement to address both your use case and the dynamic ticks issue in #83.

Note: This solution was generated with AI assistance. Please let me know if this approach works for your use case or if you need further clarification.

entronad avatar Aug 11 '25 07:08 entronad

@entronad Claude cannot help here unfortunately . Could you please share your thoughts? Or maybe any plans on adding this feature?

komakur avatar Sep 02 '25 17:09 komakur

@komakur I had a similar need, though my zoom range is smaller than yours (my data is 0-14d, with users zooming into sub-day ranges). The recommendation from Claude was helpful for me.

Below I've shown how I handle redrawing of time scale ticks.

...
        'timestamp': graphic.Variable(
          accessor: (Map map) => map['timestamp'] as DateTime,
          scale: graphic.TimeScale(
            min: minTimestamp,
            max: maxTimestamp,
            ticks: calculateAxisTicks(minTimestamp, maxTimestamp, 5),
            formatter: (dt) => tickDiff < const Duration(hours: 12)
                ? DateFormat('HH:mm').format(dt)
                : (tickDiff < const Duration(days: 1))
                ? DateFormat('HH').format(dt)
                : DateFormat('MM/dd').format(dt),
            title: 'Timestamp',
          ),
        ),

minTimestamp and maxTimestamp are loaded from bloc; the bloc variables are updated by the gesture event. I assume you've already figured out some handler for changing axis range based on graphic.GestureType.scaleUpdate

formatter changes the axis DateFormat based on the current viewport range. BUT, in the case where the zoom level changes a significant amount, we also need to redraw/recalculate the ticks (by default, graphic won't recalculate the axis ticks. To do that, I created a function to dynamically generate ticks based on view window.

/// roundUp extension rounds to the next delta duration
extension DateTimeExtension on DateTime {
  DateTime roundUp({Duration delta = const Duration(days: 1)}) {
    return DateTime.fromMillisecondsSinceEpoch(
      millisecondsSinceEpoch - millisecondsSinceEpoch % delta.inMilliseconds,
    ).add(delta);
  }

/// Calculates a list of "nice" axis ticks for a given time range.
List<DateTime> calculateAxisTicks({required DateTime startTime, required DateTime endTime, int desiredTickCount = 5}) {
  final visibleDuration = endTime.difference(startTime);
  if (visibleDuration.isNegative || visibleDuration.inMilliseconds == 0) {
    return [];
  }

  // Define "nice" intervals. The chosen interval will determine the tick spacing.
  final List<Duration> niceIntervals;
  if (visibleDuration < const Duration(hours: 4)) {
    // For smaller views, allow ticks on minutes.
    niceIntervals = [const Duration(minutes: 30), const Duration(hours: 1)];
  } else if (visibleDuration < const Duration(days: 2)) {
    // For medium views, ticks are on the hour.
    niceIntervals = [
      const Duration(hours: 1),
      const Duration(hours: 2),
      const Duration(hours: 3),
      const Duration(hours: 4),
      const Duration(hours: 6),
    ];
  } else {
    // For large views, ticks can be spaced by many hours or days.
    niceIntervals = [const Duration(hours: 6), const Duration(hours: 12), const Duration(days: 1)];
  }

  // Calculate the ideal interval to get the desired number of ticks.
  final idealIntervalMs = visibleDuration.inMilliseconds / desiredTickCount;

  // Find the "nice" interval that is closest to our ideal interval.
  final Duration tickInterval = niceIntervals.reduce(
    (a, b) => (a.inMilliseconds - idealIntervalMs).abs() < (b.inMilliseconds - idealIntervalMs).abs() ? a : b,
  );

  // Use the existing `roundUp` extension to find the first tick
  // that aligns with our interval grid (e.g., finds 10:00 from 09:47 for a 1-hour interval).
  final DateTime firstTick = startTime.roundUp(delta: tickInterval);

  final List<DateTime> ticks = [];
  var currentTick = firstTick;

  // Generate ticks until we pass the end of the visible range.
  while (currentTick.isBefore(endTime)) {
    ticks.add(currentTick);
    currentTick = currentTick.add(tickInterval);
  }
  return ticks;
}

bcliang avatar Nov 10 '25 23:11 bcliang