androidmpandroidchartlinegraph

In the MPAndroidChart, how do you set the labels on the x-axis (time) with a fixed interval?


enter image description here

I've been trying for a week different things I found on SO, including setting the granularity and the axis min and max values. Look at how neatly distributed the values on the y-axis are, the interval is fixed at 40 and the number always ends with the digit 0. I didn't even have to do anything to make the y-axis like this, it was like this by default. I want the x-axis to be distributed similarly, for example 6 values would appear [11:20 11:30 11:40 11:50 12:00 12:10], and then when you zoom in they may appear like this [11:20 11:25 11:30 11:35 11:40 11:45]

Here is my code:

XAxis xAxis = averageEventRateLineChart.getXAxis();
xAxis.setPosition(XAxis.XAxisPosition.BOTTOM);
xAxis.setDrawGridLines(false);
xAxis.setDrawAxisLine(true);
xAxis.setDrawGridLinesBehindData(false);                                       
xAxis.setAxisLineColor(mContext.getResources().getColor(R.color.graph_line_color));
xAxis.setTextSize(10f);
xAxis.setAxisLineWidth(1f);                           
xAxis.setTextColor(mContext.getResources().getColor(R.color.grey_text_color));
xAxis.setTypeface(ResourcesCompat.getFont(mContext, R.font.open_sans_regular));
xAxis.setValueFormatter(new MyValueFormatter());

YAxis leftAxis = averageEventRateLineChart.getAxisLeft();
leftAxis.setDrawGridLines(false);
leftAxis.setDrawAxisLine(true);
leftAxis.setDrawGridLinesBehindData(false);                     
leftAxis.setAxisLineColor(mContext.getResources().getColor(R.color.graph_line_color));
leftAxis.setAxisLineWidth(1f);
leftAxis.setAxisMinimum(0);
leftAxis.setTextSize(10f);                             
leftAxis.setTextColor(mContext.getResources().getColor(R.color.grey_text_color));
leftAxis.setTypeface(ResourcesCompat.getFont(mContext, R.font.open_sans_regular));

Time formatter class:

public class MyValueFormatter extends ValueFormatter {

    private final SimpleDateFormat formatHours = new SimpleDateFormat("HH:mm", Locale.ENGLISH);
    private final SimpleDateFormat formatDays = new SimpleDateFormat("MMM d", Locale.ENGLISH);
    private final SimpleDateFormat formatMonths = new SimpleDateFormat("MMM yyyy", Locale.ENGLISH);

    public MyValueFormatter(LineChart lineChart) {
    }

    @Override
    public String getFormattedValue(float value) {
        long dataRange = (long) (mLineChart.getHighestVisibleX() - mLineChart.getLowestVisibleX());
        if (dataRange < TimeUnit.HOURS.toMillis(24)) {
            return formatHours.format(new Date((long) value));
        } else if (dataRange < TimeUnit.DAYS.toMillis(7)) {
            return formatDays.format(new Date((long) value));
        } else {
            return formatMonths.format(new Date((long) value));
        }
    }
}

Solution

  • MPAndroidChart has logic already in AxisRenderer to try to put axis labels at "nice" positions. The problem is that this gets messed up if your x-axis unit is something like epoch milliseconds. One thing you can do that helps a lot is to change your plotted data to a different x-scale, like minutes since a more recent time than 1970.

    For example, a chart of time data on October 4, 2023 plotted in epoch ms time looks like this (notice the pixelated-looking lines from converting from long to float, and the non-nice axis values)

    ms time unit

    while plotting the same chart in minutes since midnight on October 4, 2023 looks like this (line is smooth, axis values automatically at nice intervals of 20)

    minutes time unit

    The code to do this involves scaling the x values when you generate the data. However, if you want the units to also look nice when zoomed out to multiple days you have to account for that in the axis renderer. To further customize where the labels go, you can create your own custom XAxisRenderer and override the computeAxisValues method to control exactly where the labels get written.

    Complete Example

    Here is a custom XAxisRenderer for handling charts where the x-axis is time from a Date object and handling nicely formatting and changing the axis as you zoom in. The TimeAxisRenderer provides methods for translating back and forth to chart units, formatting the axis, and placing the labels at nice intervals.

    When the chart is zoomed out, it shows date labels ("Nov 2") but when it is zoomed in, it switches to "HH:mm" labels, except for the first label on a given day (so you still can see what day you are at while zoomed in). When further zoomed in, it shows nice minute intervals (e.g. 4:20, 4:40, 5:00), while keeping the first label as the date.

    example zoom

    The custom renderer code:

    public class TimeAxisRenderer extends XAxisRenderer {
    
        enum DisplayedTimeUnit {
            MINUTES(1f),
            HOURS(60f),
            DAYS(60f*24);
    
            // Number of minutes in this unit
            public final float factor;
    
            private DisplayedTimeUnit(float fac) {
                this.factor = fac;
            }
        }
    
        public static long epoch() {
            // pick your own epoch!
            Calendar calendar = Calendar.getInstance();
            calendar.set(2020, 1, 1, 0, 0, 0);
            return calendar.getTime().getTime();
        }
    
        public static DisplayedTimeUnit getUnit(float min, float max) {
            // chart unit is minutes since custom epoch
            if( max <= min ) {
                return DisplayedTimeUnit.MINUTES; // arbitrary fallback
            }
    
            float range_hours = (max - min) / 60f;
            if( range_hours < 4) {
                // When chart range is less than 4 hours, allow nice
                // increments in minutes (e.g. 4:20, 4:40, 5:00)
                return DisplayedTimeUnit.MINUTES;
            }
            else if( range_hours < 24*4 ) {
                // When chart range is less than 4 days, allow nice
                // increments at even hour spacing
                return DisplayedTimeUnit.HOURS;
            }
            else {
                // When chart range is more than 4 days, show days
                return DisplayedTimeUnit.DAYS;
            }
        }
    
        public static float to_chart_x(Date date) {
            long dt = date.getTime() - epoch();
            return (float)(dt/1000) / 60f;
        }
    
        public static Date from_chart_x(float x) {
            long t = (long)(x*60)*1000 + epoch();
            return new Date(t);
        }
    
        public static class ValueFormatter extends com.github.mikephil.charting.formatter.ValueFormatter {
    
            private final SimpleDateFormat formatMinutes = new SimpleDateFormat("HH:mm", Locale.ENGLISH);
            private final SimpleDateFormat formatDays = new SimpleDateFormat("MMM d", Locale.ENGLISH);
    
            private final LineChart mChart;
            private float lastFormattedValue = 1e9f;
            private float lastHour = 100f;
            private final Calendar c = Calendar.getInstance();
    
            public ValueFormatter(LineChart chart) {
                mChart = chart;
            }
    
            @Override
            public String getFormattedValue(float value) {
                float min = mChart.getLowestVisibleX();
                float max = mChart.getHighestVisibleX();
    
                if( value < min ) {
                    return "";
                }
    
                // Values are formatted in order, lowest to highest.
                // Use this to determine if it's the first value
                boolean isFirstValue = value < lastFormattedValue;
                if( isFirstValue ) {
                    lastHour = 100f;
                }
                lastFormattedValue = value;
    
                Date date = from_chart_x(value);
                c.setTime(date);
                float hour = c.get(Calendar.HOUR_OF_DAY);
                boolean isFirstHourOfDay = hour < lastHour;
                lastHour = hour;
    
                DisplayedTimeUnit unit = getUnit(min, max);
    
                if (unit == DisplayedTimeUnit.MINUTES) {
                    if( isFirstValue ) {
                        return formatDays.format(date);
                    }
                    else {
                        return formatMinutes.format(date);
                    }
                } else if (unit == DisplayedTimeUnit.HOURS) {
                    if( isFirstHourOfDay ) {
                        return formatDays.format(date);
                    }
                    else {
                        return formatMinutes.format(date);
                    }
                } else {
                    return formatDays.format(date);
                }
            }
        }
    
        public TimeAxisRenderer(ViewPortHandler viewPortHandler, XAxis xAxis, Transformer trans) {
            super(viewPortHandler, xAxis, trans);
        }
    
        @Override
        protected void computeAxisValues(float min, float max) {
            computeNiceAxisValues(min, max);
            super.computeSize();
        }
    
        // Custom method for calculating a "nice" interval at the 
        // desired display time unit
        private double calculateInterval(float min, float max, int count) {
    
            double range = max - min;
    
            DisplayedTimeUnit unit = getUnit(min, max);
            float axisScale = unit.factor;
    
            // Find out how much spacing (in y value space) between axis values
            double rawInterval = range / count / axisScale;
            double interval = Utils.roundToNextSignificant(rawInterval);
    
            // If granularity is enabled, then do not allow the interval to go below specified granularity.
            // This is used to avoid repeated values when rounding values for display.
            if (mAxis.isGranularityEnabled()) {
                float gran = mAxis.getGranularity() * axisScale;
                interval = interval < gran ? gran : interval;
            }
    
            // Normalize interval
            double intervalMagnitude = Utils.roundToNextSignificant(Math.pow(10, (int) Math.log10(interval)));
            int intervalSigDigit = (int) (interval / intervalMagnitude);
            if (intervalSigDigit > 5) {
                // Use one order of magnitude higher, to avoid intervals like 0.9 or
                // 90
                interval = Math.floor(10 * intervalMagnitude);
            }
    
            interval *= axisScale;
    
            return interval;
        }
    
        private void computeNiceAxisValues(float xMin, float xMax) {
    
            int labelCount = mAxis.getLabelCount();
            double range = xMax - xMin;
    
            if (labelCount == 0 || range <= 0 || Double.isInfinite(range)) {
                mAxis.mEntries = new float[]{};
                mAxis.mCenteredEntries = new float[]{};
                mAxis.mEntryCount = 0;
                return;
            }
    
            // == Use a time-aware interval calculation
            double interval = calculateInterval(xMin, xMax, labelCount);
    
            // == Below here copied from AxisRenderer::computeAxisValues unchanged ============
            int n = mAxis.isCenterAxisLabelsEnabled() ? 1 : 0;
    
            // force label count
            if (mAxis.isForceLabelsEnabled()) {
    
                interval = (float) range / (float) (labelCount - 1);
                mAxis.mEntryCount = labelCount;
    
                if (mAxis.mEntries.length < labelCount) {
                    // Ensure stops contains at least numStops elements.
                    mAxis.mEntries = new float[labelCount];
                }
    
                float v = xMin;
    
                for (int i = 0; i < labelCount; i++) {
                    mAxis.mEntries[i] = v;
                    v += interval;
                }
    
                n = labelCount;
    
                // no forced count
            } else {
    
                double first = interval == 0.0 ? 0.0 : Math.ceil(xMin / interval) * interval;
                if(mAxis.isCenterAxisLabelsEnabled()) {
                    first -= interval;
                }
    
                double last = interval == 0.0 ? 0.0 : Utils.nextUp(Math.floor(xMax / interval) * interval);
    
                double f;
                int i;
    
                if (interval != 0.0) {
                    for (f = first; f <= last; f += interval) {
                        ++n;
                    }
                }
    
                mAxis.mEntryCount = n;
    
                if (mAxis.mEntries.length < n) {
                    // Ensure stops contains at least numStops elements.
                    mAxis.mEntries = new float[n];
                }
    
                for (f = first, i = 0; i < n; f += interval, ++i) {
    
                    if (f == 0.0) // Fix for negative zero case (Where value == -0.0, and 0.0 == -0.0)
                        f = 0.0;
    
                    mAxis.mEntries[i] = (float) f;
                }
            }
    
            // set decimals
            if (interval < 1) {
                mAxis.mDecimals = (int) Math.ceil(-Math.log10(interval));
            } else {
                mAxis.mDecimals = 0;
            }
    
            if (mAxis.isCenterAxisLabelsEnabled()) {
    
                if (mAxis.mCenteredEntries.length < n) {
                    mAxis.mCenteredEntries = new float[n];
                }
    
                float offset = (float)interval / 2f;
    
                for (int i = 0; i < n; i++) {
                    mAxis.mCenteredEntries[i] = mAxis.mEntries[i] + offset;
                }
            }
        }
    }
    

    and how you use it

    XAxis xAx = chart.getXAxis();
    xAx.setPosition(XAxis.XAxisPosition.BOTTOM);
    xAx.setTextSize(textSize);
    xAx.setValueFormatter(
        new TimeAxisRenderer.ValueFormatter(chart)
    );
    chart.setXAxisRenderer(
        new TimeAxisRenderer(
            chart.getViewPortHandler(),
            xAx,
            chart.getRendererXAxis().getTransformer()
        )
    );
    

    And using the translator when generating the chart data from Date objects

    private void generateData() {
        Calendar calendar = Calendar.getInstance();
    
        for(int d = 2; d < 10; ++d) {
            for (int h = 1; h < 22; ++h) {
                for (int m = 0; m < 60; m += 5) {
                    for (int s = 0; s < 60; s += 22) {
                        calendar.set(2023, 10, d, h, m, s);
                        Date date = calendar.getTime();
    
                        float xi = (d * 60*24 + h * 60.0f + m + s / 60.0f) / 180.0f;
                        float y = 5.0f + (float) Math.sin(xi);
                        data.add(new Entry(TimeAxisRenderer.to_chart_x(date), y));
                    }
                }
            }
        }
    }