com.ruesga.timelinechart.TimelineChartView.java Source code

Java tutorial

Introduction

Here is the source code for com.ruesga.timelinechart.TimelineChartView.java

Source

/*
 * Copyright (C) 2015 Jorge Ruesga
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.ruesga.timelinechart;

import android.animation.Animator;
import android.animation.ValueAnimator;
import android.annotation.TargetApi;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.database.ContentObserver;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.support.annotation.RawRes;
import android.support.v4.content.ContextCompat;
import android.support.v4.util.LongSparseArray;
import android.support.v4.util.Pair;
import android.support.v4.view.VelocityTrackerCompat;
import android.support.v4.view.ViewCompat;
import android.text.DynamicLayout;
import android.text.Layout;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.style.AbsoluteSizeSpan;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.Log;
import android.util.SparseArray;
import android.util.TypedValue;
import android.view.MotionEvent;
import android.view.SoundEffectConstants;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.animation.DecelerateInterpolator;
import android.widget.EdgeEffect;
import android.widget.OverScroller;

import com.ruesga.timelinechart.helpers.ArraysHelper;
import com.ruesga.timelinechart.helpers.MaterialPaletteHelper;

import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.TimeZone;

/**
 * A view to represent data over a timeline.<p />
 * <p />
 * This class uses a {@link Cursor} to draw the data. All cursors must follow
 * the following constrains:
 * <ul>
 *     <li>The first field must contains a timestamp, which represent
 *         a time in the graph timeline. This value will be the key to access to
 *         the graph information.</li>
 *     <li>One or more float/double numeric in the rest of the fields of
 *         the cursor. Every one of this fields will represent a serie in the
 *         graph.</li>
 * </ul>
 * <p />
 *
 * User must call {@link #observeData(Cursor)} or {@link #observeData(Cursor, int)}
 * to allow the view observe for data changes in the cursor. Once the view observes
 * the cursor, any change detected in the cursor will be reflected in the graph view.
 * The method {@link #observeData(Cursor, int)} can be used to perform optimizations around
 * the data load process. The follow optimizations can be used.
 * <ul>
 *     <li><b>NO_OPTIMIZATION</b>. Internal data will be destroyed and recreated every
 *         time the cursor changes. Use this value if you know that the cursor can vary its
 *         number of fields (series to display in the graph). This will be the default
 *         optimization by default.</li>
 *     <li><b>NO_DELETES_OPTIMIZATION</b>. Swap data is consistent, so is safe to add and
 *         update information (no deletion will happen), reducing the number of internal
 *         references to create. Use this optimization if you know that the cursor won't
 *         vary its number of fields (series to display in the graph) and existent data
 *         must not be deleted.</li>
 *     <li><b>ONLY_ADDITIONS_OPTIMIZATION</b>. Only new records are added at the end of
 *         the cursor. This is optimized for live graphs where new records are added as
 *         time goes. The data load process won't update, delete or add information
 *         older than the last timestamp saw in the last iteration. Data in cursor expected
 *         to be sorted ascending by timestamp and cursor won't vary its number
 *         of fields (series to display in the graph).</li>
 * </ul>
 * <p />
 * <p />
 *
 * The view supports various graph mode representations that can be established via
 * {@code tlcGraphMode} attribute or {@link #setGraphMode(int)} method.<p />
 *
 * The view features an autogeneration of a material-based color palette based on the
 * background of the {@code tlcGraphBackground} attribute. User can override this
 * color palette with its own one.<p />
 *
 * The view features an auto tick labeled, based on the sort of time. The minimum level
 * displayed label is based in days, displaying hours, minutes or seconds when
 * those amounts are detected.
 * <p />
 * <p />
 *
 * <h4>Attributes:</h4>
 *
 * The view has a set of custom attributes to allow configure of the view behaviour. All
 * this attributes can be set via layout's xml attributes or setters.<p />
 *
 * <ul>
 *     <li><b>tlcGraphBackground</b>: Background color of the graph area. This value
 *         also determines the autogereated palette of colors, if no user palette was
 *         used. {@link #setGraphAreaBackground(int)} can be used at runtime to
 *         set this color.</li><p />
 *
 *     <li><b>tlcFooterBackground</b>: Background color of the footer area.
 *         {@link #setFooterAreaBackground(int)} can be used at runtime to set this
 *         color.</li><p />
 *
 *     <li><b>tlcShowFooter</b>: A boolean value indicating whether to show/hide
 *         the footer of the graph. {@link #setShowFooter(boolean)} can be used at runtime
 *         to show/hide the footer area.</li><p />
 *
 *     <li><b>tlcFooterBarHeight</b>: A float value indicating the height of footer
 *         area of the graph. {@link #setFooterHeight(float)} can be used at runtime
 *         to set the height the footer area.</li><p />
 *
 *     <li><b>tlcBarItemWidth</b>: A float value indicating the width of a bar item
 *         of the graph. {@link #setBarItemWidth(float)} can be used at runtime
 *         to set the width of a bar item.</li><p />
 *
 *     <li><b>tlcBarItemSpace</b>: A float value indicating the space between bar items.
 *         {@link #setBarItemSpace(float)} can be used at runtime
 *         to set the space between bar items.</li><p />
 *
 *     <li><b>tlcGraphMode</b>: The graph representation mode. This attribute accepts
 *         3 modes: tlcBars (series are draw one over the other), tlcBarsStack
 *         (series are draw one on top the other), tlcBarsSideBySide (series are
 *         draw one beside the other). {@link #setGraphMode(int)} can be used at runtime
 *         to set the graph mode (see {@link #GRAPH_MODE_BARS}, {@link #GRAPH_MODE_BARS_STACK}
 *         and {@link #GRAPH_MODE_BARS_SIDE_BY_SIDE}).</li><p />
 *
 *     <li><b>tlcPlaySelectionSoundEffect</b>: A boolean value indicating whether play
 *         sound effects on item selection. {@link #setPlaySelectionSoundEffect(boolean)} can be
 *         used at runtime to set if the view should reproduce sound effects.</li><p />
 *
 *     <li><b>tlcSelectionSoundEffectSource</b>: A reference to a raw resource identifier
 *         that will be play as sound effect on item selection. Define an invalid resource identifier
 *         (value 0) to use the system default sound effect.
 *         {@link #setSelectionSoundEffectSource(int)} can be used at runtime to set the
 *         resource to use as sound effect.</li><p />
 *
 *     <li><b>tlcAnimateCursorTransition</b>: Whether full cursor swap are graphical animated.
 *         {@link #setAnimateCursorTransition(boolean)} can be used at runtime to set
 *
 *     <li><b>tlcFollowCursorPosition</b>: Whether follow real time cursor updates (live update).
 *         Only if scroll is in the last item. {@link #setFollowCursorPosition(boolean)}
 *         can be used at runtime to set the behaviour on cursor updates.</li><p />
 *
 *     <li><b>tlcAlwaysEnsureSelection</b>: Whether move current view to the nearest selection
 *         if, after a user scroll/fling operation, the view is not centered in an item.
 *         If {@code true} move view to the nearest item and selected it.
 *         {@link #setAlwaysEnsureSelection(boolean)} can be used at runtime to set the
 *         behaviour when selection requires to be ensured.</li><p />
 * </ul>
 */
public class TimelineChartView extends View {

    private static final String TAG = "TimelineChartView";

    /**
     * A class that represents a item information.
     */
    public static class Item {
        private Item() {
        }

        /**
         * The timestamp that data belongs to.
         */
        public long mTimestamp;

        /**
         * The values of all the series for the timestamp.
         */
        public double[] mSeries;
    }

    /**
     * A class that represents a item event information.
     */
    public static class ItemEvent {
        private ItemEvent() {
        }

        /**
         * The timestamp associated to the event.
         */
        public long mTimestamp;

        /**
         * The serie associated to the event.
         */
        public int mSerie;
    }

    /**
     * An interface definition to notify click event on items.
     */
    public interface OnClickItemListener {
        /**
         * Called on a click event detected on a area containing an item.
         *
         * @param item the item where the click was detected
         * @param serie the number of the serie on which the click was detected, or -1 if
         *              it happened on a shared area of the item (xe: the tick label area).
         */
        void onClickItem(Item item, int serie);
    }

    /**
     * An interface definition to notify long click event on items.
     */
    public interface OnLongClickItemListener {
        /**
         * Called on a long click event detected on a area containing an item.
         *
         * @param item the item where the long click was detected
         * @param serie the number of the serie on which the long click was detected, or -1 if
         *              it happened on a shared area of the item (xe: the tick label area).
         */
        void onLongClickItem(Item item, int serie);
    }

    /**
     * An interface definition to notify item selection event.
     */
    public interface OnSelectedItemChangedListener {
        /**
         * Called when a item was selected.
         *
         * @param selectedItem information about the selected item
         * @param fromUser whether the event came from a user iteration.
         */
        void onSelectedItemChanged(Item selectedItem, boolean fromUser);

        /**
         * Called when there is no selection.
         */
        void onNothingSelected();
    }

    /**
     * An interface definition to notify changes in the color palette.
     */
    public interface OnColorPaletteChangedListener {
        /**
         * Called when the color palette changed.
         *
         * @param palette the new color palette.
         */
        void onColorPaletteChanged(int[] palette);
    }

    private class LongPressDetector implements Runnable {
        boolean mLongPressTriggered;

        @Override
        public void run() {
            mLongPressTriggered = true;
            Message.obtain(mUiHandler, MSG_ON_LONG_CLICK_ITEM, computeItemEvent()).sendToTarget();
        }
    }

    /** Constant to define the graph mode to normal bars (series are draw one over the other).*/
    public static final int GRAPH_MODE_BARS = 0;
    /** Constant to define the graph mode to stacked bars (series are draw one on top the other).*/
    public static final int GRAPH_MODE_BARS_STACK = 1;
    /** Constant to define the graph mode to beside bars (series are draw one beside the other).*/
    public static final int GRAPH_MODE_BARS_SIDE_BY_SIDE = 2;

    /**
     * Internal data will be destroyed and recreated every time the cursor changes. Use this
     * value if you know that the cursor can vary its number of fields (series to display in
     * the graph).
     */
    public static final int NO_OPTIMIZATION = 0;
    /**
     * Swap data is consistent, so is safe to add and update information (no deletion will happen),
     * reducing the number of internal references to create. Use this optimization if
     * you know that the cursor won't vary its number of fields (series to display in
     * the graph) and existent data must not be deleted.
     */
    public static final int NO_DELETES_OPTIMIZATION = 1;
    /**
     * Only new records are added at the end of the cursor. This is optimized for live
     * graphs where new records are added as time goes. The data load process won't update,
     * delete or add information older than the last timestamp saw in the last iteration.
     * Data in cursor expected to be sorted ascending by timestamp and cursor won't vary
     * its number of fields (series to display in the graph).
     */
    public static final int ONLY_ADDITIONS_OPTIMIZATION = 2;

    // Sort of available formats for tick labels
    private static final int TICK_LABEL_SECONDS_FORMAT = 0;
    private static final int TICK_LABEL_HOUR_MINUTES_FORMAT = 1;
    private static final int TICK_LABEL_DAY_FORMAT = 2;

    private static final float MAX_ZOOM_OUT = 4.0f;
    private static final float MIN_ZOOM_OUT = 1.0f;

    private static final float SOUND_EFFECT_VOLUME = 0.3f;
    private static final int SYSTEM_SOUND_EFFECT = 0;

    private static final int TAP_TIMEOUT = 50;

    private Cursor mCursor;
    private int mOptimizationFlag = NO_OPTIMIZATION;
    private int mSeries;
    private LongSparseArray<Pair<double[], int[]>> mData = new LongSparseArray<>();
    private double mMaxValue;
    private final Item mItem = new Item();
    private final RectF mSerieRect = new RectF();

    private int mSeriesSwap;
    private LongSparseArray<Pair<double[], int[]>> mDataSwap = new LongSparseArray<>();
    private double mMaxValueSwap;
    private float mMaxOffsetSwap;
    private boolean mTickHasDayFormatSwap;

    private final RectF mViewArea = new RectF();
    private final RectF mGraphArea = new RectF();
    private final RectF mFooterArea = new RectF();
    private float mDefFooterBarHeight;
    private float mFooterBarHeight;
    private boolean mShowFooter;
    private int mGraphMode;
    private boolean mPlaySelectionSoundEffect;
    private int mSelectionSoundEffectSource;
    private boolean mAnimateCursorTransition;
    private boolean mFollowCursorPosition;
    private boolean mAlwaysEnsureSelection;

    private EdgeEffect mEdgeEffectLeft;
    private EdgeEffect mEdgeEffectRight;
    private boolean mEdgeEffectLeftActive;
    private boolean mEdgeEffectRightActive;

    private int[] mUserPalette;
    private int[] mCurrentPalette;

    private float mBarItemWidth;
    private float mBarItemSpace;
    private float mBarWidth;
    private float mCurrentPositionIndicatorWidth;
    private float mCurrentPositionIndicatorHeight;

    private Paint mGraphAreaBgPaint;
    private Paint mFooterAreaBgPaint;

    private Paint[] mSeriesBgPaint;
    private Paint[] mHighlightSeriesBgPaint;
    private TextPaint mTickLabelFgPaint;

    private final Path mCurrentPositionPath = new Path();

    private long mCurrentTimestamp = -1;
    private long mLastTimestamp = -1;
    private float mCurrentOffset = 0.f;
    private float mLastOffset = -1.f;
    private float mMaxOffset = 0.f;
    private float mInitialTouchOffset = 0.f;
    private float mInitialTouchX = 0.f;
    private float mInitialTouchY = 0.f;
    private float mLastX = 0.f;
    private float mLastY = 0.f;
    private float mCurrentZoom = 1.f;

    private int mMaxBarItemsInScreen = 0;
    private final int[] mItemsOnScreen = new int[2];

    private SimpleDateFormat[] mTickFormatter;
    private Date mTickDate;
    private SparseArray<DynamicSpannableString>[] mTickTextSpannables;
    private SparseArray<DynamicLayout>[] mTickTextLayouts;
    private Calendar mTickCalendar;
    private boolean mTickHasDayFormat;
    private float mTickLabelMinHeight;

    private String[] mTickLabels;
    private String[] mTickFormats;

    private float mTextSizeFactor;
    private float mSize8;
    private float mSize12;
    private float mSize20;

    private VelocityTracker mVelocityTracker;
    private OverScroller mScroller;
    private final LongPressDetector mLongPressDetector = new LongPressDetector();
    private long mLongPressTimeout;
    private float mTouchSlop;
    private float mMaxFlingVelocity;
    private long mLastPressTimestamp;

    private static final int STATE_IDLE = 0;
    private static final int STATE_INITIALIZE = 1;
    private static final int STATE_MOVING = 2;
    private static final int STATE_FLINGING = 3;
    private static final int STATE_SCROLLING = 4;
    private static final int STATE_ZOOMING = 5;
    private int mState = STATE_IDLE;

    private static final int MSG_ON_SELECTION_ITEM_CHANGED = 1;
    private static final int MSG_ON_CLICK_ITEM = 2;
    private static final int MSG_ON_LONG_CLICK_ITEM = 3;
    private static final int MSG_COMPUTE_DATA = 4;
    private static final int MSG_UPDATE_COMPUTED_DATA = 5;

    private Handler mUiHandler;
    private Handler mBackgroundHandler;
    private HandlerThread mBackgroundHandlerThread;

    private boolean mIsDataComputed;

    private final Handler.Callback mMessenger = new Handler.Callback() {
        @Override
        public boolean handleMessage(Message msg) {
            switch (msg.what) {
            // Ui thread
            case MSG_ON_SELECTION_ITEM_CHANGED:
                notifyOnSelectionItemChanged((boolean) msg.obj);
                return true;
            case MSG_ON_CLICK_ITEM:
                notifyGenericClickEvent((ItemEvent) msg.obj);
                return true;
            case MSG_ON_LONG_CLICK_ITEM:
                notifyGenericLongClickEvent((ItemEvent) msg.obj);
                return true;
            case MSG_UPDATE_COMPUTED_DATA:
                // Generate bar items palette based on background color
                setupSeriesBackground(mGraphAreaBgPaint.getColor());
                mIsDataComputed = true;

                // Redraw the data and notify the changes
                notifyOnSelectionItemChanged(false);

                // Animate?
                if (msg.arg1 == 1) {
                    mInZoomOut = false;
                    mZoomAnimator.setFloatValues(MAX_ZOOM_OUT, MIN_ZOOM_OUT);
                    mZoomAnimator.start();
                }

                // Update the graph view
                ViewCompat.postInvalidateOnAnimation(TimelineChartView.this);
                return true;

            // Non-Ui thread
            case MSG_COMPUTE_DATA:
                performComputeData(msg.arg1 == 1, msg.arg2 == 1);
                return true;
            }
            return false;
        }
    };

    // Cursor observers
    private final DataSetObserver mDataSetObserver = new DataSetObserver() {
        @Override
        public void onChanged() {
            // Avoid this operation if ContentObserver is reloading the cursor
            if (mObserverStatus != 2) {
                mObserverStatus = 1;
                reloadCursorData(false);
                mObserverStatus = 0;
            }
        }

        @Override
        public void onInvalidated() {
            // Avoid this operation if ContentObserver is reloading the cursor
            if (mObserverStatus != 2) {
                mObserverStatus = 1;
                clear();
                mObserverStatus = 0;
            }
        }
    };

    private class CursorContentObserver extends ContentObserver {
        public CursorContentObserver(Handler handler) {
            super(handler);
        }

        @Override
        @SuppressWarnings("deprecation")
        public void onChange(boolean selfChange) {
            super.onChange(selfChange);
            // This required database/disk access so, it must be execute in
            // a background handler
            // Avoid this operation if DataSetObserver is reloading the cursor
            if (mObserverStatus != 1) {
                mObserverStatus = 2;
                synchronized (mCursorLock) {
                    mCursor.requery();
                }
                reloadCursorData(false);
                mObserverStatus = 0;
            }
        }

        @Override
        public void onChange(boolean selfChange, Uri uri) {
            onChange(selfChange);
        }
    }

    private ContentObserver mContentObserver;
    private int mObserverStatus = 0;

    private AudioManager mAudioManager;
    private MediaPlayer mSoundEffectMP;

    private OnClickItemListener mOnClickItemCallback;
    private OnLongClickItemListener mOnLongClickItemCallback;
    private Set<OnSelectedItemChangedListener> mOnSelectedItemChangedCallbacks = Collections
            .synchronizedSet(new HashSet<OnSelectedItemChangedListener>());
    private Set<OnColorPaletteChangedListener> mOnColorPaletteChangedCallbacks = Collections
            .synchronizedSet(new HashSet<OnColorPaletteChangedListener>());

    private boolean mInZoomOut = false;
    private ValueAnimator mZoomAnimator;

    private final Object mLock = new Object();
    private final Object mCursorLock = new Object();

    /** {@inheritDoc} */
    public TimelineChartView(Context ctx) {
        this(ctx, null, 0);
    }

    /** {@inheritDoc} */
    public TimelineChartView(Context ctx, AttributeSet attrs) {
        this(ctx, attrs, 0);
    }

    /** {@inheritDoc} */
    public TimelineChartView(Context ctx, AttributeSet attrs, int defStyleAttr) {
        super(ctx, attrs, defStyleAttr);
        init(ctx, attrs, defStyleAttr, 0);
    }

    /** {@inheritDoc} */
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    public TimelineChartView(Context ctx, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(ctx, attrs, defStyleAttr, defStyleRes);
        init(ctx, attrs, defStyleAttr, defStyleRes);
    }

    private void init(Context ctx, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        mUiHandler = new Handler(Looper.getMainLooper(), mMessenger);
        if (!isInEditMode()) {
            mAudioManager = (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE);
        }

        final Resources res = getResources();
        final Resources.Theme theme = ctx.getTheme();

        mTickFormats = getResources().getStringArray(R.array.tlcDefTickLabelFormats);
        mTickLabels = getResources().getStringArray(R.array.tlcDefTickLabelValues);

        final DisplayMetrics dp = getResources().getDisplayMetrics();
        mSize8 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 8, dp);
        mSize12 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 12, dp);
        mSize20 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 20, dp);

        final ViewConfiguration vc = ViewConfiguration.get(ctx);
        mLongPressTimeout = ViewConfiguration.getLongPressTimeout();
        mTouchSlop = vc.getScaledTouchSlop() / 2;
        mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
        mScroller = new OverScroller(ctx);

        int graphBgColor = ContextCompat.getColor(ctx, R.color.tlcDefGraphBackgroundColor);
        int footerBgColor = ContextCompat.getColor(ctx, R.color.tlcDefFooterBackgroundColor);

        mDefFooterBarHeight = mFooterBarHeight = res.getDimension(R.dimen.tlcDefFooterBarHeight);
        mShowFooter = res.getBoolean(R.bool.tlcDefShowFooter);
        mGraphMode = res.getInteger(R.integer.tlcDefGraphMode);
        mPlaySelectionSoundEffect = res.getBoolean(R.bool.tlcDefPlaySelectionSoundEffect);
        mSelectionSoundEffectSource = res.getInteger(R.integer.tlcDefSelectionSoundEffectSource);
        mAnimateCursorTransition = res.getBoolean(R.bool.tlcDefAnimateCursorTransition);
        mFollowCursorPosition = res.getBoolean(R.bool.tlcDefFollowCursorPosition);
        mAlwaysEnsureSelection = res.getBoolean(R.bool.tlcDefAlwaysEnsureSelection);

        mGraphAreaBgPaint = new Paint();
        mGraphAreaBgPaint.setColor(graphBgColor);
        mFooterAreaBgPaint = new Paint();
        mFooterAreaBgPaint.setColor(footerBgColor);
        mTickLabelFgPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.LINEAR_TEXT_FLAG);
        mTickLabelFgPaint.setFakeBoldText(true);
        mTickLabelFgPaint.setColor(MaterialPaletteHelper.isDarkColor(footerBgColor) ? Color.LTGRAY : Color.DKGRAY);

        mBarItemWidth = res.getDimension(R.dimen.tlcDefBarItemWidth);
        mBarItemSpace = res.getDimension(R.dimen.tlcDefBarItemSpace);

        TypedArray a = theme.obtainStyledAttributes(attrs, R.styleable.tlcTimelineChartView, defStyleAttr,
                defStyleRes);
        try {
            int n = a.getIndexCount();
            for (int i = 0; i < n; i++) {
                int attr = a.getIndex(i);
                if (attr == R.styleable.tlcTimelineChartView_tlcGraphBackground) {
                    graphBgColor = a.getColor(attr, graphBgColor);
                    mGraphAreaBgPaint.setColor(graphBgColor);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcShowFooter) {
                    mShowFooter = a.getBoolean(attr, mShowFooter);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcFooterBackground) {
                    footerBgColor = a.getColor(attr, footerBgColor);
                    mFooterAreaBgPaint.setColor(footerBgColor);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcFooterBarHeight) {
                    mFooterBarHeight = a.getDimension(attr, mFooterBarHeight);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcGraphMode) {
                    mGraphMode = a.getInt(attr, mGraphMode);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcAnimateCursorTransition) {
                    mAnimateCursorTransition = a.getBoolean(attr, mAnimateCursorTransition);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcFollowCursorPosition) {
                    mFollowCursorPosition = a.getBoolean(attr, mFollowCursorPosition);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcAlwaysEnsureSelection) {
                    mAlwaysEnsureSelection = a.getBoolean(attr, mAlwaysEnsureSelection);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcBarItemWidth) {
                    mBarItemWidth = a.getDimension(attr, mBarItemWidth);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcBarItemSpace) {
                    mBarItemSpace = a.getDimension(attr, mBarItemSpace);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcPlaySelectionSoundEffect) {
                    mPlaySelectionSoundEffect = a.getBoolean(attr, mPlaySelectionSoundEffect);
                } else if (attr == R.styleable.tlcTimelineChartView_tlcSelectionSoundEffectSource) {
                    mSelectionSoundEffectSource = a.getInt(attr, mSelectionSoundEffectSource);
                }
            }
        } finally {
            a.recycle();
        }

        // SurfaceView requires a background
        if (getBackground() == null) {
            setBackgroundColor(ContextCompat.getColor(ctx, android.R.color.transparent));
        }

        // Minimize the impact of create dynamic layouts by assume that in most case
        // we will have a day formatter
        mTickHasDayFormat = true;

        // Initialize stuff
        setupBackgroundHandler();
        setupTickLabels();
        if (ViewCompat.getOverScrollMode(this) != ViewCompat.OVER_SCROLL_NEVER) {
            setupEdgeEffects();
        }
        setupAnimators();
        setupSoundEffects();

        // Initialize the drawing refs (this will be update when we have
        // the real size of the canvas)
        computeBoundAreas();

        // Create a fake data for the edit mode
        if (isInEditMode()) {
            setupViewInEditMode();
        }
    }

    /** {@inheritDoc} */
    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        setupBackgroundHandler();
        setupSoundEffects();
        mContentObserver = new CursorContentObserver(mBackgroundHandler);
        synchronized (mCursorLock) {
            if (mCursor != null) {
                mCursor.registerContentObserver(mContentObserver);
            }
        }
    }

    /** {@inheritDoc} */
    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();

        // Destroy background thread
        mBackgroundHandlerThread.quit();
        mBackgroundHandler = null;
        mBackgroundHandlerThread = null;

        // Destroy cursor
        releaseCursor();

        // Destroy internal tracking variables
        clear();
        releaseSoundEffects();
        if (mVelocityTracker != null) {
            mVelocityTracker.recycle();
            mVelocityTracker = null;
        }
    }

    /** {@inheritDoc} */
    @Override
    protected void onConfigurationChanged(Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        mTickCalendar = Calendar.getInstance(TimeZone.getDefault(), Locale.getDefault());
    }

    /**
     * Set the color of the background of the graph area.
     */
    public void setGraphAreaBackground(int color) {
        if (mGraphAreaBgPaint.getColor() != color) {
            mGraphAreaBgPaint.setColor(color);
            setupSeriesBackground(color);
            setupEdgeEffectColor();
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
     * Whether the footer is shown.
     */
    public boolean isShowFooter() {
        return mShowFooter;
    }

    /**
     * Whether the footer will be shown.
     */
    public void setShowFooter(boolean show) {
        if (mShowFooter != show) {
            mShowFooter = show;
            computeBoundAreas();
            requestLayout();
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
     * Set the color of the background of the footer area.
     */
    public void setFooterAreaBackground(int color) {
        if (mFooterAreaBgPaint.getColor() != color) {
            mFooterAreaBgPaint.setColor(color);
            mTickLabelFgPaint.setColor(MaterialPaletteHelper.isDarkColor(color) ? Color.LTGRAY : Color.DKGRAY);
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
     * Returns the height in pixels of the footer area.
     */
    public float getFooterBarHeight() {
        return mFooterBarHeight;
    }

    /**
     * Sets the height in pixels of the footer area.
     */
    public void setFooterHeight(float height) {
        if (mFooterBarHeight != height) {
            mFooterBarHeight = height;
            computeBoundAreas();
            setupTickLabels();
            requestLayout();
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
     * Returns the space in pixels between bar items.
     */
    public float getBarItemSpace() {
        return mBarItemSpace;
    }

    /**
     * Sets the space in pixels between bar items.
     */
    public void setBarItemSpace(float barItemSpace) {
        if (mBarItemSpace != barItemSpace) {
            mBarItemSpace = barItemSpace;
            computeMaxBarItemsInScreen();
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
     * Returns the width in pixels of a bar item.
     */
    public float getBarItemWidth() {
        return mBarItemWidth;
    }

    /**
     * Sets the width in pixels of a bar item.
     */
    public void setBarItemWidth(float barItemWidth) {
        if (mBarItemWidth != barItemWidth) {
            mBarItemWidth = barItemWidth;
            computeBoundAreas();
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
     * Returns the graph mode representation.
     * @see {@link #GRAPH_MODE_BARS}
     * @see {@link #GRAPH_MODE_BARS_STACK}
     * @see {@link #GRAPH_MODE_BARS_SIDE_BY_SIDE}
     */
    public int getGraphMode() {
        return mGraphMode;
    }

    /**
     * Sets the graph mode representation.
     * @see {@link #GRAPH_MODE_BARS}
     * @see {@link #GRAPH_MODE_BARS_STACK}
     * @see {@link #GRAPH_MODE_BARS_SIDE_BY_SIDE}
     */
    public void setGraphMode(int mode) {
        if (mode != mGraphMode) {
            mGraphMode = mode;
            Message.obtain(mBackgroundHandler, MSG_COMPUTE_DATA).sendToTarget();
        }
    }

    /**
     * Whether cursor swaps are animated.
     */
    public boolean isAnimateCursorTransition() {
        return mAnimateCursorTransition;
    }

    /**
     * Whether cursor swaps should be animated.
     */
    public void setAnimateCursorTransition(boolean animateCursorTransition) {
        mAnimateCursorTransition = animateCursorTransition;
    }

    /**
     * Whether graph is following real time cursor updates (live update).
     */
    public boolean isFollowCursorPosition() {
        return mFollowCursorPosition;
    }

    /**
     * Whether follow real time cursor updates (live update). Only if scroll is in the last item.
     */
    public void setFollowCursorPosition(boolean follow) {
        mFollowCursorPosition = follow;
    }

    /**
     * Whether view will move to the nearest selection if, after a user
     * scroll/fling operation, the view is not centered in an item.
     */
    public boolean isAlwaysEnsureSelection() {
        return mAlwaysEnsureSelection;
    }

    /**
     * Whether move current view to the nearest selection if, after a user
     * scroll/fling operation, the view is not centered in an item. If {@code true} move
     * view to the nearest item and selected it.
     */
    public void setAlwaysEnsureSelection(boolean ensureSelection) {
        mAlwaysEnsureSelection = ensureSelection;
    }

    /**
     * Returns the user color palette.
     */
    public int[] getUserPalette() {
        return mUserPalette;
    }

    /**
     * Sets the user color palette.
     */
    public void setUserPalette(int[] userPalette) {
        if (!Arrays.equals(mUserPalette, userPalette)) {
            mUserPalette = userPalette;
            setupSeriesBackground(mGraphAreaBgPaint.getColor());
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
     * Returns the current color palette (it could be a combination of the user
     * palette and the generated one).
     * @see {@link #setUserPalette(int[])}
     */
    public int[] getCurrentPalette() {
        return mCurrentPalette;
    }

    /**
     * Whether play a sound effect on item selection.
     */
    public boolean isPlaySelectionSoundEffect() {
        return mPlaySelectionSoundEffect;
    }

    /**
     * Whether should play a sound effect on item selection.
     */
    public void setPlaySelectionSoundEffect(boolean value) {
        mPlaySelectionSoundEffect = value;
        setupSoundEffects();
    }

    /**
     * Returns the raw resource identifier used to play a sound effect
     * on item selection, or {@code 0} if default system effect is used.
     */
    public int getSelectionSoundEffectSource() {
        return mSelectionSoundEffectSource;
    }

    /**
     * Sets the raw resource identifier to use to play a sound effect
     * on item selection. Use {@code 0} to use the default system effect.
     */
    public void setSelectionSoundEffectSource(@RawRes int source) {
        this.mSelectionSoundEffectSource = source;
        setupSoundEffects();
    }

    /**
     * Returns the callback which listen for click events on items.
     * @see {@link com.ruesga.timelinechart.TimelineChartView.OnClickItemListener}
     */
    public OnClickItemListener getOnClickItemListener() {
        return mOnClickItemCallback;
    }

    /**
     * Returns the callback which will listen for click events on items.
     * @see {@link com.ruesga.timelinechart.TimelineChartView.OnClickItemListener}
     */
    public void setOnClickItemListener(OnClickItemListener cb) {
        mOnClickItemCallback = cb;
    }

    /**
     * Returns the callback which listen for long click events on items.
     * @see {@link com.ruesga.timelinechart.TimelineChartView.OnLongClickItemListener}
     */
    public OnLongClickItemListener getOnLongClickItemListener() {
        return mOnLongClickItemCallback;
    }

    /**
     * Sets the callback which will listen for click events on items.
     * @see {@link com.ruesga.timelinechart.TimelineChartView.OnLongClickItemListener}
     */
    public void setOnLongClickItemListener(OnLongClickItemListener cb) {
        mOnLongClickItemCallback = cb;
    }

    /**
     * Register the callback as a listener to start receiving item selection changes.
     * @see {@link com.ruesga.timelinechart.TimelineChartView.OnSelectedItemChangedListener}
     */
    public void addOnSelectedItemChangedListener(OnSelectedItemChangedListener cb) {
        mOnSelectedItemChangedCallbacks.add(cb);
    }

    /**
     * Unregister the callback as a listener to stop receiving item selection changes.
     * @see {@link com.ruesga.timelinechart.TimelineChartView.OnSelectedItemChangedListener}
     */
    public void removeOnSelectedItemChangedListener(OnSelectedItemChangedListener cb) {
        mOnSelectedItemChangedCallbacks.remove(cb);
    }

    /**
     * Register the callback as a listener to start receiving color palette changes.
     * @see {@link com.ruesga.timelinechart.TimelineChartView.OnColorPaletteChangedListener}
     */
    public void addOnColorPaletteChangedListener(OnColorPaletteChangedListener cb) {
        mOnColorPaletteChangedCallbacks.add(cb);
    }

    /**
     * Unregister the callback as a listener to stop receiving color palette changes.
     * @see {@link com.ruesga.timelinechart.TimelineChartView.OnColorPaletteChangedListener}
     */
    public void removeOnColorPaletteChangedListener(OnColorPaletteChangedListener cb) {
        mOnColorPaletteChangedCallbacks.remove(cb);
    }

    /**
     * Registers the cursor and start observing changes on it. This method won't perform
     * any sort of optimization in the data processing.
     * @see {@link #observeData(Cursor, int)}
     * @see {@link #NO_OPTIMIZATION}
     */
    public void observeData(Cursor c) {
        observeData(c, NO_OPTIMIZATION);
    }

    /**
     * Registers the cursor and start observing changes on it.
     *
     * The cursor <i>MUST</i> follow the next constrains:
     * <ul>
     *     <li>The first field must contains a timestamp, which represent
     *         a time in the graph timeline. This value will be the key to access to
     *         the graph information.</li>
     *     <li>One or more float/double numeric in the rest of the fields of
     *         the cursor. Every one of this fields will represent a serie in the
     *         graph.</li>
     * </ul>
     *
     * @param c the cursor to observe.
     * @param flag An optimization flag. See optimization constants for a description of
     *             what every optimizion does.
     * @see {@link #NO_OPTIMIZATION}
     * @see {@link #NO_DELETES_OPTIMIZATION}
     * @see {@link #ONLY_ADDITIONS_OPTIMIZATION}
     */
    public void observeData(Cursor c, int flag) {
        synchronized (mCursorLock) {
            checkCursorIntegrity(c);

            // Close previous cursor
            final boolean animate = mCursor != null;
            releaseCursor();

            // Ensure we have a valid handler (if for some reason view wasn't attached yet)
            setupBackgroundHandler();

            // Save the cursor reference and listen for changes
            mCursor = c;
            mOptimizationFlag = flag;
            reloadCursorData(animate);
            mCursor.registerDataSetObserver(mDataSetObserver);
            if (mContentObserver != null) {
                mCursor.registerContentObserver(mContentObserver);
            }
        }
    }

    @Override
    public boolean canScrollHorizontally(int direction) {
        final float x = mScroller.getCurrX();
        return (direction < 0 && x < mMaxOffset) || (direction > 0 && x > 0);
    }

    @Override
    public boolean canScrollVertically(int direction) {
        return false;
    }

    /** {@inheritDoc} */
    @Override
    public boolean onTouchEvent(final MotionEvent event) {
        // Ignore events while performing scrolling animation
        if (mState == STATE_ZOOMING) {
            return true;
        }

        final int action = event.getActionMasked();
        final int index = event.getActionIndex();
        final int pointerId = event.getPointerId(index);
        final long now = System.currentTimeMillis();
        mLastX = event.getX();
        mLastY = event.getY();

        switch (action) {
        case MotionEvent.ACTION_DOWN:
            // Initialize velocity tracker
            if (mVelocityTracker == null) {
                mVelocityTracker = VelocityTracker.obtain();
            } else {
                mVelocityTracker.clear();
            }
            mVelocityTracker.addMovement(event);
            mScroller.forceFinished(true);
            releaseEdgeEffects();
            mState = STATE_INITIALIZE;

            mLongPressDetector.mLongPressTriggered = false;
            mUiHandler.postDelayed(mLongPressDetector, mLongPressTimeout);

            mInitialTouchOffset = mCurrentOffset;
            mInitialTouchX = event.getX();
            mInitialTouchY = event.getY();
            mLastPressTimestamp = now;
            return true;

        case MotionEvent.ACTION_MOVE:
            // If a long press was detected then we end with the movement
            if (mLongPressDetector.mLongPressTriggered) {
                return true;
            }

            mVelocityTracker.addMovement(event);
            float diffX = event.getX() - mInitialTouchX;
            float diffY = event.getY() - mInitialTouchY;
            if (Math.abs(diffX) > mTouchSlop || mState >= STATE_MOVING) {
                mUiHandler.removeCallbacks(mLongPressDetector);

                mCurrentOffset = mInitialTouchOffset + diffX;
                if (mCurrentOffset < 0) {
                    onOverScroll();
                    mCurrentOffset = 0;
                } else if (mCurrentOffset > mMaxOffset) {
                    onOverScroll();
                    mCurrentOffset = mMaxOffset;
                }
                mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
                mState = STATE_MOVING;
                ViewCompat.postInvalidateOnAnimation(this);
            } else if (Math.abs(diffY) > mTouchSlop && mState < STATE_MOVING) {
                mUiHandler.removeCallbacks(mLongPressDetector);
                return false;
            }
            return true;

        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            mUiHandler.removeCallbacks(mLongPressDetector);
            // If a long press was detected then we end with the movement
            if (mLongPressDetector.mLongPressTriggered) {
                return true;
            }

            if (mState >= STATE_MOVING) {
                final int velocity = (int) VelocityTrackerCompat.getXVelocity(mVelocityTracker, pointerId);
                mScroller.forceFinished(true);
                mState = STATE_FLINGING;
                releaseEdgeEffects();
                mScroller.fling((int) mCurrentOffset, 0, velocity, 0, 0, (int) mMaxOffset, 0, 0);
                ViewCompat.postInvalidateOnAnimation(this);
            } else {
                // Reset scrolling state
                mState = STATE_IDLE;

                if (action == MotionEvent.ACTION_UP) {
                    // we are in a tap or long press action
                    final long timeDiff = (now - mLastPressTimestamp);
                    // If diff < 0, that means that time have change. ignore this event
                    if (timeDiff >= 0) {
                        if (timeDiff > TAP_TIMEOUT && timeDiff < mLongPressTimeout) {
                            // A tap event happens. Long click are detected outside
                            Message.obtain(mUiHandler, MSG_ON_CLICK_ITEM, computeItemEvent()).sendToTarget();
                        }
                    }
                }
            }

            mLastPressTimestamp = -1;
            return true;
        }
        return false;
    }

    private void onOverScroll() {
        final boolean needOverScroll;
        synchronized (mLock) {
            needOverScroll = mData.size() >= Math.floor(mMaxBarItemsInScreen / 2);
        }
        final int overScrollMode = ViewCompat.getOverScrollMode(this);
        if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS
                || (overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && needOverScroll)) {
            boolean needsInvalidate = false;
            if (mCurrentOffset > mMaxOffset) {
                mEdgeEffectLeft.onPull(mCurrentOffset - mMaxOffset);
                needsInvalidate = true;
            }
            if (mCurrentOffset < 0) {
                mEdgeEffectRight.onPull(mCurrentOffset);
                needsInvalidate = true;
            }

            if (needsInvalidate) {
                ViewCompat.postInvalidateOnAnimation(this);
            }
        }
    }

    /** {@inheritDoc} */
    @Override
    public void computeScroll() {
        super.computeScroll();

        // Ignore any scroll while performing scrolling animation
        if (mState == STATE_ZOOMING) {
            return;
        }

        // Determine whether we still scrolling and needs a viewport refresh
        final boolean scrolling = mScroller.computeScrollOffset();
        if (scrolling) {
            float x = mScroller.getCurrX();
            if (x > mMaxOffset || x < 0) {
                return;
            }
            mCurrentOffset = x;
            ViewCompat.postInvalidateOnAnimation(this);
        } else if (mState > STATE_MOVING) {
            boolean needsInvalidate = false;
            final boolean needOverScroll;
            synchronized (mLock) {
                needOverScroll = mData.size() >= Math.floor(mMaxBarItemsInScreen / 2);
            }
            final int overScrollMode = ViewCompat.getOverScrollMode(this);
            if (overScrollMode == ViewCompat.OVER_SCROLL_ALWAYS
                    || (needOverScroll && overScrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS)) {
                float x = mScroller.getCurrX();
                if (x >= mMaxOffset && mEdgeEffectLeft.isFinished() && !mEdgeEffectLeftActive) {
                    mEdgeEffectLeft.onAbsorb((int) mScroller.getCurrVelocity());
                    mEdgeEffectLeftActive = true;
                    needsInvalidate = true;
                }
                if (x <= 0 && mEdgeEffectRight.isFinished() && !mEdgeEffectRightActive) {
                    mEdgeEffectRight.onAbsorb((int) mScroller.getCurrVelocity());
                    mEdgeEffectRightActive = true;
                    needsInvalidate = true;
                }
            }
            if (!needsInvalidate) {
                // Reset state
                mState = STATE_IDLE;
                mLastTimestamp = -1;
            } else {
                ViewCompat.postInvalidateOnAnimation(this);
            }
        }

        long timestamp = computeTimestampFromOffset(mCurrentOffset);

        // If we are not centered in a item, perform an scroll
        if (mAlwaysEnsureSelection && mState == STATE_IDLE) {
            timestamp = computeNearestTimestampFromOffset(mCurrentOffset);
            smoothScrollTo(timestamp);
        }

        if (mCurrentTimestamp != timestamp) {
            // Don't perform selection operations while we are just scrolling
            if (mState != STATE_SCROLLING) {
                boolean fromUser = mCurrentTimestamp != -2;
                mCurrentTimestamp = timestamp;
                if (fromUser) {
                    performSelectionSoundEffect();
                }

                // Notify any valid item, but only notify invalid items if
                // we are not panning/scrolling
                if (mCurrentTimestamp >= 0 || !scrolling) {
                    Message.obtain(mUiHandler, MSG_ON_SELECTION_ITEM_CHANGED, fromUser).sendToTarget();
                }
            }
        }
    }

    /** {@inheritDoc} */
    @Override
    public void setOverScrollMode(int overScrollMode) {
        if (overScrollMode != OVER_SCROLL_NEVER) {
            setupEdgeEffects();
        } else {
            mEdgeEffectLeft = null;
            mEdgeEffectRight = null;
        }
        super.setOverScrollMode(overScrollMode);
    }

    /**
     * Move the current viewport of this view to the timestamp passed as argument. If
     * timestamp doesn't exists no operation will be performed.
     */
    public void scrollTo(long timestamp) {
        // Ignore any scroll while performing scrolling animation
        if (mState == STATE_ZOOMING) {
            return;
        }

        final float offset = computeOffsetForTimestamp(timestamp);
        if (offset >= 0 && offset != mCurrentOffset) {
            mCurrentOffset = offset;
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /**
     * Performs a smooth transition of the current viewport of this view to
     * the timestamp passed as argument. If timestamp doesn't exists no
     * operation will be performed.
     */
    public void smoothScrollTo(long timestamp) {
        // Ignore any scroll while performing scrolling animation
        if (mState == STATE_ZOOMING) {
            return;
        }

        final float offset = computeOffsetForTimestamp(timestamp);
        if (offset >= 0 && offset != mCurrentOffset) {
            int dx = (int) (mCurrentOffset - offset) * -1;
            mScroller.forceFinished(true);
            mState = STATE_SCROLLING;
            mLastTimestamp = mCurrentTimestamp;
            mScroller.startScroll((int) mCurrentOffset, 0, dx, 0);
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    private ItemEvent computeItemEvent() {
        // Check whether tap happens outside viewport area
        if (!mViewArea.contains(mLastX, mLastY)) {
            return null;
        }

        final LongSparseArray<Pair<double[], int[]>> data;
        final double maxValue;
        synchronized (mLock) {
            data = mData;
            maxValue = mMaxValue;
        }
        int size = data.size() - 1;
        if (size <= 0) {
            return null;
        }

        // Check if we are in an space area and not in a bar area
        float offset = mInitialTouchOffset + ((mGraphArea.width() / 2) + mGraphArea.left - mInitialTouchX);
        double s = (offset + (mBarItemWidth / 2)) % mBarWidth;
        if (s > mBarItemWidth) {
            // We are in a space area
            return null;
        }

        // So we are in an bar area, so we have a valid index
        final int index = size - ((int) Math.ceil((offset - (mBarItemWidth / 2)) / mBarWidth));
        if (index < 0 || index >= data.size()) {
            return null;
        }
        final Pair<double[], int[]> o = data.valueAt(index);
        if (o == null) {
            return null;
        }

        final float halfItemBarWidth = (mBarItemWidth / 2);
        final ItemEvent itemEvent = new ItemEvent();
        itemEvent.mTimestamp = data.keyAt(index);
        if (mShowFooter && mFooterArea.contains(mLastX, mLastY)) {
            // Tap in the bar area means we cannot extract the serie
            itemEvent.mSerie = -1;
        } else {
            // Determine if the tap happens in a drawing area (and to what serie belongs)
            final double[] values = o.first;
            final int[] indexes = o.second;
            final float height = mGraphArea.height();
            float y1, y2 = mGraphArea.height();
            final float cx = mGraphArea.left + (mGraphArea.width() / 2);
            final float x = cx + (mCurrentOffset - computeOffsetForTimestamp(itemEvent.mTimestamp));

            itemEvent.mSerie = -1;
            if (mGraphMode != GRAPH_MODE_BARS_STACK) {
                int count = values.length;
                float x1 = x - halfItemBarWidth;
                float x2 = x + halfItemBarWidth;
                float bw = mBarItemWidth / mSeries;

                for (int j = 0; j < count; j++) {
                    y2 = height;
                    if (mGraphMode == GRAPH_MODE_BARS_SIDE_BY_SIDE) {
                        y1 = (float) (height - ((height * ((values[j] * 100) / maxValue)) / 100));
                        x1 = x - halfItemBarWidth + (bw * j);
                        x2 = x1 + bw;
                    } else {
                        y1 = (float) (height - ((height * ((values[j] * 100) / maxValue)) / 100));
                    }
                    mSerieRect.set(x1, y1, x2, y2);
                    if (mSerieRect.contains(mLastX, mLastY)) {
                        itemEvent.mSerie = indexes[j];
                        break;
                    }
                }
            } else {
                int count = values.length;
                for (int j = 0; j < count; j++) {
                    float h = (float) ((height * ((values[j] * 100) / maxValue)) / 100);
                    y1 = y2 - h;
                    mSerieRect.set(x - halfItemBarWidth, y1, x + halfItemBarWidth, y2);
                    if (mSerieRect.contains(mLastX, mLastY)) {
                        itemEvent.mSerie = j;
                        break;
                    }
                    y2 -= h;
                }
            }

            // If tap isn't in an area then there is not item event
            if (itemEvent.mSerie == -1) {
                return null;
            }
        }
        return itemEvent;
    }

    private long computeTimestampFromOffset(float offset) {
        final LongSparseArray<Pair<double[], int[]>> data;
        synchronized (mLock) {
            data = mData;
        }
        int size = data.size() - 1;
        if (size < 0) {
            return -1;
        }

        // Check if we are in an space area and not in a bar area
        double s = (offset + (mBarItemWidth / 2)) % mBarWidth;
        if (s > mBarItemWidth) {
            // We are in a space area
            return -1;
        }

        // So we are in an bar area, so we have a valid index
        final int index = size - ((int) Math.ceil((offset - (mBarItemWidth / 2)) / mBarWidth));
        return data.keyAt(index);
    }

    private float computeOffsetForTimestamp(long timestamp) {
        final LongSparseArray<Pair<double[], int[]>> data;
        synchronized (mLock) {
            data = mData;
        }
        final int index = data.indexOfKey(timestamp);
        if (index >= 0) {
            final int size = data.size();
            return (mBarWidth * (size - index - 1));
        }
        return -1;
    }

    private long computeNearestTimestampFromOffset(float offset) {
        final LongSparseArray<Pair<double[], int[]>> data;
        synchronized (mLock) {
            data = mData;
        }
        int size = data.size() - 1;
        if (size < 0) {
            return -1;
        }

        // So we are in an bar area, so we have a valid index
        final int index = size - ((int) Math.ceil((offset - (mBarItemWidth / 2)) / mBarWidth));
        return data.keyAt(index);
    }

    /** {@inheritDoc} */
    @Override
    protected void onDraw(Canvas c) {
        // 1.- Clip to padding
        c.clipRect(mViewArea);

        // 2.- Draw the backgrounds areas
        c.drawRect(mGraphArea, mGraphAreaBgPaint);
        if (mShowFooter) {
            c.drawRect(mFooterArea, mFooterAreaBgPaint);
        }

        final LongSparseArray<Pair<double[], int[]>> data;
        final double maxValue;
        synchronized (mLock) {
            data = mData;
            maxValue = mMaxValue;
        }
        boolean hasData = data.size() > 0;
        if (hasData && mIsDataComputed) {
            // 3.- Compute viewport and draw the data
            computeItemsOnScreen(data);
            drawBarItems(c, data, maxValue);

            // 4.- Draw tick labels and current position
            if (mShowFooter) {
                drawTickLabels(c, data);
                c.drawPath(mCurrentPositionPath, mFooterAreaBgPaint);
            }
        }

        // Draw the edge scrolling effects
        drawEdgeEffects(c);
    }

    private void drawBarItems(Canvas c, LongSparseArray<Pair<double[], int[]>> data, double maxValue) {

        final float halfItemBarWidth = mBarItemWidth / 2;
        final float height = mGraphArea.height();
        final Paint[] seriesBgPaint;
        final Paint[] highlightSeriesBgPaint;
        synchronized (mLock) {
            seriesBgPaint = mSeriesBgPaint;
            highlightSeriesBgPaint = mHighlightSeriesBgPaint;
        }

        // Apply zoom animation
        final float zoom = mCurrentZoom;
        final float cx = mGraphArea.left + (mGraphArea.width() / 2);
        int restoreCount = 0;
        if (zoom != 1.f) {
            restoreCount = c.save();
            c.scale(zoom, zoom, cx, mGraphArea.bottom);
        }

        final int size = data.size() - 1;
        for (int i = mItemsOnScreen[1]; i >= mItemsOnScreen[0]; i--) {
            final float x = cx + mCurrentOffset - (mBarWidth * (size - i));
            float bw = mBarItemWidth / mSeries;
            double[] values = data.valueAt(i).first;
            int[] indexes = data.valueAt(i).second;

            float y1, y2 = height;
            float x1 = x - halfItemBarWidth, x2 = x + halfItemBarWidth;
            if (mGraphMode != GRAPH_MODE_BARS_STACK) {
                int count = values.length - 1;
                for (int j = count, n = 0; j >= 0; j--, n++) {
                    y2 = height;
                    final Paint paint;
                    if (mGraphMode == GRAPH_MODE_BARS_SIDE_BY_SIDE) {
                        y1 = (float) (height - ((height * ((values[n] * 100) / maxValue)) / 100));
                        x1 = x - halfItemBarWidth + (bw * n);
                        x2 = x1 + bw;
                        paint = (x - halfItemBarWidth) < cx && (x + halfItemBarWidth) > cx
                                && (mLastTimestamp == mCurrentTimestamp || (mState != STATE_SCROLLING))
                                        ? highlightSeriesBgPaint[indexes[n]]
                                        : seriesBgPaint[indexes[n]];
                    } else {
                        y1 = (float) (height - ((height * ((values[j] * 100) / maxValue)) / 100));
                        paint = x1 < cx && x2 > cx
                                && (mLastTimestamp == mCurrentTimestamp || (mState != STATE_SCROLLING))
                                        ? highlightSeriesBgPaint[indexes[j]]
                                        : seriesBgPaint[indexes[j]];
                    }

                    c.drawRect(x1, mGraphArea.top + y1, x2, mGraphArea.top + y2, paint);
                }
            } else {
                int count = values.length;
                for (int j = 0; j < count; j++) {
                    float h = (float) ((height * ((values[j] * 100) / maxValue)) / 100);
                    y1 = y2 - h;

                    final Paint paint = x1 < cx && x2 > cx
                            && (mLastTimestamp == mCurrentTimestamp || (mState != STATE_SCROLLING))
                                    ? highlightSeriesBgPaint[indexes[j]]
                                    : seriesBgPaint[indexes[j]];
                    c.drawRect(x1, mGraphArea.top + y1, x2, mGraphArea.top + y2, paint);
                    y2 -= h;
                }
            }
        }

        // Restore from zoom
        if (zoom != 1.f) {
            c.restoreToCount(restoreCount);
        }
    }

    private void drawTickLabels(Canvas c, LongSparseArray<Pair<double[], int[]>> data) {
        final float alphaVariation = MAX_ZOOM_OUT - MIN_ZOOM_OUT;
        final float alpha = MAX_ZOOM_OUT - mCurrentZoom;
        mTickLabelFgPaint.setAlpha((int) ((alpha * 255) / alphaVariation));

        final int size = data.size() - 1;
        final float cx = mGraphArea.left + (mGraphArea.width() / 2);
        for (int i = mItemsOnScreen[1]; i >= mItemsOnScreen[0]; i--) {
            // Update the dynamic layout
            long timestamp = data.keyAt(i);
            final int tickFormat = getTickLabelFormat(timestamp);
            mTickDate.setTime(timestamp);
            final String text = mTickFormatter[tickFormat].format(mTickDate).replace(".", "")
                    .toUpperCase(Locale.getDefault());
            DynamicSpannableString spannable = mTickTextSpannables[tickFormat].get(text.length());
            if (spannable == null) {
                // If we don't have an spannable for the text length, create a new one
                // that allow to use it now and in the future. Doing this here (on draw)
                // is not the best, but it supposed to only be performed one time per
                // different tick text length
                spannable = createSpannableTick(tickFormat, text);
                mTickTextSpannables[tickFormat].put(text.length(), spannable);
            }
            spannable.update(text);

            DynamicLayout layout = mTickTextLayouts[tickFormat].get(text.length());
            if (layout == null) {
                // Update the layout as well
                layout = new DynamicLayout(spannable, mTickLabelFgPaint, (int) mBarItemWidth,
                        Layout.Alignment.ALIGN_CENTER, 1.0f, 1.0f, false);
                mTickTextLayouts[tickFormat].put(text.length(), layout);
            }

            // Calculate the x position and draw the layout
            final float x = cx + mCurrentOffset - (mBarWidth * (size - i)) - (layout.getWidth() / 2);
            final int restoreCount = c.save();
            c.translate(x, mFooterArea.top + (mFooterArea.height() / 2 - mTickLabelMinHeight / 2));
            layout.draw(c);
            c.restoreToCount(restoreCount);
        }
    }

    private void drawEdgeEffects(Canvas c) {
        boolean needsInvalidate = false;

        if (mEdgeEffectLeft != null && !mEdgeEffectLeft.isFinished()) {
            final int restoreCount = c.save();
            c.rotate(270);
            c.translate(-mGraphArea.height() - mGraphArea.top, mGraphArea.left);
            mEdgeEffectLeft.setSize((int) mGraphArea.height(), (int) mGraphArea.width());
            needsInvalidate = mEdgeEffectLeft.draw(c);
            c.restoreToCount(restoreCount);
        }

        if (mEdgeEffectRight != null && !mEdgeEffectRight.isFinished()) {
            final int restoreCount = c.save();
            c.rotate(90);
            c.translate(mGraphArea.top, -getWidth() + (getWidth() - mGraphArea.right));
            mEdgeEffectRight.setSize((int) mGraphArea.height(), (int) mGraphArea.width());
            needsInvalidate |= mEdgeEffectRight.draw(c);
            c.restoreToCount(restoreCount);
        }

        if (needsInvalidate) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }

    /** {@inheritDoc} */
    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        mViewArea.set(getPaddingLeft(), getPaddingTop(), getWidth() - getPaddingRight(),
                getHeight() - getPaddingBottom());
        computeBoundAreas();
    }

    private void computeItemsOnScreen(LongSparseArray<Pair<double[], int[]>> data) {
        if (mLastOffset == mCurrentOffset) {
            return;
        }
        int size = data.size() - 1;
        float offset = mCurrentOffset + (mBarItemWidth / 2);
        int last = size - (int) Math.floor(offset / mBarWidth) + (int) Math.ceil(mMaxBarItemsInScreen / 2);
        int rest = 0;
        if (last > size) {
            rest = last - size;
            last = size;
        }
        int first = last - (mMaxBarItemsInScreen - 1) + rest;
        if (first < 0) {
            first = 0;
        }

        // Save the item positions
        mItemsOnScreen[0] = first;
        mItemsOnScreen[1] = last;
        mLastOffset = mCurrentOffset;
    }

    private void computeBoundAreas() {
        if (mShowFooter) {
            // Compute current based on the bar height
            computeCurrentPositionIndicatorDimensions();

            mGraphArea.set(mViewArea);
            mGraphArea.bottom = Math.max(mViewArea.bottom - mFooterBarHeight, 0);
            mFooterArea.set(mViewArea);
            mFooterArea.top = mGraphArea.bottom;
            mFooterArea.bottom = mGraphArea.bottom + mFooterBarHeight;

            mCurrentPositionPath.reset();
            final float w = mGraphArea.width();
            final float h = mGraphArea.height();
            if (w > 0 && h > 0) {
                mCurrentPositionPath.moveTo(mGraphArea.left + (w / 2) - (mCurrentPositionIndicatorWidth / 2),
                        mGraphArea.bottom);
                mCurrentPositionPath.lineTo(mGraphArea.left + w / 2,
                        mGraphArea.bottom - mCurrentPositionIndicatorHeight);
                mCurrentPositionPath.lineTo(mGraphArea.left + (w / 2) + (mCurrentPositionIndicatorWidth / 2),
                        mGraphArea.bottom);
            }
        } else {
            mGraphArea.set(mViewArea);
            mFooterArea.set(-1, -1, -1, -1);
        }

        // Compute max bar items here too
        computeMaxBarItemsInScreen();
    }

    private void computeMaxBarItemsInScreen() {
        ensureBarWidth();
        mMaxBarItemsInScreen = (int) Math.ceil(mGraphArea.width() / mBarWidth) + 2;
    }

    private void computeCurrentPositionIndicatorDimensions() {
        mCurrentPositionIndicatorWidth = mBarItemWidth / 2.8f;
        mCurrentPositionIndicatorHeight = mBarItemWidth / 4f;
    }

    private synchronized void setupBackgroundHandler() {
        if (mBackgroundHandler == null) {
            // Create a background thread
            mBackgroundHandlerThread = new HandlerThread(TAG + "BackgroundThread");
            mBackgroundHandlerThread.start();
            mBackgroundHandler = new Handler(mBackgroundHandlerThread.getLooper(), mMessenger);
        }
    }

    @SuppressWarnings("unchecked")
    private void setupTickLabels() {
        synchronized (mLock) {
            mTickCalendar = Calendar.getInstance(TimeZone.getDefault(), Locale.getDefault());

            mTextSizeFactor = mFooterBarHeight / mDefFooterBarHeight;
            mTickLabelFgPaint.setTextSize((int) (mSize8 * mTextSizeFactor));

            mTickDate = new Date();

            int count = mTickFormats.length;
            mTickTextLayouts = new SparseArray[count];
            mTickFormatter = new SimpleDateFormat[count];
            mTickTextSpannables = new SparseArray[count];
            for (int i = 0; i < count; i++) {
                mTickFormatter[i] = new SimpleDateFormat(mTickFormats[i], Locale.getDefault());
                mTickDate.setTime(Long.valueOf(mTickLabels[i]));
                final String text = mTickFormatter[i].format(mTickDate).replace(".", "")
                        .toUpperCase(Locale.getDefault());
                mTickTextSpannables[i] = new SparseArray<>();
                mTickTextLayouts[i] = new SparseArray<>();

                // Store spannable in memory based in its length, so we don't have to rebuild
                // a every time, just only in case they are needed (normally never)
                DynamicSpannableString spannable = createSpannableTick(i, text);
                mTickTextLayouts[i].put(text.length(), new DynamicLayout(spannable, mTickLabelFgPaint,
                        (int) mBarItemWidth, Layout.Alignment.ALIGN_CENTER, 1.0f, 1.0f, false));

                // Save min height
                mTickLabelMinHeight = Math.max(mTickLabelMinHeight,
                        mTickTextLayouts[i].get(text.length()).getHeight());
            }
        }
    }

    private DynamicSpannableString createSpannableTick(int tickFormat, CharSequence text) {
        DynamicSpannableString spannable = new DynamicSpannableString(text);
        mTickTextSpannables[tickFormat].put(text.length(), spannable);
        if (tickFormat == (mTickFormats.length - 1)) {
            spannable.setSpan(new AbsoluteSizeSpan((int) (mSize20 * mTextSizeFactor)), 0, 2,
                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        } else if (tickFormat == 1) {
            spannable.setSpan(new AbsoluteSizeSpan((int) (mSize12 * mTextSizeFactor)), 0, text.length(),
                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
        return spannable;
    }

    private void setupEdgeEffects() {
        if (mEdgeEffectLeft == null) {
            mEdgeEffectLeft = new EdgeEffect(getContext());
        }
        if (mEdgeEffectRight == null) {
            mEdgeEffectRight = new EdgeEffect(getContext());
        }
        setupEdgeEffectColor();
    }

    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    private void setupEdgeEffectColor() {
        if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
            if (mGraphAreaBgPaint != null && mEdgeEffectLeft != null && mEdgeEffectRight != null) {
                int color = MaterialPaletteHelper.isDarkColor(mGraphAreaBgPaint.getColor()) ? Color.WHITE
                        : Color.BLACK;
                mEdgeEffectLeft.setColor(color);
                mEdgeEffectRight.setColor(color);
            }
        }
    }

    private void setupSoundEffects() {
        if (mPlaySelectionSoundEffect && mSelectionSoundEffectSource != SYSTEM_SOUND_EFFECT) {
            if (mSoundEffectMP == null) {
                mSoundEffectMP = MediaPlayer.create(getContext(), mSelectionSoundEffectSource);
                mSoundEffectMP.setVolume(SOUND_EFFECT_VOLUME, SOUND_EFFECT_VOLUME);
            }
        } else if (mSoundEffectMP != null) {
            releaseSoundEffects();
        }
    }

    private void releaseSoundEffects() {
        if (mSoundEffectMP.isPlaying()) {
            mSoundEffectMP.stop();
        }
        mSoundEffectMP.release();
        mSoundEffectMP = null;
    }

    private void setupAnimators() {
        // A zoom-in/zoom-out animator
        mZoomAnimator = ValueAnimator.ofFloat(1.f);
        mZoomAnimator.setDuration(350L);
        mZoomAnimator.setInterpolator(new DecelerateInterpolator());
        mZoomAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mCurrentZoom = (Float) animation.getAnimatedValue();
                ViewCompat.postInvalidateOnAnimation(TimelineChartView.this);
            }
        });
        mZoomAnimator.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                if (mInZoomOut) {
                    // Swap temporary refs
                    swapRefs();

                    // Update the view, notify and end the animation
                    Message.obtain(mUiHandler, MSG_UPDATE_COMPUTED_DATA, 1, 0).sendToTarget();
                } else {
                    mState = STATE_IDLE;
                }
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                mState = STATE_IDLE;
            }

            @Override
            public void onAnimationRepeat(Animator animation) {
            }
        });
    }

    private void reloadCursorData(boolean animate) {
        int arg1 = mAnimateCursorTransition && animate ? 1 : 0;
        Message.obtain(mBackgroundHandler, MSG_COMPUTE_DATA, arg1, 1).sendToTarget();
    }

    private void performComputeData(boolean animate, boolean notify) {
        // Process the data
        processData();

        if (animate) {
            // Run in an animation
            if (mZoomAnimator.isRunning()) {
                mZoomAnimator.cancel();
            }
            mInZoomOut = true;
            mZoomAnimator.setFloatValues(MIN_ZOOM_OUT, MAX_ZOOM_OUT);
            mScroller.forceFinished(true);
            mState = STATE_ZOOMING;
            mZoomAnimator.start();

        } else if (notify) {
            // Swap temporary refs
            mScroller.forceFinished(true);
            mState = STATE_IDLE;
            swapRefs();

            // Update the view and notify
            Message.obtain(mUiHandler, MSG_UPDATE_COMPUTED_DATA, 0, 0).sendToTarget();
        }

        // Update the graph view
        ViewCompat.postInvalidateOnAnimation(TimelineChartView.this);
    }

    private void processData() {
        // This optimizations can by applied to data in this method according to the
        // defined current optimization flag:
        //
        //  NO_OPTIMIZATION: All the data is compute again
        //  NO_DELETES_OPTIMIZATION: Internal can be preserve and only updates
        //    and additions will happen
        //  ONLY_ADDITIONS_OPTIMIZATION: Internal is preserve, and only additions are accounted
        synchronized (mCursorLock) {
            if (mCursor != null && !mCursor.isClosed() && mCursor.moveToFirst()) {
                // Load the cursor to memory
                boolean hasDayFormat = false;
                double max = 0d;
                final LongSparseArray<Pair<double[], int[]>> data;
                // Clone the data if we optimization flag allow it.
                if (mOptimizationFlag != NO_OPTIMIZATION) {
                    data = cloneCurrentData(mCursor.getCount());
                } else {
                    data = new LongSparseArray<>(mCursor.getCount());
                }

                int series = mCursor.getColumnCount() - 1;
                if (mItem.mSeries == null || mItem.mSeries.length != series) {
                    mItem.mSeries = new double[series];
                }

                long lastTimestamp = -1;
                if (mOptimizationFlag == ONLY_ADDITIONS_OPTIMIZATION) {
                    hasDayFormat = mTickHasDayFormat;
                    max = mMaxValue;
                    mCursor.moveToLast();
                    if (data.size() > 0) {
                        lastTimestamp = data.keyAt(data.size() - 1);
                    }
                }

                // Extract the data from the cursor applying the current optimization flag.
                int lastTickLabelFormat = -1;
                do {
                    long timestamp = mCursor.getLong(0);
                    if (timestamp == lastTimestamp && mOptimizationFlag == ONLY_ADDITIONS_OPTIMIZATION) {
                        break;
                    }

                    // Determine the best tick vertical alignment
                    final int tickLabelFormat = getTickLabelFormat(timestamp);
                    if (tickLabelFormat == TICK_LABEL_DAY_FORMAT
                            || (lastTickLabelFormat != -1 && lastTickLabelFormat != tickLabelFormat)) {
                        hasDayFormat = true;
                    }
                    lastTickLabelFormat = tickLabelFormat;

                    final double[] seriesData;
                    final int[] indexes;
                    if (mOptimizationFlag == NO_OPTIMIZATION) {
                        seriesData = new double[series];
                        indexes = new int[series];
                    } else {
                        Pair<double[], int[]> v = data.get(timestamp);
                        if (v != null) {
                            seriesData = v.first;
                            indexes = v.second;
                        } else {
                            seriesData = new double[series];
                            indexes = new int[series];
                        }
                    }
                    double stackVal = 0d;
                    for (int i = 0; i < series; i++) {
                        final double v = mCursor.getDouble(i + 1);
                        seriesData[i] = v;
                        if (mGraphMode != GRAPH_MODE_BARS_STACK && v > max) {
                            max = v;
                        } else {
                            stackVal += v;
                        }
                        indexes[i] = i;
                    }
                    if (mGraphMode == GRAPH_MODE_BARS_STACK && stackVal > max) {
                        max = stackVal;
                    }

                    // Sort the items to properly one over other in screen
                    if (mGraphMode == GRAPH_MODE_BARS) {
                        ArraysHelper.sort(seriesData, indexes);
                    }
                    Pair<double[], int[]> pair = new Pair<>(seriesData, indexes);
                    data.put(timestamp, pair);
                } while (mOptimizationFlag == ONLY_ADDITIONS_OPTIMIZATION ? mCursor.moveToPrevious()
                        : mCursor.moveToNext());

                // Calculate the max available offset
                int size = data.size() - 1;
                float maxOffset = mBarWidth * size;

                //swap data
                synchronized (mLock) {
                    mSeriesSwap = series;
                    mDataSwap = data;
                    mMaxValueSwap = max;
                    mMaxOffsetSwap = maxOffset;
                    mTickHasDayFormatSwap = hasDayFormat;
                }
            } else {
                // Cursor is empty or closed
                clearSwapRefs();
            }
        }
    }

    private LongSparseArray<Pair<double[], int[]>> cloneCurrentData(int capacity) {
        final LongSparseArray<Pair<double[], int[]>> prevData;
        synchronized (mLock) {
            prevData = mData;
        }
        if (prevData != null) {
            final int size = prevData.size();
            final LongSparseArray<Pair<double[], int[]>> data = new LongSparseArray<>(Math.max(capacity, size));
            for (int i = 0; i < size; i++) {
                data.append(prevData.keyAt(i), prevData.valueAt(i));
            }
            return data;
        }
        return new LongSparseArray<>();
    }

    private void checkCursorIntegrity(Cursor c) {
        if (c.getCount() == 0) {
            return;
        }
        int columnCount = c.getColumnCount();
        if (columnCount < 1) {
            throw new IllegalArgumentException("Cursor must have at least 2 columns");
        }
        if (!isNumericColumnType(0, c)) {
            throw new IllegalArgumentException("Column 0 must be a timestamp (numeric type)");
        }
        for (int i = 1; i < columnCount; i++) {
            if (!isNumericColumnType(i, c)) {
                throw new IllegalArgumentException("All series must be a valid numeric type");
            }
        }
    }

    private boolean isNumericColumnType(int columnIndex, Cursor c) {
        int type = c.getType(columnIndex);
        return type == Cursor.FIELD_TYPE_INTEGER || type == Cursor.FIELD_TYPE_FLOAT;
    }

    private void setupSeriesBackground(int color) {
        int[] currentPalette = new int[mSeries];
        Paint[] seriesBgPaint = new Paint[mSeries];
        Paint[] highlightSeriesBgPaint = new Paint[mSeries];
        if (mSeries == 0) {
            return;
        }

        int userPaletteCount = 0;
        if (mUserPalette != null) {
            userPaletteCount = mUserPalette.length;
            for (int i = 0; i < userPaletteCount; i++) {
                seriesBgPaint[i] = new Paint();
                currentPalette[i] = mUserPalette[i];
                seriesBgPaint[i].setColor(currentPalette[i]);
                highlightSeriesBgPaint[i] = new Paint(seriesBgPaint[i]);
                highlightSeriesBgPaint[i].setColor(MaterialPaletteHelper.getComplementaryColor(currentPalette[i]));
            }
        }

        // Generate bar items palette based on background color
        int needed = mSeries - userPaletteCount;
        int[] palette = MaterialPaletteHelper.createMaterialSpectrumPalette(color, needed);
        for (int i = userPaletteCount; i < mSeries; i++) {
            seriesBgPaint[i] = new Paint();
            currentPalette[i] = palette[i - userPaletteCount];
            seriesBgPaint[i].setColor(currentPalette[i]);
            highlightSeriesBgPaint[i] = new Paint(seriesBgPaint[i]);
            highlightSeriesBgPaint[i].setColor(MaterialPaletteHelper.getComplementaryColor(currentPalette[i]));
        }

        final boolean changed = !(Arrays.equals(currentPalette, mCurrentPalette));

        synchronized (mLock) {
            mCurrentPalette = currentPalette;
            mSeriesBgPaint = seriesBgPaint;
            mHighlightSeriesBgPaint = highlightSeriesBgPaint;
        }

        if (changed) {
            notifyOnColorPaletteChanged();
        }
    }

    private void swapRefs() {
        synchronized (mLock) {
            mSeries = mSeriesSwap;
            mData = mDataSwap;
            mMaxValue = mMaxValueSwap;
            mLastOffset = -1.f;
            mMaxOffset = mMaxOffsetSwap;

            // Compute current offset and timestamp
            final int index = mData.indexOfKey(mCurrentTimestamp);
            final boolean lastItem = mCurrentOffset == 0.f;
            final boolean haveTimestamp = index >= 0;
            if (haveTimestamp && (!lastItem || !mFollowCursorPosition)) {
                mCurrentOffset = computeOffsetForTimestamp(mCurrentTimestamp);
            } else {
                mCurrentOffset = 0;
                mCurrentTimestamp = -2;
            }

            // Setup tick labels if we detected changes
            if (mTickHasDayFormat != mTickHasDayFormatSwap) {
                mTickHasDayFormat = mTickHasDayFormatSwap;
                setupTickLabels();
            }
        }
    }

    private void clearSwapRefs() {
        mDataSwap.clear();
        mMaxValueSwap = 0d;
        mTickHasDayFormatSwap = false;
    }

    private void clear() {
        synchronized (mLock) {
            mData.clear();
            mMaxValue = 0d;
            mCurrentTimestamp = -1;
        }
    }

    private void notifyGenericClickEvent(ItemEvent itemEvent) {
        if (mOnClickItemCallback != null && itemEvent != null && itemEvent.mTimestamp > 0) {
            final Item item = obtainItem(itemEvent.mTimestamp);
            if (item != null) {
                mOnClickItemCallback.onClickItem(item, itemEvent.mSerie);
            }
        } else if (isClickable()) {
            // Click on a empty area or click item not registered and view request
            // click actions
            performClick();
        }
    }

    private void notifyGenericLongClickEvent(ItemEvent itemEvent) {
        if (mOnLongClickItemCallback != null && itemEvent != null && itemEvent.mTimestamp > 0) {
            final Item item = obtainItem(itemEvent.mTimestamp);
            if (item != null) {
                mOnLongClickItemCallback.onLongClickItem(item, itemEvent.mSerie);
            }
        } else if (isLongClickable()) {
            // Long click on a empty area or long click item not registered and view request
            // long click actions
            performLongClick();
        }
    }

    private void notifyOnSelectionItemChanged(boolean fromUser) {
        if (mOnSelectedItemChangedCallbacks.size() == 0) {
            return;
        }

        final Item item = obtainItem(mCurrentTimestamp);
        if (item == null) {
            for (OnSelectedItemChangedListener cb : mOnSelectedItemChangedCallbacks) {
                cb.onNothingSelected();
            }
        } else {
            for (OnSelectedItemChangedListener cb : mOnSelectedItemChangedCallbacks) {
                cb.onSelectedItemChanged(item, fromUser);
            }
        }
    }

    private void notifyOnColorPaletteChanged() {
        for (OnColorPaletteChangedListener cb : mOnColorPaletteChangedCallbacks) {
            cb.onColorPaletteChanged(mCurrentPalette);
        }
    }

    private Item obtainItem(long timestamp) {
        final Pair<double[], int[]> data;
        final int count;
        synchronized (mLock) {
            data = mData.get(timestamp);
            count = mSeries;
        }
        if (data == null) {
            return null;
        }

        // Compute item. Restore original sort before notify
        mItem.mTimestamp = timestamp;
        for (int i = 0; i < count; i++) {
            mItem.mSeries[i] = data.first[data.second[i]];
        }
        return mItem;
    }

    private void ensureBarWidth() {
        if (!mShowFooter) {
            return;
        }
        if (mTickTextLayouts != null) {
            float minWidth = 0.f;
            for (SparseArray<DynamicLayout> a : mTickTextLayouts) {
                int count = a.size();
                for (int i = 0; i < count; i++) {
                    DynamicLayout layout = a.valueAt(i);
                    final float width = layout.getWidth();
                    if (minWidth < width) {
                        minWidth = width;
                    }
                }
            }
            if (minWidth > mBarItemWidth) {
                Log.w(TAG, "There is not enough space for labels. Switch BarItemWidth to " + minWidth);
                mBarItemWidth = minWidth;
            }
        }
        mBarWidth = mBarItemWidth + mBarItemSpace;
    }

    private int getTickLabelFormat(long timestamp) {
        mTickCalendar.setTimeInMillis(timestamp);
        final int hour = mTickCalendar.get(Calendar.HOUR_OF_DAY);
        final int minute = mTickCalendar.get(Calendar.MINUTE);
        final int second = mTickCalendar.get(Calendar.SECOND);
        final int millisecond = mTickCalendar.get(Calendar.MILLISECOND);
        if (hour == 0 && minute == 0 && second == 0 && millisecond == 0) {
            return TICK_LABEL_DAY_FORMAT;
        }
        if (second == 0 && millisecond == 0) {
            return TICK_LABEL_HOUR_MINUTES_FORMAT;
        }
        return TICK_LABEL_SECONDS_FORMAT;
    }

    private void performSelectionSoundEffect() {
        if (!isInEditMode()) {
            if (mPlaySelectionSoundEffect) {
                if (mSelectionSoundEffectSource == SYSTEM_SOUND_EFFECT) {
                    mAudioManager.playSoundEffect(SoundEffectConstants.CLICK, SOUND_EFFECT_VOLUME);
                } else {
                    mSoundEffectMP.start();
                }
            }
        }
    }

    private void releaseEdgeEffects() {
        mEdgeEffectLeftActive = mEdgeEffectRightActive = false;
        if (mEdgeEffectLeft != null) {
            mEdgeEffectLeft.onRelease();
        }
        if (mEdgeEffectRight != null) {
            mEdgeEffectRight.onRelease();
        }
    }

    private void releaseCursor() {
        synchronized (mCursorLock) {
            if (mCursor != null) {
                mCursor.unregisterDataSetObserver(mDataSetObserver);
                if (mContentObserver != null) {
                    mCursor.unregisterContentObserver(mContentObserver);
                }
                if (!mCursor.isClosed()) {
                    mCursor.close();
                }
                mCursor = null;
                mSeries = 0;
                mItem.mSeries = new double[mSeries];
            }
        }
    }

    private void setupViewInEditMode() {
        final int[] INDEXES = new int[] { 0, 1 };
        mData = new LongSparseArray<>();
        mData.put(1452639600000L, new Pair<>(new double[] { 1867263, 2262779 }, INDEXES));
        mData.put(1452726000000L, new Pair<>(new double[] { 578273, 2871800 }, INDEXES));
        mData.put(1452812400000L, new Pair<>(new double[] { 2709, 2960491 }, INDEXES));
        mData.put(1452898800000L, new Pair<>(new double[] { 1322623, 6864896 }, INDEXES));
        mData.put(1452985200000L, new Pair<>(new double[] { 1272367, 4282328 }, INDEXES));
        mData.put(1453071600000L, new Pair<>(new double[] { 115774, 7706941 }, INDEXES));
        mData.put(1453158000000L, new Pair<>(new double[] { 1920784, 3800944 }, INDEXES));
        mData.put(1453244400000L, new Pair<>(new double[] { 534265, 5978142 }, INDEXES));
        mData.put(1453330800000L, new Pair<>(new double[] { 117245, 7801457 }, INDEXES));
        mData.put(1453417200000L, new Pair<>(new double[] { 430320, 5054115 }, INDEXES));
        mData.put(1453503600000L, new Pair<>(new double[] { 2461596, 8174509 }, INDEXES));
        mData.put(1453590000000L, new Pair<>(new double[] { 702240, 503133 }, INDEXES));
        mData.put(1453676400000L, new Pair<>(new double[] { 1364885, 4013798 }, INDEXES));
        mData.put(1453762800000L, new Pair<>(new double[] { 1310028, 877585 }, INDEXES));
        mData.put(1453849200000L, new Pair<>(new double[] { 801779, 8092978 }, INDEXES));
        mData.put(1453935600000L, new Pair<>(new double[] { 1089847, 3678389 }, INDEXES));
        mSeries = 2;
        mMaxValue = 8174509;
        //setupSeriesBackground(mGraphAreaBgPaint.getColor());
        mIsDataComputed = true;
        mState = STATE_IDLE;
        mLastTimestamp = -1;
        mCurrentTimestamp = 1453935600000L;

        int[] palette1 = MaterialPaletteHelper.createMaterialSpectrumPalette(mGraphAreaBgPaint.getColor(), 2);
        int[] palette2 = MaterialPaletteHelper.createMaterialSpectrumPalette(
                MaterialPaletteHelper.getComplementaryColor(mGraphAreaBgPaint.getColor()), 2);

        mSeriesBgPaint = new Paint[2];
        mSeriesBgPaint[0] = new Paint();
        mSeriesBgPaint[0].setColor(palette1[0]);
        mSeriesBgPaint[1] = new Paint();
        mSeriesBgPaint[1].setColor(palette1[1]);

        mHighlightSeriesBgPaint = new Paint[2];
        mHighlightSeriesBgPaint[0] = new Paint();
        mHighlightSeriesBgPaint[0].setColor(palette2[0]);
        mHighlightSeriesBgPaint[1] = new Paint();
        mHighlightSeriesBgPaint[1].setColor(palette2[1]);

    }
}