Correction to PeriodAxis and improve performance

Discussion about JFreeChart related to stockmarket charts.
Locked
Celso
Posts: 10
Joined: Mon Feb 16, 2009 6:39 pm

Correction to PeriodAxis and improve performance

Post by Celso » Sun Mar 01, 2009 7:52 pm

I extended class PeriodAxis and I made some corrections in it.

1st: draw method: I put some verification before drawing somethings
2nd: using periods to calculate and put the divisors at correct position
3rd: improving performance using two class variables, firstMillisecond and lastMillisecond, call this method this.getFirst().getFirstMillisecond(this.calendar) all the time is terrible to performance

Take a look at the picture.

http://picasaweb.google.com.br/ricardo. ... 5919896594

Code: Select all

package br.com.crmsoft.finstock.chart.axis;

import static java.util.Calendar.MONTH;
import static org.jfree.chart.axis.PeriodAxisLabelInfo.DEFAULT_DIVIDER_PAINT;
import static org.jfree.chart.axis.PeriodAxisLabelInfo.DEFAULT_DIVIDER_STROKE;
import static org.jfree.chart.axis.PeriodAxisLabelInfo.DEFAULT_INSETS;
import static org.jfree.chart.axis.PeriodAxisLabelInfo.DEFAULT_LABEL_PAINT;

import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.geom.Line2D;
import java.awt.geom.Rectangle2D;
import java.lang.reflect.Constructor;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;

import org.jfree.chart.axis.AxisState;
import org.jfree.chart.axis.PeriodAxis;
import org.jfree.chart.axis.PeriodAxisLabelInfo;
import org.jfree.chart.event.AxisChangeEvent;
import org.jfree.chart.plot.Plot;
import org.jfree.chart.plot.PlotRenderingInfo;
import org.jfree.chart.plot.ValueAxisPlot;
import org.jfree.data.Range;
import org.jfree.data.time.Day;
import org.jfree.data.time.Month;
import org.jfree.data.time.RegularTimePeriod;
import org.jfree.data.time.Year;
import org.jfree.text.TextUtilities;
import org.jfree.ui.RectangleEdge;
import org.jfree.ui.TextAnchor;

/**
 * <p>Esta classe foi estendida com o intuito de melhorar a performance, algumas validações antes de renderizar na tela e
 * ajuste das datas que serão mostradas na Axis. Antes estava mostrando em local errado os divisores</p>
 * 
 * @author Celso Ricardo - CRMSoft Projetos e Consultoria
 * @version $Id$
 */
// FIXME Verificar como excluir as datas que não estão no Dataset
public class FinStockPeriodAxis extends PeriodAxis {

	private static final long serialVersionUID = -7679349583579345374L;

	protected long firstMillisecond;

	protected long lastMillisecond;

	protected Calendar calendar;

	public FinStockPeriodAxis(String label, RegularTimePeriod first, RegularTimePeriod last, TimeZone timeZone) {
		super(label);

		this.calendar = Calendar.getInstance(timeZone);

		this.setFirst(first);
		this.setLast(last);
		this.setTimeZone(timeZone);
		this.setAutoRangeTimePeriodClass(first.getClass());
		this.setMajorTickTimePeriodClass(first.getClass());
		this.setMinorTickMarksVisible(false);
		this.setMinorTickTimePeriodClass(RegularTimePeriod.downsize(this.getMajorTickTimePeriodClass()));
		this.setAutoRange(true);

		PeriodAxisLabelInfo labelInfo[] = new PeriodAxisLabelInfo[2];

		labelInfo[0] = new PeriodAxisLabelInfo(Month.class, new SimpleDateFormat("MMM"), DEFAULT_INSETS, new Font("SansSerif",
				Font.PLAIN, 8), DEFAULT_LABEL_PAINT, true, DEFAULT_DIVIDER_STROKE, DEFAULT_DIVIDER_PAINT);
		labelInfo[1] = new PeriodAxisLabelInfo(Year.class, new SimpleDateFormat("yyyy"), DEFAULT_INSETS, new Font("SansSerif",
				Font.BOLD, 10), DEFAULT_LABEL_PAINT, true, DEFAULT_DIVIDER_STROKE, DEFAULT_DIVIDER_PAINT);

		this.setLabelInfo(labelInfo);
	}

	public FinStockPeriodAxis(String label, RegularTimePeriod first, RegularTimePeriod last) {
		this(label, first, last, TimeZone.getDefault());
	}

	public FinStockPeriodAxis(String label) {
		this(label, new Day(), new Day());
	}

	@Override
	public void setFirst(RegularTimePeriod first) {
		super.setFirst(first);

		this.firstMillisecond = first.getFirstMillisecond(this.calendar);
	}

	@Override
	public void setLast(RegularTimePeriod last) {
		super.setLast(last);

		this.lastMillisecond = last.getLastMillisecond(this.calendar);
	}

	/**
	 * Returns the range for the axis.
	 * 
	 * @return The axis range (never <code>null</code>).
	 */
	public Range getRange() {
		return new Range(this.firstMillisecond, this.lastMillisecond);
	}

	/**
	 * Sets the range for the axis, if requested, sends an {@link AxisChangeEvent} to all registered listeners. As a side-effect,
	 * the auto-range flag is set to <code>false</code> (optional).
	 * 
	 * @param range
	 *            the range (<code>null</code> not permitted).
	 * @param turnOffAutoRange
	 *            a flag that controls whether or not the auto range is turned off.
	 * @param notify
	 *            a flag that controls whether or not listeners are notified.
	 */
	@Override
	public void setRange(Range range, boolean turnOffAutoRange, boolean notify) {
		super.setRange(range, turnOffAutoRange, false);

		this.firstMillisecond = this.getFirst().getFirstMillisecond(this.calendar);
		this.lastMillisecond = this.getLast().getLastMillisecond(this.calendar);

		if (notify) {
				notifyListeners(new AxisChangeEvent(this));
		}
	}

	/**
	 * Draws the axis on a Java 2D graphics device (such as the screen or a printer).
	 * 
	 * @param g2
	 *            the graphics device (<code>null</code> not permitted).
	 * @param cursor
	 *            the cursor location (determines where to draw the axis).
	 * @param plotArea
	 *            the area within which the axes and plot should be drawn.
	 * @param dataArea
	 *            the area within which the data should be drawn.
	 * @param edge
	 *            the axis location (<code>null</code> not permitted).
	 * @param plotState
	 *            collects information about the plot (<code>null</code> permitted).
	 * 
	 * @return The axis state (never <code>null</code>).
	 */
	public AxisState draw(Graphics2D g2, double cursor, Rectangle2D plotArea, Rectangle2D dataArea, RectangleEdge edge,
			PlotRenderingInfo plotState) {

		AxisState axisState = new AxisState(cursor);
		if (isAxisLineVisible()) {
			drawAxisLine(g2, cursor, dataArea, edge);
		}

		if (this.isTickMarksVisible()) {
			this.drawTickMarks(g2, axisState, dataArea, edge);
		}

		if (this.isTickLabelsVisible()) {
			for (int band = 0; band < this.getLabelInfo().length; band++) {
				axisState = this.drawTickLabels(band, g2, axisState, dataArea, edge);
			}
		}

		// draw the axis label (note that 'state' is passed in *and*
		// returned)...
		axisState = drawLabel(getLabel(), g2, plotArea, dataArea, edge, axisState);
		return axisState;

	}

	/**
	 * Draws the tick labels for one "band" of time periods.
	 * 
	 * @param band
	 *            the band index (zero-based).
	 * @param g2
	 *            the graphics device.
	 * @param state
	 *            the axis state.
	 * @param dataArea
	 *            the data area.
	 * @param edge
	 *            the edge where the axis is located.
	 * 
	 * @return The updated axis state.
	 */
	protected AxisState drawTickLabels(int band, Graphics2D g2, AxisState state, Rectangle2D dataArea, RectangleEdge edge) {

		// work out the initial gap
		double delta1 = 0.0;
		PeriodAxisLabelInfo labelInfo = this.getLabelInfo()[band];
		FontMetrics fm = g2.getFontMetrics(labelInfo.getLabelFont());
		if (edge == RectangleEdge.BOTTOM) {
			delta1 = labelInfo.getPadding().calculateTopOutset(fm.getHeight());
		} else if (edge == RectangleEdge.TOP) {
			delta1 = labelInfo.getPadding().calculateBottomOutset(fm.getHeight());
		}
		state.moveCursor(delta1, edge);
		long axisMin = this.firstMillisecond;
		long axisMax = this.lastMillisecond;
		g2.setFont(labelInfo.getLabelFont());
		g2.setPaint(labelInfo.getLabelPaint());

		// work out the number of periods to skip for labelling
		RegularTimePeriod p1 = labelInfo.createInstance(new Date(axisMin), this.getTimeZone());
		RegularTimePeriod p2 = labelInfo.createInstance(new Date(axisMax), this.getTimeZone());
		String label1 = labelInfo.getDateFormat().format(new Date(p1.getMiddleMillisecond(this.calendar)));
		String label2 = labelInfo.getDateFormat().format(new Date(p2.getMiddleMillisecond(this.calendar)));
		Rectangle2D b1 = TextUtilities.getTextBounds(label1, g2, g2.getFontMetrics());
		Rectangle2D b2 = TextUtilities.getTextBounds(label2, g2, g2.getFontMetrics());
		double w = Math.max(b1.getWidth(), b2.getWidth());
		long ww = Math.round(java2DToValue(dataArea.getX() + w + 5.0, dataArea, edge));
		if (isInverted()) {
			ww = axisMax - ww;
		} else {
			ww = ww - axisMin;
		}
		long length = p1.getLastMillisecond(this.calendar) - p1.getFirstMillisecond(this.calendar);
		int periods = (int) (ww / length) + 1;

		RegularTimePeriod p = labelInfo.createInstance(new Date(axisMin), this.getTimeZone());
		Rectangle2D b = null;
		long lastXX = 0L;
		float y = (float) (state.getCursor());
		TextAnchor anchor = TextAnchor.TOP_CENTER;
		float yDelta = (float) b1.getHeight();
		if (edge == RectangleEdge.TOP) {
			anchor = TextAnchor.BOTTOM_CENTER;
			yDelta = -yDelta;
		}

		// Flag para verificar se é a primeira vez no loop
		boolean firstTimeAtLoop = true;

		while (p.getFirstMillisecond(this.calendar) <= axisMax) {
			long firstMillisecond = p.getFirstMillisecond(this.calendar);

			// Verifica se o mês é par, para que o divisor do ano não fique em um mês que não seja janeiro
			Calendar c = Calendar.getInstance(this.getTimeZone());
			c.setTime(new Date(firstMillisecond));
			if (firstTimeAtLoop && c.get(MONTH) % 2 != 0) {
				p = p.next();
				firstMillisecond = p.getFirstMillisecond(this.calendar);
			}
			// Seta falso na flag para não entrar novamente no if acima
			firstTimeAtLoop = false;

			long lastMillisecond = 0L;

			// Código abaixo recupera o último milesegundo de acordo com a quantidade do periodo
			int iPeriod = 1;
			do {

				lastMillisecond = p.getLastMillisecond(this.calendar);
				iPeriod++;
				p = p.next();
			} while (iPeriod <= periods);

			// Calcula a média entre o inicio e fim, é feito isso no caso de estar agrupando 2 meses
			float x = (float) valueToJava2D(firstMillisecond + (lastMillisecond - firstMillisecond) / 2, dataArea, edge);

			// Volta os períodos passados para recuperar o milesegundo final
			for (int j = 0; j <= periods - 1; j++) {
				p = p.previous();
			}

			DateFormat df = labelInfo.getDateFormat();
			String label = df.format(new Date(p.getMiddleMillisecond(this.calendar)));
			long first = firstMillisecond;
			long last = lastMillisecond;
			if (last > axisMax) {
				// this is the last period, but it is only partially visible
				// so check that the label will fit before displaying it...
				Rectangle2D bb = TextUtilities.getTextBounds(label, g2, g2.getFontMetrics());
				if ((x + bb.getWidth() / 2) > dataArea.getMaxX()) {
					float xstart = (float) valueToJava2D(Math.max(first, axisMin), dataArea, edge);
					if (bb.getWidth() < (dataArea.getMaxX() - xstart)) {
						x = ((float) dataArea.getMaxX() + xstart) / 2.0f;
					} else {
						label = null;
					}
				}
			}
			if (first < axisMin) {
				// this is the first period, but it is only partially visible
				// so check that the label will fit before displaying it...
				Rectangle2D bb = TextUtilities.getTextBounds(label, g2, g2.getFontMetrics());
				if ((x - bb.getWidth() / 2) < dataArea.getX()) {
					float xlast = (float) valueToJava2D(Math.min(last, axisMax), dataArea, edge);
					if (bb.getWidth() < (xlast - dataArea.getX())) {
						x = (xlast + (float) dataArea.getX()) / 2.0f;
					} else {
						label = null;
					}
				}

			}
			if (label != null) {
				g2.setPaint(labelInfo.getLabelPaint());
				b = TextUtilities.drawAlignedString(label, g2, x, y, anchor);
			}
			if (lastXX > 0L) {
				if (labelInfo.getDrawDividers()) {
					long nextXX = p.getFirstMillisecond(this.calendar);
					long mid = (lastXX + nextXX) / 2;
					float mid2d = (float) valueToJava2D(mid, dataArea, edge);
					g2.setStroke(labelInfo.getDividerStroke());
					g2.setPaint(labelInfo.getDividerPaint());
					g2.draw(new Line2D.Float(mid2d, y, mid2d, y + yDelta));
				}
			}
			lastXX = last;
			for (int i = 0; i < periods; i++) {
				p = p.next();
			}
		}
		double used = 0.0;
		if (b != null) {
			used = b.getHeight();
			// work out the trailing gap
			if (edge == RectangleEdge.BOTTOM) {
				used += labelInfo.getPadding().calculateBottomOutset(fm.getHeight());
			} else if (edge == RectangleEdge.TOP) {
				used += labelInfo.getPadding().calculateTopOutset(fm.getHeight());
			}
		}
		state.moveCursor(used, edge);
		return state;
	}

	/**
	 * Converts a data value to a coordinate in Java2D space, assuming that the axis runs along one edge of the specified
	 * dataArea.
	 * <p>
	 * Note that it is possible for the coordinate to fall outside the area.
	 * 
	 * @param value
	 *            the data value.
	 * @param area
	 *            the area for plotting the data.
	 * @param edge
	 *            the edge along which the axis lies.
	 * 
	 * @return The Java2D coordinate.
	 */
	public double valueToJava2D(double value, Rectangle2D area, RectangleEdge edge) {

		double result = Double.NaN;
		double axisMin = this.firstMillisecond;
		double axisMax = this.lastMillisecond;
		if (RectangleEdge.isTopOrBottom(edge)) {
			double minX = area.getX();
			double maxX = area.getMaxX();
			if (isInverted()) {
				result = maxX + ((value - axisMin) / (axisMax - axisMin)) * (minX - maxX);
			} else {
				result = minX + ((value - axisMin) / (axisMax - axisMin)) * (maxX - minX);
			}
		} else if (RectangleEdge.isLeftOrRight(edge)) {
			double minY = area.getMinY();
			double maxY = area.getMaxY();
			if (isInverted()) {
				result = minY + (((value - axisMin) / (axisMax - axisMin)) * (maxY - minY));
			} else {
				result = maxY - (((value - axisMin) / (axisMax - axisMin)) * (maxY - minY));
			}
		}
		return result;
	}

	/**
	 * Converts a coordinate in Java2D space to the corresponding data value, assuming that the axis runs along one edge of the
	 * specified dataArea.
	 * 
	 * @param java2DValue
	 *            the coordinate in Java2D space.
	 * @param area
	 *            the area in which the data is plotted.
	 * @param edge
	 *            the edge along which the axis lies.
	 * 
	 * @return The data value.
	 */
	public double java2DToValue(double java2DValue, Rectangle2D area, RectangleEdge edge) {

		double result = Double.NaN;
		double min = 0.0;
		double max = 0.0;
		double axisMin = this.firstMillisecond;
		double axisMax = this.lastMillisecond;
		if (RectangleEdge.isTopOrBottom(edge)) {
			min = area.getX();
			max = area.getMaxX();
		} else if (RectangleEdge.isLeftOrRight(edge)) {
			min = area.getMaxY();
			max = area.getY();
		}
		if (isInverted()) {
			result = axisMax - ((java2DValue - min) / (max - min) * (axisMax - axisMin));
		} else {
			result = axisMin + ((java2DValue - min) / (max - min) * (axisMax - axisMin));
		}
		return result;
	}

	/**
	 * Rescales the axis to ensure that all data is visible.
	 */
	protected void autoAdjustRange() {

		Plot plot = getPlot();
		if (plot == null) {
			return; // no plot, no data
		}

		if (plot instanceof ValueAxisPlot) {
			ValueAxisPlot vap = (ValueAxisPlot) plot;

			Range r = vap.getDataRange(this);
			if (r == null) {
				r = getDefaultAutoRange();
			}

			long upper = Math.round(r.getUpperBound());
			long lower = Math.round(r.getLowerBound());
			
			double range = upper - lower;
			long minRange = (long) getAutoRangeMinimumSize();
            if (range < minRange) {
                long expand = (long) (minRange - range) / 2;
                upper = upper + expand;
                lower = lower - expand;
            }
            upper = upper + (long) (range * getUpperMargin());
            lower = lower - (long) (range * getLowerMargin());
			
			this.setFirst(this.createInstance(this.getAutoRangeTimePeriodClass(), new Date(lower), this.getTimeZone()));
			this.setLast(this.createInstance(this.getAutoRangeTimePeriodClass(), new Date(upper), this.getTimeZone()));
			r = new Range(lower, upper);
            setRange(r, false, false);
		}
	}

	/**
	 * A utility method used to create a particular subclass of the {@link RegularTimePeriod} class that includes the specified
	 * millisecond, assuming the specified time zone.
	 * 
	 * @param periodClass
	 *            the class.
	 * @param millisecond
	 *            the time.
	 * @param zone
	 *            the time zone.
	 * 
	 * @return The time period.
	 */
	@SuppressWarnings("unchecked")
	protected RegularTimePeriod createInstance(Class periodClass, Date millisecond, TimeZone zone) {
		RegularTimePeriod result = null;
		try {
			Constructor c = periodClass.getDeclaredConstructor(new Class[] { Date.class, TimeZone.class });
			result = (RegularTimePeriod) c.newInstance(new Object[] { millisecond, zone });
		} catch (Exception e) {
			// do nothing
		}
		return result;
	}
}

david.gilbert
JFreeChart Project Leader
Posts: 11683
Joined: Fri Mar 14, 2003 10:29 am
antibot: No, of course not.
Contact:

Re: Correction to PeriodAxis and improve performance

Post by david.gilbert » Tue Mar 03, 2009 11:09 am

Thanks. I committed changes to Subversion for 1 and 3, the latter in a slightly different way to the way you did it (the RegularTimePeriod classes can already cache the millisecond values, so I just made better use of that). I wasn't quite sure what 2 was doing...perhaps you could explain it some more.
David Gilbert
JFreeChart Project Leader

:idea: Read my blog
:idea: Ask your company to buy the JFreeChart Developer Guide
:idea: Check out other products sold by my company Object Refinery Limited

Celso
Posts: 10
Joined: Mon Feb 16, 2009 6:39 pm

Re: Correction to PeriodAxis and improve performance

Post by Celso » Tue Mar 03, 2009 4:39 pm

Hi David,

take a look at those screenshots:

http://picasaweb.google.com.br/ricardo. ... 0976390434
http://picasaweb.google.com.br/ricardo. ... 5919896594

I´ve made the following code to correct the location of divisors of MONTH and YEAR, but now the Jan and Year divisors can be drawn at the same "X".

Flag to verify if it´s the first time at loop

Code: Select all

// Flag para verificar se é a primeira vez no loop
boolean firstTimeAtLoop = true;
If the month of the firstMillisecond is "odd", get the next period (MONTH) and get the firstMillisecond of that then set firstTimeAtLoop to false

Code: Select all

		while (p.getFirstMillisecond(this.calendar) <= axisMax) {
			long firstMillisecond = p.getFirstMillisecond(this.calendar);

			// Verifica se o mês é par, para que o divisor do ano não fique em um mês que não seja janeiro
			Calendar c = Calendar.getInstance(this.getTimeZone());
			c.setTime(new Date(firstMillisecond));
			if (firstTimeAtLoop && c.get(MONTH) % 2 != 0) {
				p = p.next();
				firstMillisecond = p.getFirstMillisecond(this.calendar);
			}
			// Seta falso na flag para não entrar novamente no if acima
			firstTimeAtLoop = false;

			long lastMillisecond = 0L;
The following code retrieves the lastMillisecond according with the number of periods

Code: Select all

			// Código abaixo recupera o último milesegundo de acordo com a quantidade do periodo
			int iPeriod = 1;
			do {

				lastMillisecond = p.getLastMillisecond(this.calendar);
				iPeriod++;
				p = p.next();
			} while (iPeriod <= periods);
Get the middle value between first and last milliseconds and then returns the RegularTimePeriod "n" periods

Code: Select all

			// Calcula a média entre o inicio e fim, é feito isso no caso de estar agrupando 2 meses
			float x = (float) valueToJava2D(firstMillisecond + (lastMillisecond - firstMillisecond) / 2, dataArea, edge);

			// Volta os períodos passados para recuperar o milesegundo final
			for (int j = 0; j <= periods - 1; j++) {
				p = p.previous();
			}

Locked