LineChart.java

package de.slothsoft.charts.linechart;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

import de.slothsoft.charts.Area;
import de.slothsoft.charts.Chart;
import de.slothsoft.charts.ChartPart;
import de.slothsoft.charts.GraphicContext;
import de.slothsoft.charts.PaintInstructions;
import de.slothsoft.charts.RefreshListener;

/**
 * This class represents a {@link Chart} that displays lines of some sort inside a
 * coordinate system. It's structure looks like this: <br>
 * <img src=
 * "https://raw.githubusercontent.com/wiki/slothsoft/charts/images/line-chart-structure.png"
 * alt="structure">
 *
 * @author Stef Schulz
 * @since 0.1.0
 */

public class LineChart extends Chart {

	final List<Line> lines = new ArrayList<>();

	Area lastGraphArea;

	private final XAxis xAxis = new XAxis(this);
	private final YAxis yAxis = new YAxis(this);
	private final RefreshListener refreshListener = e -> fireRefreshNeeded();

	private Area displayedArea;
	private double zoomFactor = 0.25;

	/**
	 * Default constructor.
	 */

	public LineChart() {
		addChartPart(this.xAxis);
		addChartPart(this.yAxis);
	}

	@Override
	public void paintOn(GraphicContext gc, PaintInstructions instructions) {
		this.lastGraphArea = instructions.getArea().copy();
		for (final ChartPart part : fetchChartParts()) {
			this.lastGraphArea = part.snipNecessarySpace(this.lastGraphArea);
		}
		super.paintOn(gc, instructions);
	}

	@Override
	protected void paintGraph(GraphicContext gc, PaintInstructions instructions) {
		final Area graphArea = calculateDisplayedArea();
		final double graphWidth = graphArea.getEndX() - graphArea.getStartX();
		final double graphHeight = graphArea.getEndY() - graphArea.getStartY();

		final double actualWidth = this.lastGraphArea.getEndX() - this.lastGraphArea.getStartX();
		final double actualHeight = this.lastGraphArea.getEndY() - this.lastGraphArea.getStartY();
		final double scaleX = actualWidth / graphWidth;
		final double scaleY = actualHeight / graphHeight;
		gc.scale(scaleX, scaleY);

		final PaintInstructions lineInstructions = instructions.copy().area(graphArea);
		final GraphicContext linesGc = new FlipYGraphicContext(gc);
		// the top left corner is not where the graph origin is, so move
		final double originX = -Math.min(graphArea.getStartX(), graphArea.getEndX()) * scaleX;
		final double originY = Math.max(graphArea.getStartY(), graphArea.getEndY()) * scaleY;
		gc.translate(originX, originY);

		for (final Line line : this.lines) {
			line.paintOn(linesGc, lineInstructions);
		}

		// reset everything that was done previously
		gc.translate(-originX, -originY);
		gc.scale(1 / scaleX, 1 / scaleY);
	}

	/**
	 * Calculates the displayed area via {@link #getDisplayedArea()} or the added lines.
	 *
	 * @return the displayed area
	 * @see #getDisplayedArea()
	 * @see #addLine(Line)
	 * @see #addLines(Line[])
	 * @since 0.2.0
	 */

	public Area calculateDisplayedArea() {
		if (this.displayedArea != null) return this.displayedArea;
		if (this.lines.isEmpty()) return Line.createDefaultArea();

		Area result = new Area();
		for (final Line line : this.lines) {
			result = result.unite(line.calculatePreferredArea());
		}
		return result;
	}

	/**
	 * Adds a line to the chart.
	 *
	 * @param line a line to add
	 */

	public void addLine(Line line) {
		line.addRefreshListener(this.refreshListener);
		this.lines.add(line);
		fireRefreshNeeded();
	}

	/**
	 * Adds some lines to the chart.
	 *
	 * @param addedLines lines to add
	 */

	public void addLines(Line... addedLines) {
		for (final Line addedLine : addedLines) {
			addedLine.addRefreshListener(this.refreshListener);
		}
		this.lines.addAll(Arrays.asList(addedLines));
		fireRefreshNeeded();
	}

	/**
	 * Removes a line from the chart.
	 *
	 * @param line a line to add
	 */

	public void removeLine(Line line) {
		line.removeRefreshListener(this.refreshListener);
		this.lines.remove(line);
		fireRefreshNeeded();
	}

	/**
	 * Removes some lines to the chart.
	 *
	 * @param removedLines lines to add
	 */

	public void removeLines(Line... removedLines) {
		for (final Line removedLine : removedLines) {
			removedLine.removeRefreshListener(this.refreshListener);
		}
		this.lines.removeAll(Arrays.asList(removedLines));
		fireRefreshNeeded();
	}

	/**
	 * Moves the displayed area of this {@link Chart} by the coordinates used for the
	 * entire chart. Let's say the chart is painted on an area 1000x1000 pixels, but the
	 * graph only displays something between the coordinates 0 and 1. If you move
	 * 100pixels in the chart scale, you only need to move the graph 0.1 points.
	 *
	 * @param xIncrement the x movement
	 * @param yIncrement the y movement
	 * @exception IllegalArgumentException if graph was never painted before
	 */

	public void moveDisplayedAreaByChartCoordinates(double xIncrement, double yIncrement) {
		requireLastGraphAreaNotNull();
		final Area wantedArea = calculateDisplayedArea();

		final double scaleX = (this.lastGraphArea.getEndX() - this.lastGraphArea.getStartX())
				/ (wantedArea.getEndX() - wantedArea.getStartX());
		final double scaleY = (this.lastGraphArea.getEndY() - this.lastGraphArea.getStartY())
				/ (wantedArea.getEndY() - wantedArea.getStartY());

		moveDisplayedAreaDirectly(xIncrement / scaleX, yIncrement / scaleY);
	}

	private void requireLastGraphAreaNotNull() {
		if (this.lastGraphArea == null)
			throw new IllegalArgumentException(
					"You need to paint the graph at least once before you can move it with this method!");
	}

	/**
	 * Moves the displayed area of this {@link Chart} directly, i.e. adds the movement
	 * coordinates to the already existing ones.
	 *
	 * @param xIncrement the x movement
	 * @param yIncrement the y movement
	 * @see #moveDisplayedAreaByChartCoordinates(double, double)
	 */

	void moveDisplayedAreaDirectly(double xIncrement, double yIncrement) {
		if (xIncrement == 0 && yIncrement == 0) return;
		final Area newArea = calculateDisplayedArea().copy();
		newArea.move(xIncrement, yIncrement);
		setDisplayedArea(newArea);
	}

	/**
	 * Zooms the graph area in using the chart's coordinates.
	 *
	 * @param chartX the x coordinate in the chart's coordinate system
	 * @param chartY the y coordinate in the chart's coordinate system
	 * @see #convertToGraphCoordinates(double, double)
	 * @see #convertToGraphX(double)
	 * @see #convertToGraphY(double)
	 * @see #zoomDisplayedAreaOutByChartCoordinates(double, double)
	 */

	public void zoomDisplayedAreaInByChartCoordinates(double chartX, double chartY) {
		zoomDisplayedAreaInByGraphCoordinates(convertToGraphX(chartX), convertToGraphY(chartY));
	}

	/**
	 * Zooms the graph area in using the graph's coordinates.
	 *
	 * @param graphX the x coordinate in the graph's coordinate system
	 * @param graphY the y coordinate in the graph's coordinate system
	 * @see #convertToGraphCoordinates(double, double)
	 * @see #convertToGraphX(double)
	 * @see #convertToGraphY(double)
	 * @see #zoomDisplayedAreaOutByGraphCoordinates(double, double)
	 */

	public void zoomDisplayedAreaInByGraphCoordinates(double graphX, double graphY) {
		zoom(graphX, graphY, true);
	}

	private void zoom(double graphX, double graphY, boolean in) {
		final Area wantedArea = calculateDisplayedArea();

		final double width = wantedArea.getEndX() - wantedArea.getStartX();
		final double height = wantedArea.getEndY() - wantedArea.getStartY();

		final double beforeXScale = (graphX - wantedArea.getStartX()) / width;
		final double beforeYScale = (graphY - wantedArea.getStartY()) / height;

		double beforeXZoom = beforeXScale * this.zoomFactor * width;
		double afterXZoom = (1 - beforeXScale) * this.zoomFactor * width;

		double beforeYZoom = beforeYScale * this.zoomFactor * height;
		double afterYZoom = (1 - beforeYScale) * this.zoomFactor * height;

		if (!in) {
			beforeXZoom *= -1;
			afterXZoom *= -1;
			beforeYZoom *= -1;
			afterYZoom *= -1;
		}
		setDisplayedArea(new Area(wantedArea.getStartX() + beforeXZoom, wantedArea.getStartY() + beforeYZoom,
				wantedArea.getEndX() - afterXZoom, wantedArea.getEndY() - afterYZoom));
	}

	/**
	 * Zooms the graph area out using the chart's coordinates.
	 *
	 * @param chartX the x coordinate in the chart's coordinate system
	 * @param chartY the y coordinate in the chart's coordinate system
	 * @exception IllegalArgumentException if graph was never painted before
	 * @see #convertToGraphCoordinates(double, double)
	 * @see #convertToGraphX(double)
	 * @see #convertToGraphY(double)
	 * @see #zoomDisplayedAreaOutByChartCoordinates(double, double)
	 */

	public void zoomDisplayedAreaOutByChartCoordinates(double chartX, double chartY) {
		zoomDisplayedAreaOutByGraphCoordinates(convertToGraphX(chartX), convertToGraphY(chartY));
	}

	/**
	 * Zooms the graph area out using the graph's coordinates.
	 *
	 * @param graphX the x coordinate in the graph's coordinate system
	 * @param graphY the y coordinate in the graph's coordinate system
	 * @exception IllegalArgumentException if graph was never painted before
	 * @see #convertToGraphCoordinates(double, double)
	 * @see #convertToGraphX(double)
	 * @see #convertToGraphY(double)
	 * @see #zoomDisplayedAreaInByGraphCoordinates(double, double)
	 */

	public void zoomDisplayedAreaOutByGraphCoordinates(double graphX, double graphY) {
		zoom(graphX, graphY, false);
	}

	/**
	 * Converts chart coordinates to a graph ones. The chart is everything and x and y
	 * move from the top left to the bottom right. The graph is the area with the lines
	 * and moves from bottom left to top right.
	 *
	 * @param chartX the x coordinate in the chart's coordinate system
	 * @param chartY the y coordinate in the chart's coordinate system
	 * @exception IllegalArgumentException if graph was never painted before
	 * @see #convertToGraphX(double)
	 * @see #convertToGraphY(double)
	 */

	public double[] convertToGraphCoordinates(double chartX, double chartY) {
		return new double[]{convertToGraphX(chartX), convertToGraphY(chartY)};
	}

	/**
	 * Converts a chart coordinate to a graph one. The chart is everything and x and y
	 * move from the top left to the bottom right. The graph is the area with the lines
	 * and moves from bottom left to top right.
	 *
	 * @param chartX the x coordinate in the chart's coordinate system
	 * @exception IllegalArgumentException if graph was never painted before
	 * @see #convertToGraphCoordinates(double, double)
	 * @see #convertToGraphY(double)
	 */

	public double convertToGraphX(double chartX) {
		requireLastGraphAreaNotNull();
		final Area wantedArea = calculateDisplayedArea();

		final double scale = (wantedArea.getEndX() - wantedArea.getStartX())
				/ (this.lastGraphArea.getEndX() - this.lastGraphArea.getStartX());
		return scale * (chartX - this.lastGraphArea.getStartX()) + wantedArea.getStartX();
	}

	/**
	 * Converts a chart coordinate to a graph one. The chart is everything and x and y
	 * move from the top left to the bottom right. The graph is the area with the lines
	 * and moves from bottom left to top right.
	 *
	 * @param chartY the y coordinate in the chart's coordinate system
	 * @exception IllegalArgumentException if graph was never painted before
	 * @see #convertToGraphCoordinates(double, double)
	 * @see #convertToGraphX(double)
	 */

	public double convertToGraphY(double chartY) {
		requireLastGraphAreaNotNull();
		final Area wantedArea = calculateDisplayedArea();

		final double scale = (wantedArea.getEndY() - wantedArea.getStartY())
				/ (this.lastGraphArea.getEndY() - this.lastGraphArea.getStartY());
		return wantedArea.getEndY() - scale * (chartY - this.lastGraphArea.getStartY());
	}

	/**
	 * Converts a graph coordinate to a chart one. The chart is everything and x and y
	 * move from the top left to the bottom right. The graph is the area with the lines
	 * and moves from bottom left to top right.
	 *
	 * @param graphX the x coordinate in the graph's coordinate system
	 * @exception IllegalArgumentException if graph was never painted before
	 * @see #convertToChartY(double)
	 */

	public double convertToChartX(double graphX) {
		requireLastGraphAreaNotNull();
		final Area wantedArea = calculateDisplayedArea();

		final double scale = wantedArea.calculateWidth() / this.lastGraphArea.calculateWidth();
		return (graphX - wantedArea.getStartX()) / scale + this.lastGraphArea.getStartX();
	}

	/**
	 * Converts a graph coordinate to a chart one. The chart is everything and x and y
	 * move from the top left to the bottom right. The graph is the area with the lines
	 * and moves from bottom left to top right.
	 *
	 * @param graphY the y coordinate in the graph's coordinate system
	 * @exception IllegalArgumentException if graph was never painted before
	 * @see #convertToChartX(double)
	 */

	public double convertToChartY(double graphY) {
		requireLastGraphAreaNotNull();
		final Area wantedArea = calculateDisplayedArea();

		final double scale = wantedArea.calculateHeight() / this.lastGraphArea.calculateHeight();
		return (-graphY + wantedArea.getEndY()) / scale + this.lastGraphArea.getStartY();
	}

	/**
	 * Resets the displayed area.
	 *
	 * @see #setDisplayedArea(Area)
	 */

	public void resetDisplayedArea() {
		setDisplayedArea(null);
	}

	/**
	 * Returns the displayed area of this chart, i.e. the coordinates to display.
	 * <code>null</code> is used to indicate the value is calculated by questioning the
	 * {@link Line}s.
	 *
	 * @return the displayed area
	 */

	public Area getDisplayedArea() {
		return this.displayedArea;
	}

	/**
	 * Sets the displayed area of this chart, i.e. the coordinates to display.
	 * <code>null</code> is used to indicate the value is calculated by questioning the
	 * {@link Line}s.
	 *
	 * @param newDisplayedArea the displayed area
	 * @return this instance
	 */

	public LineChart displayedArea(Area newDisplayedArea) {
		setDisplayedArea(newDisplayedArea);
		return this;
	}

	/**
	 * Sets the displayed area of this chart, i.e. the coordinates to display.
	 * <code>null</code> is used to indicate the value is calculated by questioning the
	 * {@link Line}s.
	 *
	 * @param displayedArea the displayed area
	 */

	public void setDisplayedArea(Area displayedArea) {
		final Area oldDisplayedArea = this.displayedArea;
		this.displayedArea = displayedArea;
		if (!Objects.equals(displayedArea, oldDisplayedArea)) {
			fireRefreshNeeded();
		}
	}

	/**
	 * Returns the factor which should be used to zoom the graph area. A value of 0.25
	 * means it gets zoomed by 25%.
	 *
	 * @return the zoom factor
	 * @see #zoomDisplayedAreaInByChartCoordinates(double, double)
	 * @see #zoomDisplayedAreaInByGraphCoordinates(double, double)
	 * @see #zoomDisplayedAreaOutByChartCoordinates(double, double)
	 * @see #zoomDisplayedAreaOutByGraphCoordinates(double, double)
	 */

	public double getZoomFactor() {
		return this.zoomFactor;
	}

	/**
	 * Sets the factor which should be used to zoom the graph area. A value of 0.25 means
	 * it gets zoomed by 25%.
	 *
	 * @param newZoomFactor the zoom factor
	 * @return this instance
	 * @see #zoomDisplayedAreaInByChartCoordinates(double, double)
	 * @see #zoomDisplayedAreaInByGraphCoordinates(double, double)
	 * @see #zoomDisplayedAreaOutByChartCoordinates(double, double)
	 * @see #zoomDisplayedAreaOutByGraphCoordinates(double, double)
	 */

	public LineChart zoomFactor(double newZoomFactor) {
		setZoomFactor(newZoomFactor);
		return this;
	}

	/**
	 * Sets the factor which should be used to zoom the graph area. A value of 0.25 means
	 * it gets zoomed by 25%.
	 *
	 * @param zoomFactor the zoom factor
	 * @see #zoomDisplayedAreaInByChartCoordinates(double, double)
	 * @see #zoomDisplayedAreaInByGraphCoordinates(double, double)
	 * @see #zoomDisplayedAreaOutByChartCoordinates(double, double)
	 * @see #zoomDisplayedAreaOutByGraphCoordinates(double, double)
	 */

	public void setZoomFactor(double zoomFactor) {
		this.zoomFactor = zoomFactor;
	}

	/**
	 * Returns the x axis of this line chart.
	 *
	 * @return the x axis
	 */

	public XAxis getXAxis() {
		return this.xAxis;
	}

	/**
	 * Returns the y axis of this line chart.
	 *
	 * @return the y axis
	 */

	public YAxis getYAxis() {
		return this.yAxis;
	}
}