AbstractObservationPlotPane.java
/**
* VStar: a statistical analysis tool for variable star data.
* Copyright (C) 2009 AAVSO (http://www.aavso.org/)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.aavso.tools.vstar.ui.pane.plot;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JPanel;
import javax.swing.JTextArea;
import javax.swing.SwingUtilities;
import org.aavso.tools.vstar.data.SeriesType;
import org.aavso.tools.vstar.data.ValidObservation;
import org.aavso.tools.vstar.input.AbstractObservationRetriever;
import org.aavso.tools.vstar.ui.VStar;
import org.aavso.tools.vstar.ui.dialog.ObservationDetailsDialog;
import org.aavso.tools.vstar.ui.mediator.Mediator;
import org.aavso.tools.vstar.ui.mediator.message.ObservationSelectionMessage;
import org.aavso.tools.vstar.ui.mediator.message.PanRequestMessage;
import org.aavso.tools.vstar.ui.mediator.message.ZoomRequestMessage;
import org.aavso.tools.vstar.ui.mediator.message.ZoomType;
import org.aavso.tools.vstar.ui.model.plot.ObservationAndMeanPlotModel;
import org.aavso.tools.vstar.util.locale.LocaleProps;
import org.aavso.tools.vstar.util.notification.Listener;
import org.aavso.tools.vstar.util.prefs.ChartPropertiesPrefs;
import org.jfree.chart.ChartFactory;
import org.jfree.chart.ChartMouseEvent;
import org.jfree.chart.ChartMouseListener;
import org.jfree.chart.ChartPanel;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.StandardChartTheme;
import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.entity.EntityCollection;
import org.jfree.chart.entity.XYItemEntity;
import org.jfree.chart.plot.PlotOrientation;
import org.jfree.chart.plot.PlotRenderingInfo;
import org.jfree.chart.plot.SeriesRenderingOrder;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.renderer.xy.XYErrorRenderer;
import org.jfree.chart.title.TextTitle;
import org.jfree.chart.ui.RectangleInsets;
import org.jfree.data.Range;
import org.jfree.data.general.Dataset;
import org.jfree.data.general.DatasetChangeEvent;
import org.jfree.data.general.DatasetChangeListener;
/**
* This class is the base class for chart panes containing a plot of a set of
* valid observations. It is genericised on observation model.
*/
@SuppressWarnings("serial")
abstract public class AbstractObservationPlotPane<T extends ObservationAndMeanPlotModel> extends JPanel
implements ChartMouseListener, DatasetChangeListener {
protected T obsModel;
protected Dimension bounds;
protected String title;
protected String subTitle;
protected String domainTitle;
protected String rangeTitle;
protected AbstractObservationRetriever retriever;
protected JFreeChart chart;
protected ChartPanel chartPanel;
protected JPanel chartControlPanel;
protected JTextArea obsInfo;
protected VStarPlotDataRenderer renderer;
// Show error bars?
protected boolean showErrorBars;
// Show cross-hairs?
protected boolean showCrossHairs;
protected JButton visibilityButton;
// Last selected point and observation.
protected Point2D lastPointClicked;
protected ValidObservation lastObSelected;
protected Dataset lastDatasetSelected;
/**
* Constructor
*
* @param title The title for the chart.
* @param subTitle The sub-title for the chart.
* @param domainTitle The domain title (e.g. Julian Date, phase).
* @param rangeTitle The range title (e.g. magnitude).
* @param obsModel The data model to plot.
* @param bounds The bounding box to which to set the chart's preferred
* size.
* @param retriever The observation retriever for observations in this plot.
*/
public AbstractObservationPlotPane(String title, String subTitle, String domainTitle, String rangeTitle, T obsModel,
Dimension bounds, AbstractObservationRetriever retriever) {
super();
this.title = title;
this.subTitle = subTitle;
this.domainTitle = domainTitle;
this.rangeTitle = rangeTitle;
this.obsModel = obsModel;
this.bounds = bounds;
this.retriever = retriever;
this.setLayout(new BoxLayout(this, BoxLayout.PAGE_AXIS));
this.showErrorBars = true;
this.showCrossHairs = true;
// Create a chart with legend, tooltips, and URLs showing
// and add it to the panel.
this.chartPanel = new ChartPanel(ChartFactory.createScatterPlot(title, domainTitle, rangeTitle, obsModel,
PlotOrientation.VERTICAL, true, true, true));
this.chartPanel.setPreferredSize(bounds);
this.chart = chartPanel.getChart();
if (subTitle != null && "".equals(subTitle.trim())) {
this.chart.addSubtitle(new TextTitle(subTitle));
}
this.renderer = new VStarPlotDataRenderer();
this.renderer.setDrawYError(this.showErrorBars);
this.lastPointClicked = null;
this.lastObSelected = null;
this.lastDatasetSelected = null;
// Should reduce number of Java2D draw operations.
// this.renderer.setDrawSeriesLineAsPath(true);
// Tell renderer which series' elements should initially be
// rendered (i.e. visible) or joined.
// TODO: in future, we should isolate series joining logic to this view
// class and its subclasses; see also comments in
// ObservationAndMeanPlotModel.
setJoinedSeries();
setSeriesVisibility();
// this.chart.getXYPlot().setSeriesRenderingOrder(
// SeriesRenderingOrder.FORWARD);
this.chart.getXYPlot().setSeriesRenderingOrder(SeriesRenderingOrder.REVERSE);
this.chart.getXYPlot().setDomainCrosshairLockedOnData(true);
this.chart.getXYPlot().setRangeCrosshairLockedOnData(true);
// Make it possible to pan the plot.
chart.getXYPlot().setDomainPannable(true);
chart.getXYPlot().setRangePannable(true);
chart.getXYPlot().setRenderer(renderer);
setupCrossHairs();
setSeriesColors();
setSeriesSizes();
SeriesType.getSeriesColorChangeNotifier().addListener(createSeriesColorChangeListener());
SeriesType.getSeriesSizeChangeNotifier().addListener(createSeriesSizeChangeListener());
// We want the magnitude scale to go from high to low as we ascend the
// Y axis since as magnitude values get smaller, brightness increases.
NumberAxis rangeAxis = (NumberAxis) chart.getXYPlot().getRangeAxis();
rangeAxis.setInverted(true);
setMagScale();
obsModel.addChangeListener(this);
this.add(chartPanel);
this.add(Box.createRigidArea(new Dimension(0, 10)));
// Listen to events.
Mediator.getInstance().getObservationSelectionNotifier().addListener(createObservationSelectionListener());
Mediator.getInstance().getZoomRequestNotifier().addListener(createZoomRequestListener());
Mediator.getInstance().getPanRequestNotifier().addListener(createPanRequestListener());
// Update chart properties.
SwingUtilities.invokeLater(new Runnable() {
public void run() {
// Invoke in the UI thread (some paranoia)
updateChartProperties();
}
});
}
/**
* @return the chartPanel
*/
public ChartPanel getChartPanel() {
return chartPanel;
}
/**
* @return the chartControlPanel
*/
public JPanel getChartControlPanel() {
return chartControlPanel;
}
/**
* @return the obsInfo
*/
public JTextArea getObsInfo() {
return obsInfo;
}
/**
* @return the renderer
*/
public XYErrorRenderer getRenderer() {
return renderer;
}
/**
* @return the lastPointClicked
*/
public Point2D getLastPointClicked() {
return lastPointClicked;
}
/**
* @return the lastObSelected
*/
public ValidObservation getLastObSelected() {
return lastObSelected;
}
/**
* @return the lastDatasetSelected
*/
public Dataset getLastDatasetSelected() {
return lastDatasetSelected;
}
/**
* Returns a series color change listener.
*/
private Listener<Map<SeriesType, Color>> createSeriesColorChangeListener() {
return new Listener<Map<SeriesType, Color>>() {
// Update the series colors.
public void update(Map<SeriesType, Color> info) {
setSeriesColors();
}
public boolean canBeRemoved() {
return true;
}
};
}
/**
* Returns a series size change listener.
*/
private Listener<Map<SeriesType, Integer>> createSeriesSizeChangeListener() {
return new Listener<Map<SeriesType, Integer>>() {
// Update the series sizes.
public void update(Map<SeriesType, Integer> info) {
setSeriesSizes();
}
public boolean canBeRemoved() {
return true;
}
};
}
/**
* Was there a change in the series visibility? Some callers may want to invoke
* this only for its side effects, while others may also want to know the
* result.
*
* @param deltaMap A mapping from series number to whether or not each series'
* visibility was changed.
*
* @return Was there a change in the visibility of any series?
*/
protected boolean seriesVisibilityChange(Map<Integer, Boolean> deltaMap) {
boolean delta = false;
for (int seriesNum : deltaMap.keySet()) {
boolean visibility = deltaMap.get(seriesNum);
delta |= obsModel.changeSeriesVisibility(seriesNum, visibility);
}
return delta;
}
/**
* Set the visibility of each series.
*/
protected void setSeriesVisibility() {
Map<SeriesType, Boolean> seriesVisibilityMap = obsModel.getSeriesVisibilityMap();
for (SeriesType seriesType : seriesVisibilityMap.keySet()) {
int seriesNum = obsModel.getSrcTypeToSeriesNumMap().get(seriesType);
renderer.setSeriesVisible(seriesNum, seriesVisibilityMap.get(seriesType));
}
}
/**
* Set the color of each series.
*/
private void setSeriesColors() {
Map<Integer, SeriesType> seriesNumToTypeMap = obsModel.getSeriesNumToSrcTypeMap();
for (int seriesNum : seriesNumToTypeMap.keySet()) {
Color color = SeriesType.getColorFromSeries(seriesNumToTypeMap.get(seriesNum));
renderer.setSeriesPaint(seriesNum, color);
}
}
/**
* Set the size of each series.
*/
private void setSeriesSizes() {
Map<Integer, SeriesType> seriesNumToTypeMap = obsModel.getSeriesNumToSrcTypeMap();
for (int seriesNum : seriesNumToTypeMap.keySet()) {
int size = SeriesType.getSizeFromSeries(seriesNumToTypeMap.get(seriesNum));
renderer.setSeriesSize(seriesNum, size);
}
}
// Cross hair handling methods.
private void setupCrossHairs() {
chart.getXYPlot().setDomainCrosshairValue(0);
chart.getXYPlot().setRangeCrosshairValue(0);
chart.getXYPlot().setDomainCrosshairVisible(true);
chart.getXYPlot().setRangeCrosshairVisible(true);
chartPanel.addChartMouseListener(this);
}
/**
*
* Updates properties of the chart
*
*/
public void updateChartProperties() {
StandardChartTheme chartTheme = (StandardChartTheme)StandardChartTheme.createJFreeTheme();
try {
Font font;
font = ChartPropertiesPrefs.getChartExtraLargeFont();
if (font != null) chartTheme.setExtraLargeFont(font);
font = ChartPropertiesPrefs.getChartLargeFont();
if (font != null) chartTheme.setLargeFont(font);
font = ChartPropertiesPrefs.getChartRegularFont();
if (font != null) chartTheme.setRegularFont(font);
font = ChartPropertiesPrefs.getChartSmallFont();
if (font != null) chartTheme.setSmallFont(font);
chartTheme.apply(this.chart);
} catch(Exception e) {
// ignore error
}
// chartTheme.apply() resets colors and sizes.
// We need to restore them.
setSeriesColors();
setSeriesSizes();
XYPlot plot = this.chart.getXYPlot();
plot.setBackgroundPaint(ChartPropertiesPrefs.getChartBackgroundColor());
plot.setDomainGridlinePaint(ChartPropertiesPrefs.getChartGridlinesColor());
plot.setRangeGridlinePaint(ChartPropertiesPrefs.getChartGridlinesColor());
this.chart.setPadding(new RectangleInsets(0, 0, 0, 30));
}
// From ChartMouseListener interface.
// If the user double-clicks on a plot point, send a selection
// message and open an information dialog. Also record the selection.
public void chartMouseClicked(ChartMouseEvent event) {
// Now, have we selected an observation?
if (event.getEntity() instanceof XYItemEntity) {
// The trigger point should correspond to the XYItemEntity's
// position.
lastPointClicked = event.getTrigger().getPoint();
XYItemEntity entity = (XYItemEntity) event.getEntity();
lastDatasetSelected = entity.getDataset();
int series = entity.getSeriesIndex();
int item = entity.getItem();
lastObSelected = obsModel.getValidObservation(series, item);
if (event.getTrigger().getClickCount() == 2) {
new ObservationDetailsDialog(lastObSelected);
}
} else {
// ...else if not XYItemEntity as subject of the event, select a
// valid observation by asking: which XYItemEntity is closest to the
// cross hairs?
// Where are the cross hairs pointing?
lastPointClicked = chartPanel.getAnchor();
EntityCollection entities = chartPanel.getChartRenderingInfo().getEntityCollection();
double closestDist = Double.MAX_VALUE;
// Note: This operation is linear in the number of visible
// observations!
// Unfortunately, the list of XYItemEntities must always be searched
// exhaustively since we don't know which XYItemEntity will turn out
// to be closest to the mouse selection. Actually, this may not be
// the case if we can assume an ordering of XYItemEntities by domain
// (X). If so, once itemBounds.getCenterX() is greater than
// lastPointClicked.getX(), we could terminate the loop. But I don't
// know if we can make that assumption.
@SuppressWarnings("unchecked")
Iterator it = entities.iterator();
while (it.hasNext()) {
Object o = it.next();
if (o instanceof XYItemEntity) {
XYItemEntity entity = (XYItemEntity) o;
Rectangle2D itemBounds = entity.getArea().getBounds2D();
Point2D centerPt = new Point2D.Double(itemBounds.getCenterX(), itemBounds.getCenterY());
double dist = centerPt.distance(lastPointClicked);
if (dist < closestDist) {
closestDist = dist;
lastDatasetSelected = entity.getDataset();
lastObSelected = obsModel.getValidObservation(entity.getSeriesIndex(), entity.getItem());
}
// Note: The approach below definitely does not work.
// if (item.getArea().contains(lastPointClicked)) {
// lastObSelected = obsModel.getValidObservation(item
// .getSeriesIndex(), item.getItem());
//
// }
}
}
}
// If we found an observation (should always), send an observation
// selection message.
if (lastObSelected != null) {
ObservationSelectionMessage message = new ObservationSelectionMessage(lastObSelected, this);
Mediator.getInstance().getObservationSelectionNotifier().notifyListeners(message);
}
}
/**
* @return The time axis label.
*/
protected static String getTimeAxisLabel(AbstractObservationRetriever retriever) {
// Custom axis title
String axis_title;
axis_title = retriever.getDomainTitle();
if (axis_title != null && !"".equals(axis_title))
return axis_title;
else
return String.format(LocaleProps.get("TIME"));
}
/**
* @return The brightness axis label.
*/
protected static String getBrightnessAxisLabel(AbstractObservationRetriever retriever) {
// Custom axis title
String axis_title;
axis_title = retriever.getRangeTitle();
if (axis_title != null && !"".equals(axis_title))
return axis_title;
else
return String.format("%s (%s)", LocaleProps.get("BRIGHTNESS"), retriever.getBrightnessUnits());
}
/**
* Given an observation, update the last observation and x,y selections.
*
* @param ob The observation in question.
*/
protected void updateSelectionFromObservation(ValidObservation ob) {
lastObSelected = ob;
EntityCollection entities = chartPanel.getChartRenderingInfo().getEntityCollection();
// Note: This operation is linear in the number of observations!
// However, the loop will on average terminate before exhaustively
// searching all entries, i.e. when the observation is matched up
// with the corresponding XYItemEntity. It's still O(n) though.
// TODO: Use RendererUtilities method to return a list of entity indices
// that lie in a given x (JD/phase) range; this would reduce n.
Iterator it = entities.iterator();
while (it.hasNext()) {
Object o = it.next();
if (o instanceof XYItemEntity) {
XYItemEntity item = (XYItemEntity) o;
// Dataset may not be same as primary observation model, e.g.
// could be model function dataset (continuous model).
if (item.getDataset() == obsModel) {
try {
double domainValue = obsModel.getXValue(item.getSeriesIndex(), item.getItem());
double mag = obsModel.getYValue(item.getSeriesIndex(), item.getItem());
// Since the data in the observations and in the
// XYItemEntities should be the same, using equality
// here ought to be safe.
List<ValidObservation> obs = obsModel.getSeriesNumToObSrcListMap().get(item.getSeriesIndex());
if (obsModel.getTimeElementEntity().getTimeElement(obs, item.getItem()) == domainValue
&& ob.getMag() == mag) {
Rectangle2D itemBounds = item.getArea().getBounds2D();
Point2D centerPt = new Point2D.Double(itemBounds.getCenterX(), itemBounds.getCenterY());
lastPointClicked = centerPt;
break;
}
} catch (IndexOutOfBoundsException e) {
// Sometimes the series-index, item-index pair will have
// changed or have become non-existent. Ignore but log.
VStar.LOGGER.log(Level.WARNING, "Observation selection error", e);
}
}
}
}
}
// Returns an observation selection listener specific to the concrete plot.
abstract protected Listener<ObservationSelectionMessage> createObservationSelectionListener();
// Returns a zoom request listener specific to the concrete plot.
abstract protected Listener<ZoomRequestMessage> createZoomRequestListener();
// Returns a pan request listener specific to the concrete plot object.
abstract protected Listener<PanRequestMessage> createPanRequestListener();
/**
* Perform a zoom on the current plot.
*
* @param info The zoom message.
*/
protected void doZoom(ZoomType zoomType) {
// "Reset" zoom level.
if (zoomType == ZoomType.ZOOM_TO_FIT) {
setMagScale();
}
// Only zoom if we have a selection in this plot.
// See also
// http://stackoverflow.com/questions/1512112/jfreechart-get-mouse-coordinates
// if we are unconvinced about getting the right point at all zoom
// levels.
if (lastPointClicked != null) {
// Determine zoom factor.
double zoomDelta = 0.25; // TODO: get from prefs
double factor = 1;
if (zoomType == ZoomType.ZOOM_IN) {
factor = 1 - zoomDelta;
} else if (zoomType == ZoomType.ZOOM_OUT) {
factor = 1 + zoomDelta;
}
// Zoom in on the specified point.
PlotRenderingInfo plotInfo = chartPanel.getChartRenderingInfo().getPlotInfo();
Point2D sourcePoint = null;
sourcePoint = lastPointClicked;
boolean anchorOnPoint = lastPointClicked != null;
chart.getXYPlot().zoomDomainAxes(factor, plotInfo, sourcePoint, anchorOnPoint);
chart.getXYPlot().zoomRangeAxes(factor, plotInfo, sourcePoint, anchorOnPoint);
}
}
/**
* From DatasetChangeListener.
*
* When the dataset changes, e.g. series visibility, we want to set the
* appropriate magnitude value range, ignoring any series that is not visible.
* We also make sure that the loaded series colors are all set.
*/
public void datasetChanged(DatasetChangeEvent event) {
setSeriesVisibility();
setSeriesColors();
setSeriesSizes();
setMagScale();
}
// Tell renderer which series elements should be rendered
// as visually joined with lines.
protected void setJoinedSeries() {
for (int series : obsModel.getSeriesWhoseElementsShouldBeJoinedVisually()) {
this.renderer.setSeriesLinesVisible(series, true);
}
}
// Helpers
/**
* Set the appropriate magnitude value scale, ignoring any series that is not
* visible.
*
* Note: for large datasets, this could be very expensive! Should maintain last
* min and max and only check observations for bands that have changed. Perhaps
* maintain a mappings from SeriesType to min/max mag.
*/
private void setMagScale() {
double min = Double.MAX_VALUE;
double max = -Double.MAX_VALUE;
double minTimeElement = Double.MAX_VALUE;
Map<Integer, List<ValidObservation>> seriesNumToObsMap = obsModel.getSeriesNumToObSrcListMap();
Map<SeriesType, Boolean> seriesVisibilityMap = obsModel.getSeriesVisibilityMap();
for (int seriesNum : seriesNumToObsMap.keySet()) {
SeriesType seriesType = obsModel.getSeriesNumToSrcTypeMap().get(seriesNum);
if (seriesVisibilityMap.get(seriesType)) {
List<ValidObservation> obs = seriesNumToObsMap.get(seriesNum);
int index = 0;
for (ValidObservation ob : obs) {
double mag = ob.getMagnitude().getMagValue();
double uncert = ob.getMagnitude().getUncertainty();
// If uncertainty not given, get HQ uncertainty if present.
if (uncert == 0.0 && ob.getHqUncertainty() != null) {
uncert = ob.getHqUncertainty();
}
if (mag - uncert < min) {
min = mag - uncert;
}
if (mag + uncert > max) {
max = mag + uncert;
}
// Get JD or phase.
double timeElement = obsModel.getTimeElementEntity().getTimeElement(obs, index);
if (timeElement < minTimeElement) {
minTimeElement = timeElement;
}
index++;
}
}
}
boolean obsToPlot = min <= max;
if (obsToPlot) {
if (min == max) {
// For just one observation we will simply have one point at the
// centre of the range.
double mag = min;
min = mag - 1;
max = mag + 1;
}
// Add a small (1%) margin around min/max.
double margin = (max - min) / 100;
min -= margin;
max += margin;
NumberAxis magAxis = (NumberAxis) chart.getXYPlot().getRangeAxis();
magAxis.setRange(new Range(min, max));
// chart.getXYPlot().getDomainAxis().setAutoRange(false);
// chart.getXYPlot().getRangeAxis().setAutoRange(false);
// chart.getXYPlot().getDomainAxis().setAutoRangeMinimumSize(minJD);
// chart.getXYPlot().getRangeAxis().setAutoRangeMinimumSize(min);
}
}
// TODO: see also
// chart.getXYPlot().get{Domain,Range}Axis().setAutoRangeMinimumSize()
// as a way to fix the zoom-out-auto-range bug! Same as Range(min, max)
// above?
}