/*
* Author: Balch
* Created: 8/21/16 7:27 AM
*
* This file is part of MockTrade.
*
* MockTrade is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* MockTrade is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with MockTrade. If not, see <http://www.gnu.org/licenses/>.
*
* Copyright (C) 2016
*
*/
package com.balch.mocktrade.shared.view;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.BlurMaskFilter;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.DashPathEffect;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PathMeasure;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffXfermode;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Shader;
import android.os.Build;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.DecelerateInterpolator;
import com.balch.android.app.framework.types.Money;
import com.balch.mocktrade.shared.PerformanceItem;
import com.balch.mocktrade.shared.R;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
/**
* Render the performance items on a daily graph.
*
* This component will graph absolute daily change over time. The
* vertical center of the graph will be 0.0 and lines drawn above the
* center will be green and below the center will render as red.
*
* The x Axis will represent the entire day, starting 30 mins before
* market open and ending 30 mins after market close (unless the last
* quote time is after market close, in which case the the end of the graph
* will be after the last quote time.
*
*/
public class DailyGraphView extends View {
private static final String TAG = DailyGraphView.class.getSimpleName();
private static final int ANIMATION_DURATION_MS = 700;
private static final int GRAPH_PADDING_VERTICAL = 30;
private static final int[] LINEAR_GRADIENT_COLORS_STROKE = new int[]{
Color.argb(255, 0, 255, 0),
Color.argb(255, 0, 156, 0),
Color.argb(255, 156, 156, 156),
Color.argb(255, 156, 0, 0),
Color.argb(255, 255, 0, 0)
};
private static final float[] LINEAR_GRADIENT_POSITIONS_STROKE = new float[]{
0f, .475f, .5f, .525f, 1f
};
private static final int[] LINEAR_GRADIENT_COLORS_FILL = new int[]{
Color.argb(92, 0, 255, 0),
Color.argb(92, 0, 156, 0),
Color.argb(92, 0, 92, 0),
Color.argb(0, 0, 0, 0),
Color.argb(92, 92, 0, 0),
Color.argb(92, 156, 0, 0),
Color.argb(92, 255, 0, 0)
};
private static final float[] LINEAR_GRADIENT_POSITIONS_FILL = new float[]{
0f, .25f, .499f, .5f, .501f, .75f, 1f
};
private final static DateFormat HOURLY_DATE_FORMAT = DateFormat.getTimeInstance(DateFormat.SHORT);
private final static DateFormat DAILY_DATE_FORMAT = new SimpleDateFormat("EEE, MMM dd", Locale.getDefault());
private static final int EXAMINER_WIDTH = 5;
private Paint mPathPaintStroke;
private Paint mPathPaintFill;
private Paint mMarketTimesPaint;
private Path mPathStroke;
private Path mPathFill;
private float mPathLengthStroke;
private Paint mExaminerPaint;
private RectF mExaminerRect;
private Paint mExaminerTimePaint;
private String mExaminerTime;
private Rect mExaminerTimeTextBounds = new Rect();
private Paint mExaminerValuePaint;
private String mExaminerValue;
private Rect mExaminerValueTextBounds = new Rect();
private List<PerformanceItem> mPerformanceItems;
private int mWidth;
private int mHeight;
private float mScaleX = 1.0f;
private float mScaleY = 1.0f;
private long mOffsetY;
private long mOffsetX;
private long mMaxYValue;
private long mMinYValue;
private long mMarketStartTime;
private long mMarketEndTime;
private boolean mAllowMove = true;
private boolean mHourly = true;
public DailyGraphView(Context context) {
super(context);
initialize(null);
}
public DailyGraphView(Context context, AttributeSet attrs) {
super(context, attrs);
initialize(attrs);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mHourly) {
if (mMarketStartTime != 0) {
float marketStartX = scaleX(mMarketStartTime);
canvas.drawLine(marketStartX, GRAPH_PADDING_VERTICAL,
marketStartX, mHeight - GRAPH_PADDING_VERTICAL, mMarketTimesPaint);
}
if (mMarketEndTime != 0) {
float marketEndX = scaleX(mMarketEndTime);
canvas.drawLine(marketEndX, GRAPH_PADDING_VERTICAL,
marketEndX, mHeight - GRAPH_PADDING_VERTICAL, mMarketTimesPaint);
}
}
float centerY = scaleY(getCenterValue());
canvas.drawLine(0, centerY, mWidth, centerY, mMarketTimesPaint);
canvas.drawPath(mPathStroke, mPathPaintStroke);
canvas.drawPath(mPathFill, mPathPaintFill);
if (mExaminerRect != null) {
canvas.drawRect(mExaminerRect, mExaminerPaint);
canvas.drawText(mExaminerTime,
mExaminerRect.left - mExaminerTimeTextBounds.centerX(), mHeight - 2,
mExaminerTimePaint);
canvas.drawText(mExaminerValue,
mExaminerRect.left - mExaminerValueTextBounds.centerX(),
mExaminerValueTextBounds.height() + 2,
mExaminerValuePaint);
}
}
private long getCenterValue() {
long centerPos = 0;
if (!mHourly && (mPerformanceItems.size() > 0)) {
centerPos = getPerformanceItemValue(mPerformanceItems.get(0)).getMicroCents();
}
return centerPos;
}
private void initialize(AttributeSet attrs) {
int examineTextSize = 34;
mAllowMove = true;
if (attrs != null) {
TypedArray a = getContext().getTheme().obtainStyledAttributes(
attrs,
R.styleable.DailyGraphView,
0, 0);
try {
examineTextSize = a.getDimensionPixelSize(R.styleable.DailyGraphView_examineTextSize, 34);
mAllowMove = a.getBoolean(R.styleable.DailyGraphView_allowMove, true);
} finally {
a.recycle();
}
}
mMarketTimesPaint = new Paint();
mMarketTimesPaint.setAntiAlias(true);
mMarketTimesPaint.setStyle(Paint.Style.STROKE);
mMarketTimesPaint.setColor(Color.LTGRAY);
mMarketTimesPaint.setAlpha(128);
mMarketTimesPaint.setStrokeWidth(2);
mMarketTimesPaint.setPathEffect(new DashPathEffect(new float[]{4, 4}, 0));
mExaminerPaint = new Paint();
mExaminerPaint.setAntiAlias(true);
mExaminerPaint.setColor(Color.argb(255, 168, 168, 168));
mExaminerPaint.setStyle(Paint.Style.FILL);
mExaminerPaint.setMaskFilter(new BlurMaskFilter(EXAMINER_WIDTH, BlurMaskFilter.Blur.NORMAL));
mExaminerPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DARKEN));
mExaminerTimePaint = new Paint();
mExaminerTimePaint.setAntiAlias(true);
mExaminerTimePaint.setColor(Color.WHITE);
mExaminerTimePaint.setStyle(Paint.Style.FILL);
mExaminerTimePaint.setTextSize(examineTextSize);
mExaminerValuePaint = new Paint();
mExaminerValuePaint.setAntiAlias(true);
mExaminerValuePaint.setColor(Color.WHITE);
mExaminerValuePaint.setStyle(Paint.Style.FILL);
mExaminerValuePaint.setTextSize(examineTextSize);
mPathPaintStroke = new Paint();
mPathPaintStroke.setAntiAlias(true);
mPathPaintStroke.setStyle(Paint.Style.STROKE);
mPathPaintStroke.setColor(Color.WHITE);
mPathPaintStroke.setStrokeWidth(4);
mPathPaintStroke.setStrokeCap(Paint.Cap.ROUND);
mPathPaintStroke.setStrokeJoin(Paint.Join.ROUND);
mPathPaintStroke.setShadowLayer(7, 0f, 0f, Color.LTGRAY);
mPathPaintFill = new Paint();
mPathPaintFill.setAntiAlias(true);
mPathPaintFill.setStyle(Paint.Style.FILL);
mPathPaintFill.setColor(Color.WHITE);
if (!isInEditMode() && Build.VERSION.SDK_INT >= 11) {
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
mPathStroke = new Path();
mPathFill = new Path();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
if ((w != 0) && (h != 0)) {
mWidth = w;
mHeight = h;
calculatePath();
}
}
private void calculatePath() {
mPathStroke.rewind();
mPathFill.rewind();
if ((mPerformanceItems != null) && (mPerformanceItems.size() >= 2)) {
calculateScale();
float centerY = scaleY(getCenterValue());
int startIndex = mHourly ? 0 : 1;
PerformanceItem performanceItem = mPerformanceItems.get(startIndex);
float xScaleValue = scaleX(mHourly ? performanceItem.getTimestamp().getTime() : 0);
float yScaleValue = scaleY(getPerformanceItemValue(performanceItem).getMicroCents());
mPathStroke.moveTo(xScaleValue, yScaleValue);
mPathFill.moveTo(xScaleValue, centerY);
mPathFill.lineTo(xScaleValue, yScaleValue);
for (int x = startIndex; x < mPerformanceItems.size() - 1; x++) {
PerformanceItem nextPerformanceItem = mPerformanceItems.get(x+1);
float xScaleValueNext = scaleX(mHourly ? nextPerformanceItem.getTimestamp().getTime() : x);
float yScaleValueNext = scaleY(getPerformanceItemValue(nextPerformanceItem).getMicroCents());
if (mHourly) {
mPathStroke.quadTo(xScaleValue, yScaleValue, xScaleValueNext, yScaleValueNext);
mPathFill.quadTo(xScaleValue, yScaleValue, xScaleValueNext, yScaleValueNext);
} else {
mPathStroke.lineTo(xScaleValueNext, yScaleValue);
mPathStroke.lineTo(xScaleValueNext, yScaleValueNext);
mPathFill.lineTo(xScaleValueNext, yScaleValue);
mPathFill.lineTo(xScaleValueNext, yScaleValueNext);
}
xScaleValue = xScaleValueNext;
yScaleValue = yScaleValueNext;
}
int lastPos = mPerformanceItems.size() - 1;
performanceItem = mPerformanceItems.get(lastPos);
xScaleValue = scaleX(mHourly ? performanceItem.getTimestamp().getTime() : lastPos);
yScaleValue = scaleY(getPerformanceItemValue(performanceItem).getMicroCents());
mPathStroke.lineTo(xScaleValue, yScaleValue);
mPathFill.lineTo(xScaleValue, yScaleValue);
mPathFill.lineTo(xScaleValue, centerY);
PathMeasure measure = new PathMeasure(mPathStroke, false);
mPathLengthStroke = measure.getLength();
Shader shader = new LinearGradient(0, GRAPH_PADDING_VERTICAL,
0, mHeight - GRAPH_PADDING_VERTICAL,
LINEAR_GRADIENT_COLORS_STROKE,
LINEAR_GRADIENT_POSITIONS_STROKE,
Shader.TileMode.CLAMP);
mPathPaintStroke.setShader(shader);
shader = new LinearGradient(0, GRAPH_PADDING_VERTICAL,
0, mHeight - GRAPH_PADDING_VERTICAL,
LINEAR_GRADIENT_COLORS_FILL,
LINEAR_GRADIENT_POSITIONS_FILL,
Shader.TileMode.CLAMP);
mPathPaintFill.setShader(shader);
}
}
private void calculateScale() {
if ((mWidth != 0) && (mHeight != 0)) {
long initialValue = mPerformanceItems.get(0).getValue().getMicroCents();
long centerValue = getCenterValue();
// set the Y range with room to accommodate the max gain or loss
long absMaxY = Math.abs(mMaxYValue - centerValue);
long absMinY = Math.abs(mMinYValue - centerValue);
long deltaY = (absMaxY > absMinY) ? 2 * absMaxY : 2 * absMinY;
// set the min scale to 1% of current value.
long minDeltaY = (long) (.01f * initialValue);
if (deltaY < minDeltaY) {
deltaY = minDeltaY;
}
mScaleY = (float) (mHeight-1) / deltaY;
mOffsetY = mHourly ? 0 : initialValue;
if (mHourly) {
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles"));
// get market start time on same day of first x
long firstX = mPerformanceItems.get(mHourly ? 0 : 1).getTimestamp().getTime();
cal.setTimeInMillis(firstX);
cal.set(Calendar.HOUR_OF_DAY, 6);
cal.set(Calendar.MINUTE, 30);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
mMarketStartTime = cal.getTimeInMillis();
// set the start to 30 mins b4 market close
cal.set(Calendar.MINUTE, 0);
long startScaleX = cal.getTimeInMillis();
// get the market end tme
cal.set(Calendar.HOUR_OF_DAY, 13);
cal.set(Calendar.MINUTE, 0);
mMarketEndTime = cal.getTimeInMillis();
// set the end scale to 30 mins after market close
cal.set(Calendar.MINUTE, 30);
long endScaleX = cal.getTimeInMillis();
// see if there is a sample after the market close and extend the end if so
long lastX = mPerformanceItems.get(mPerformanceItems.size() - 1).getTimestamp().getTime();
if (endScaleX < lastX) {
endScaleX = lastX;
}
mScaleX = mWidth / (float) (endScaleX - startScaleX);
mOffsetX = startScaleX;
} else {
mScaleX = mWidth / (float) (mPerformanceItems.size() - 1);
mOffsetX = 0;
}
}
}
public void animateGraph() {
ValueAnimator va = ValueAnimator.ofFloat(0, 1);
va.setDuration(ANIMATION_DURATION_MS);
va.setInterpolator(new DecelerateInterpolator());
va.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
public void onAnimationUpdate(ValueAnimator animation) {
Float percentage = (Float) animation.getAnimatedValue();
// change the path effect to determine how much of path to render
float visibleLength = mPathLengthStroke * percentage;
mPathPaintStroke.setPathEffect(new DashPathEffect(new float[]{visibleLength, mPathLengthStroke - visibleLength}, 0));
invalidate();
}
});
va.start();
}
public void bind(List<PerformanceItem> performanceItems, boolean hourly) {
mPerformanceItems = performanceItems;
mHourly = hourly;
mMaxYValue = Long.MIN_VALUE;
mMinYValue = Long.MAX_VALUE;
for (int idx = 0; idx < mPerformanceItems.size(); idx++) {
PerformanceItem performanceItem = mPerformanceItems.get(idx);
long y = getPerformanceItemValue(performanceItem).getMicroCents();
if (y > mMaxYValue) {
mMaxYValue = y;
}
if (y < mMinYValue) {
mMinYValue = y;
}
}
calculatePath();
animateGraph();
}
private float scaleX(float x) {
return ((x - mOffsetX) * mScaleX);
}
private float scaleY(float y) {
return mHeight / 2.0f - ((y - mOffsetY) * mScaleY);
}
/**
* This function will be used to abstract out which value to graph
*/
private Money getPerformanceItemValue(PerformanceItem performanceItem) {
Money money;
if (mHourly) {
money = performanceItem.getTodayChange();
} else {
money = performanceItem.getValue();
}
return money;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
boolean handled = false;
float eventX = event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
if (!mAllowMove) {
break;
} // else fallthrow
case MotionEvent.ACTION_DOWN: {
Money money;
Date timestamp;
long xVal = (long) (eventX / mScaleX) + mOffsetX;
if (mHourly) {
timestamp = new Date(xVal);
money = extrapolateValue(timestamp.getTime());
} else {
int index = (int) xVal + 1;
if (index < mPerformanceItems.size()) {
PerformanceItem performanceItem = mPerformanceItems.get(index);
timestamp = performanceItem.getTimestamp();
money = getPerformanceItemValue(performanceItem);
} else {
return true;
}
}
if (mExaminerRect == null) {
mExaminerRect = new RectF();
}
mExaminerRect.set(eventX, GRAPH_PADDING_VERTICAL, eventX + EXAMINER_WIDTH, mHeight - GRAPH_PADDING_VERTICAL);
mExaminerTime = mHourly ? HOURLY_DATE_FORMAT.format(timestamp) : DAILY_DATE_FORMAT.format(timestamp);
mExaminerTimePaint.getTextBounds(mExaminerTime, 0, mExaminerTime.length(), mExaminerTimeTextBounds);
mExaminerValue = "";
if (money != null) {
mExaminerValue = money.getFormatted();
mExaminerValuePaint.setColor((money.getMicroCents() >= 0) ? Color.GREEN : Color.RED);
}
mExaminerValuePaint.getTextBounds(mExaminerValue, 0, mExaminerValue.length(), mExaminerValueTextBounds);
handled = true;
break;
}
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mExaminerRect = null;
handled = true;
break;
}
if (handled) {
invalidate();
}
return handled;
}
private Money extrapolateValue(long timestamp) {
Money money = null;
if (mPerformanceItems.size() > 2) {
for (int x = 0; x < mPerformanceItems.size(); x++) {
PerformanceItem performanceItem = mPerformanceItems.get(x);
if (timestamp <= performanceItem.getTimestamp().getTime()) {
if ((timestamp == performanceItem.getTimestamp().getTime()) || (x == 0)) {
money = getPerformanceItemValue(performanceItem);
} else {
PerformanceItem prevPerformanceItem = mPerformanceItems.get(x - 1);
long deltaY = getPerformanceItemValue(performanceItem).getMicroCents() -
getPerformanceItemValue(prevPerformanceItem).getMicroCents();
long deltaX = performanceItem.getTimestamp().getTime() -
prevPerformanceItem.getTimestamp().getTime();
money = new Money((deltaY * (timestamp - prevPerformanceItem.getTimestamp().getTime())) / deltaX +
getPerformanceItemValue(prevPerformanceItem).getMicroCents());
}
break;
}
}
if (money == null) {
money = getPerformanceItemValue(mPerformanceItems.get(mPerformanceItems.size() - 1));
}
}
return money;
}
}