Improved chart rendering, added export.

This commit is contained in:
Andrew Lalis 2023-04-21 18:57:32 +02:00
parent 583d57d5bd
commit a200aee640
11 changed files with 468 additions and 65 deletions

View File

@ -14,4 +14,8 @@ public record DateRange(LocalDate start, LocalDate end) {
public static DateRange unbounded() {
return new DateRange(null, null);
}
public static DateRange lastNWeeks(int n) {
return after(LocalDate.now().minusWeeks(n));
}
}

View File

@ -3,10 +3,14 @@ package com.github.andrewlalis.running_every_day.view;
import com.github.andrewlalis.running_every_day.data.DateRange;
import com.github.andrewlalis.running_every_day.data.db.DataSource;
import com.github.andrewlalis.running_every_day.view.chart.ChartRenderingPanel;
import com.github.andrewlalis.running_every_day.view.chart.DateRangePanel;
import com.github.andrewlalis.running_every_day.view.chart.DateSeriesCharts;
import com.github.andrewlalis.running_every_day.view.chart.ExportChartImageDialog;
import com.github.andrewlalis.running_every_day.view.chart.menu.DateSeriesChartMenuItem;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionListener;
import java.time.LocalDate;
public class ChartsPanel extends JPanel {
@ -20,55 +24,47 @@ public class ChartsPanel extends JPanel {
this.chartRenderingPanel = new ChartRenderingPanel();
this.add(chartRenderingPanel, BorderLayout.CENTER);
JPanel topPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton exportButton = new JButton("Export to Image");
exportButton.addActionListener(e -> {
var dialog = new ExportChartImageDialog(this, chartRenderingPanel.getRenderer());
dialog.setVisible(true);
});
topPanel.add(exportButton);
this.add(topPanel, BorderLayout.NORTH);
JPanel chartMenu = new JPanel();
chartMenu.setLayout(new BoxLayout(chartMenu, BoxLayout.PAGE_AXIS));
chartMenu.add(buildWeightChartMenuPanel());
chartMenu.add(buildPaceChartMenuPanel());
chartMenu.add(new DateSeriesChartMenuItem(
"Weight",
"A chart that depicts weight change over time.",
chartRenderingPanel,
dataSource,
DateRange.lastNWeeks(2),
DateSeriesCharts::weight
));
chartMenu.add(new DateSeriesChartMenuItem(
"Pace",
"A chart that depicts average pace in minutes per kilometer.",
chartRenderingPanel,
dataSource,
DateRange.lastNWeeks(2),
DateSeriesCharts::pace
));
chartMenu.add(new DateSeriesChartMenuItem(
"Total Distance",
"A chart showing the total distance accumulated over time, in kilometers.",
chartRenderingPanel,
dataSource,
DateRange.lastNWeeks(4),
DateSeriesCharts::totalDistance
));
chartMenu.add(Box.createVerticalGlue());
var menuScroll = new JScrollPane(chartMenu, ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
// menuScroll.setPreferredSize(new Dimension(300, -1));
menuScroll.getVerticalScrollBar().setUnitIncrement(10);
this.add(menuScroll, BorderLayout.EAST);
}
private JPanel buildBasicChartMenuItem(String name, String description) {
JPanel panel = new JPanel();
panel.setLayout(new BoxLayout(panel, BoxLayout.PAGE_AXIS));
panel.setBorder(BorderFactory.createRaisedBevelBorder());
JLabel titleLabel = new JLabel(name);
titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD));
titleLabel.setHorizontalAlignment(SwingConstants.LEFT);
panel.add(titleLabel);
JTextArea descriptionArea = new JTextArea();
descriptionArea.setLineWrap(true);
descriptionArea.setWrapStyleWord(true);
descriptionArea.setEditable(false);
descriptionArea.setText(description);
panel.add(descriptionArea);
return panel;
}
private JPanel buildWeightChartMenuPanel() {
JPanel panel = buildBasicChartMenuItem("Weight", "A chart that depicts weight change over time.");
JPanel buttonsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton viewButton = new JButton("View");
viewButton.addActionListener(e -> chartRenderingPanel.setRenderer(DateSeriesCharts.weight(dataSource, DateRange.unbounded())));
buttonsPanel.add(viewButton);
panel.add(buttonsPanel);
return panel;
}
private JPanel buildPaceChartMenuPanel() {
JPanel panel = buildBasicChartMenuItem("Average Pace", "A chart that depicts the average pace of each run.");
JPanel buttonsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton viewButton = new JButton("View");
viewButton.addActionListener(e -> chartRenderingPanel.setRenderer(DateSeriesCharts.pace(dataSource, DateRange.unbounded())));
buttonsPanel.add(viewButton);
panel.add(buttonsPanel);
return panel;
}
}

View File

@ -97,10 +97,6 @@ public class RunRecordsPanel extends JPanel {
paginationPanel.add(lastPageButton);
this.add(paginationPanel, BorderLayout.SOUTH);
// SwingUtilities.invokeLater(() -> {
// tableModel.firstPage();
// updateButtonStates();
// });
JPanel actionsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
JButton addActionButton = new JButton("Add a Record");

View File

@ -3,8 +3,9 @@ package com.github.andrewlalis.running_every_day.view.chart;
import javax.swing.*;
import java.awt.*;
import java.awt.geom.Rectangle2D;
import java.util.function.Consumer;
public class ChartRenderingPanel extends JPanel {
public class ChartRenderingPanel extends JPanel implements Consumer<ChartRenderer> {
private ChartRenderer renderer;
public void setRenderer(ChartRenderer renderer) {
@ -12,6 +13,10 @@ public class ChartRenderingPanel extends JPanel {
this.repaint();
}
public ChartRenderer getRenderer() {
return renderer;
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
@ -23,4 +28,9 @@ public class ChartRenderingPanel extends JPanel {
g.drawString("No chart to render", 50, 50);
}
}
@Override
public void accept(ChartRenderer chartRenderer) {
setRenderer(chartRenderer);
}
}

View File

@ -0,0 +1,153 @@
package com.github.andrewlalis.running_every_day.view.chart;
import com.github.andrewlalis.running_every_day.data.DateRange;
import javax.swing.*;
import java.awt.*;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.List;
/**
* An interactive panel for selecting a date range, with multiple different options.
*/
public class DateRangePanel extends JPanel {
private record DateRangeChoice(DateRange range, String label) {
@Override
public String toString() {
return label;
}
}
private final JTabbedPane tabbedPane = new JTabbedPane();
private final JTextField startDateField = new JTextField();
private final JCheckBox startDateEnabledCheckbox = new JCheckBox();
private final JTextField endDateField = new JTextField();
private final JCheckBox endDateEnabledCheckbox = new JCheckBox();
private final DefaultComboBoxModel<DateRangeChoice> dateRangeChoiceModel = new DefaultComboBoxModel<>();
public DateRangePanel(DateRange defaultRange) {
super(new BorderLayout());
tabbedPane.addTab("Exact Date", buildManualDatePanel(defaultRange));
tabbedPane.addTab("Preset Date", buildPresetDatePanel(defaultRange));
this.add(tabbedPane, BorderLayout.CENTER);
JLabel titleLabel = new JLabel("Date Range Selector");
titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD));
this.add(titleLabel, BorderLayout.NORTH);
this.setMinimumSize(new Dimension(300, 120));
this.setPreferredSize(this.getMinimumSize());
this.setBorder(BorderFactory.createLoweredBevelBorder());
}
private JPanel buildManualDatePanel(DateRange defaultRange) {
JPanel manualDatePanel = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.gridx = 0;
c.gridy = 0;
c.anchor = GridBagConstraints.WEST;
c.weightx = 0;
c.gridwidth = 1;
c.insets = new Insets(3, 3, 3, 3);
manualDatePanel.add(new JLabel("Start"), c);
c.gridy = 1;
manualDatePanel.add(new JLabel("End"), c);
c.gridx = 1;
c.gridy = 0;
c.anchor = GridBagConstraints.EAST;
c.weightx = 1;
c.fill = GridBagConstraints.HORIZONTAL;
manualDatePanel.add(startDateField, c);
c.gridy = 1;
manualDatePanel.add(endDateField, c);
c.gridx = 2;
c.gridy = 0;
c.anchor = GridBagConstraints.CENTER;
c.weightx = 0;
manualDatePanel.add(startDateEnabledCheckbox, c);
c.gridy = 1;
manualDatePanel.add(endDateEnabledCheckbox, c);
startDateEnabledCheckbox.addActionListener(e -> {
startDateField.setEnabled(startDateEnabledCheckbox.isSelected());
});
endDateEnabledCheckbox.addActionListener(e -> {
endDateField.setEnabled(endDateEnabledCheckbox.isSelected());
});
startDateEnabledCheckbox.setSelected(false);
endDateEnabledCheckbox.setSelected(false);
if (defaultRange != null) {
if (defaultRange.start() != null) {
startDateField.setText(defaultRange.start().toString());
startDateEnabledCheckbox.setSelected(true);
}
if (defaultRange.end() != null) {
endDateField.setText(defaultRange.end().toString());
endDateEnabledCheckbox.setSelected(true);
}
}
startDateField.setEnabled(startDateEnabledCheckbox.isSelected());
endDateField.setEnabled(endDateEnabledCheckbox.isSelected());
return manualDatePanel;
}
private JPanel buildPresetDatePanel(DateRange defaultRange) {
JPanel panel = new JPanel();
var choices = List.of(
new DateRangeChoice(DateRange.lastNWeeks(1), "Last Week"),
new DateRangeChoice(DateRange.lastNWeeks(2), "Last 2 Weeks"),
new DateRangeChoice(DateRange.after(LocalDate.now().minusMonths(1)), "Last Month"),
new DateRangeChoice(DateRange.after(LocalDate.now().minusMonths(3)), "Last 3 Months"),
new DateRangeChoice(DateRange.after(LocalDate.now().minusYears(1)), "Last Year"),
new DateRangeChoice(DateRange.unbounded(), "All")
);
dateRangeChoiceModel.addAll(choices);
boolean assigned = false;
for (var choice : choices) {
if (choice.range().equals(defaultRange)) {
dateRangeChoiceModel.setSelectedItem(choice);
assigned = true;
break;
}
}
if (!assigned) {
dateRangeChoiceModel.setSelectedItem(choices.get(0));
}
panel.add(new JComboBox<>(dateRangeChoiceModel));
return panel;
}
public DateRange getSelectedDateRange() {
if (tabbedPane.getSelectedIndex() == 0) {
LocalDate start = null;
LocalDate end = null;
if (startDateEnabledCheckbox.isSelected()) {
try {
start = LocalDate.parse(startDateField.getText());
} catch (DateTimeParseException e) {
e.printStackTrace();
}
}
if (endDateEnabledCheckbox.isSelected()) {
try {
end = LocalDate.parse(endDateField.getText());
} catch (DateTimeParseException e) {
e.printStackTrace();
}
}
return new DateRange(start, end);
} else {
DateRangeChoice choice = (DateRangeChoice) dateRangeChoiceModel.getSelectedItem();
return choice.range();
}
}
}

View File

@ -12,8 +12,16 @@ import org.jfree.data.xy.XYDataset;
import java.awt.*;
import java.text.DateFormat;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.util.Locale;
/**
* An extension of the generic JFreeChartRenderer which handles formatting for
* date-series charts, which are a common type of chart that we use. This way,
* you only need to specify a title, paint for the line, and a data generator
* that produces data points.
*/
public class DateSeriesChartRenderer extends JFreeChartRenderer {
public record Datapoint (double value, LocalDate date) {}
@ -48,11 +56,12 @@ public class DateSeriesChartRenderer extends JFreeChartRenderer {
XYDataset dataset = new TimeSeriesCollection(series);
DateAxis domainAxis = new DateAxis();
domainAxis.setVerticalTickLabels(true);
domainAxis.setTickLabelsVisible(true);
domainAxis.setDateFormatOverride(DateFormat.getDateInstance());
NumberAxis rangeAxis = new NumberAxis();
rangeAxis.setRangeWithMargins(minValue, maxValue);
rangeAxis.setNumberFormatOverride(NumberFormat.getNumberInstance(Locale.US));
XYPlot plot = new XYPlot(dataset, domainAxis, rangeAxis, new XYLineAndShapeRenderer());
var chart = new JFreeChart(title, plot);

View File

@ -0,0 +1,9 @@
package com.github.andrewlalis.running_every_day.view.chart;
import com.github.andrewlalis.running_every_day.data.DateRange;
import com.github.andrewlalis.running_every_day.data.db.DataSource;
@FunctionalInterface
public interface DateSeriesChartRendererFactory {
ChartRenderer getRenderer(DataSource dataSource, DateRange range);
}

View File

@ -10,15 +10,12 @@ import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* A pre-defined list of chart renderers that can be used.
*/
public final class DateSeriesCharts {
public static ChartRenderer weight(DataSource dataSource, DateRange dateRange) {
String baseQuery = "SELECT weight, date FROM run";
String dateRangeConditions = buildDateRangeConditions(dateRange);
if (dateRangeConditions != null) {
baseQuery += " WHERE " + dateRangeConditions;
}
baseQuery += " ORDER BY date ASC";
final String query = baseQuery;
final String query = applyDateRange("SELECT weight, date FROM run", dateRange) + " ORDER BY date ASC";
return new DateSeriesChartRenderer(
"Weight (Kg)",
Color.BLUE,
@ -34,13 +31,7 @@ public final class DateSeriesCharts {
}
public static ChartRenderer pace(DataSource dataSource, DateRange dateRange) {
String baseQuery = "SELECT * FROM run";
String dateRangeConditions = buildDateRangeConditions(dateRange);
if (dateRangeConditions != null) {
baseQuery += " WHERE " + dateRangeConditions;
}
baseQuery += " ORDER BY date ASC";
final String query = baseQuery;
final String query = applyDateRange("SELECT * FROM run", dateRange) + " ORDER BY date ASC";
final var mapper = new RunRecord.Mapper();
return new DateSeriesChartRenderer(
"Pace (Min/Km)",
@ -59,6 +50,35 @@ public final class DateSeriesCharts {
);
}
public static ChartRenderer totalDistance(DataSource dataSource, DateRange dateRange) {
final String query = applyDateRange("SELECT distance, date FROM run", dateRange) + " ORDER BY date ASC";
return new DateSeriesChartRenderer(
"Total Distance (Km)",
Color.GREEN,
() -> {
List<DateSeriesChartRenderer.Datapoint> items = Queries.findAll(
dataSource.conn(),
query,
rs -> new DateSeriesChartRenderer.Datapoint(
rs.getInt(1) / 1000.0,
LocalDate.parse(rs.getString(2))
)
);
double total = 0;
// If a start date is specified, first compute the total distance ran up until then.
if (dateRange != null && dateRange.start() != null) {
total = Queries.getLong(dataSource.conn(), "SELECT SUM(distance) FROM run WHERE date < '" + dateRange.start() + "'") / 1000.0;
}
List<DateSeriesChartRenderer.Datapoint> finalItems = new ArrayList<>(items.size());
for (var d : items) {
total += d.value();
finalItems.add(new DateSeriesChartRenderer.Datapoint(total, d.date()));
}
return finalItems;
}
);
}
private static String buildDateRangeConditions(DateRange dateRange) {
if (dateRange == null || (dateRange.start() == null && dateRange.end() == null)) {
return null;
@ -72,4 +92,12 @@ public final class DateSeriesCharts {
}
return String.join(" AND ", conditions);
}
private static String applyDateRange(String query, DateRange range) {
String rangeConditions = buildDateRangeConditions(range);
if (rangeConditions != null) {
return query + " WHERE " + rangeConditions;
}
return query;
}
}

View File

@ -0,0 +1,134 @@
package com.github.andrewlalis.running_every_day.view.chart;
import javax.imageio.ImageIO;
import javax.swing.*;
import javax.swing.filechooser.FileNameExtensionFilter;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
public class ExportChartImageDialog extends JDialog {
private final ChartRenderer chartRenderer;
private final SpinnerNumberModel widthSpinnerModel = new SpinnerNumberModel(1920, 10, 10000, 1);
private final SpinnerNumberModel heightSpinnerModel = new SpinnerNumberModel(1080, 10, 10000, 1);
private final JTextField filePathField = new JTextField();
private Path currentFilePath = Path.of(".").toAbsolutePath().resolve("chart.png");
public ExportChartImageDialog(Component parent, ChartRenderer chartRenderer) {
super(SwingUtilities.getWindowAncestor(parent), "Export Chart to Image", ModalityType.APPLICATION_MODAL);
this.chartRenderer = chartRenderer;
this.setLayout(new BorderLayout());
JPanel formPanel = new JPanel(new GridBagLayout());
GridBagConstraints c = new GridBagConstraints();
c.gridx = 0;
c.gridy = 0;
c.anchor = GridBagConstraints.WEST;
c.weightx = 0;
c.insets = new Insets(5, 5, 5, 5);
formPanel.add(new JLabel("Width (px)"), c);
c.gridy = 1;
formPanel.add(new JLabel("Height (px)"), c);
c.gridy = 2;
formPanel.add(new JLabel("File"), c);
c.gridy = 3;
JButton selectFileButton = new JButton("Select File");
selectFileButton.addActionListener(e -> browseForFilePath());
formPanel.add(selectFileButton, c);
c.gridx = 1;
c.gridy = 0;
c.anchor = GridBagConstraints.EAST;
c.weightx = 1;
c.fill = GridBagConstraints.HORIZONTAL;
JSpinner widthSpinner = new JSpinner(widthSpinnerModel);
widthSpinner.setLocale(Locale.US);
formPanel.add(widthSpinner, c);
c.gridy = 1;
JSpinner heightSpinner = new JSpinner(heightSpinnerModel);
heightSpinner.setLocale(Locale.US);
formPanel.add(heightSpinner, c);
c.gridy = 2;
filePathField.setEditable(false);
filePathField.setText(currentFilePath.toAbsolutePath().toString());
filePathField.addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
browseForFilePath();
}
});
formPanel.add(filePathField, c);
this.add(formPanel, BorderLayout.CENTER);
JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT));
JButton exportButton = new JButton("Export");
exportButton.addActionListener(e -> {
exportChart();
});
buttonPanel.add(exportButton);
JButton cancelButton = new JButton("Cancel");
cancelButton.addActionListener(e -> this.dispose());
buttonPanel.add(cancelButton);
this.add(buttonPanel, BorderLayout.SOUTH);
this.setPreferredSize(new Dimension(600, 300));
this.pack();
this.setLocationRelativeTo(parent);
}
private void browseForFilePath() {
JFileChooser fileChooser = new JFileChooser(currentFilePath.toFile());
fileChooser.setFileFilter(new FileNameExtensionFilter("PNG Images", "png"));
fileChooser.setMultiSelectionEnabled(false);
int result = fileChooser.showDialog(this, "Save");
if (result == JFileChooser.APPROVE_OPTION) {
currentFilePath = fileChooser.getSelectedFile().toPath();
filePathField.setText(currentFilePath.toAbsolutePath().toString());
}
}
private void exportChart() {
if (Files.exists(currentFilePath)) {
int result = JOptionPane.showConfirmDialog(
this,
"The file " + currentFilePath + " already exists.\nAre you sure you want to overwrite it?",
"Confirm Overwrite",
JOptionPane.OK_CANCEL_OPTION,
JOptionPane.WARNING_MESSAGE
);
if (result != JOptionPane.OK_OPTION) return;
}
int width = (int) widthSpinnerModel.getValue();
int height = (int) heightSpinnerModel.getValue();
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_4BYTE_ABGR);
chartRenderer.render(img.createGraphics(), new Rectangle2D.Float(0, 0, width, height));
try {
ImageIO.write(img, "png", currentFilePath.toFile());
JOptionPane.showMessageDialog(
this,
"Export complete!",
"Export Complete",
JOptionPane.PLAIN_MESSAGE
);
this.dispose();
} catch (IOException e) {
e.printStackTrace();
JOptionPane.showMessageDialog(
this,
"An error occurred:\n" + e.getMessage(),
"Export Error",
JOptionPane.ERROR_MESSAGE
);
}
}
}

View File

@ -0,0 +1,40 @@
package com.github.andrewlalis.running_every_day.view.chart.menu;
import com.github.andrewlalis.running_every_day.view.chart.ChartRenderer;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionListener;
import java.util.function.Consumer;
public class ChartMenuItem extends JPanel {
private final JButton viewButton = new JButton("View");
protected final Consumer<ChartRenderer> rendererConsumer;
public ChartMenuItem(String name, String description, Consumer<ChartRenderer> rendererConsumer) {
this.rendererConsumer = rendererConsumer;
this.setLayout(new BoxLayout(this, BoxLayout.PAGE_AXIS));
JPanel headerPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
JLabel titleLabel = new JLabel(name);
titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD));
titleLabel.setHorizontalAlignment(SwingConstants.LEFT);
headerPanel.add(titleLabel);
this.add(headerPanel);
JTextArea descriptionArea = new JTextArea(0, 0);
descriptionArea.setLineWrap(true);
descriptionArea.setWrapStyleWord(true);
descriptionArea.setEditable(false);
descriptionArea.setText(description);
this.add(descriptionArea);
JPanel buttonsPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
buttonsPanel.add(viewButton);
this.add(buttonsPanel);
}
public void setupViewAction(ActionListener l) {
viewButton.addActionListener(l);
}
}

View File

@ -0,0 +1,24 @@
package com.github.andrewlalis.running_every_day.view.chart.menu;
import com.github.andrewlalis.running_every_day.data.DateRange;
import com.github.andrewlalis.running_every_day.data.db.DataSource;
import com.github.andrewlalis.running_every_day.view.chart.ChartRenderer;
import com.github.andrewlalis.running_every_day.view.chart.DateRangePanel;
import com.github.andrewlalis.running_every_day.view.chart.DateSeriesChartRendererFactory;
import java.util.function.Consumer;
public class DateSeriesChartMenuItem extends ChartMenuItem {
private final DateRange defaultDateRange;
private final DateRangePanel dateRangePanel;
public DateSeriesChartMenuItem(String name, String description, Consumer<ChartRenderer> rendererConsumer, DataSource dataSource, DateRange defaultRange, DateSeriesChartRendererFactory rendererFactory) {
super(name, description, rendererConsumer);
this.defaultDateRange = defaultRange;
this.dateRangePanel = new DateRangePanel(defaultRange);
this.add(dateRangePanel, 2);
setupViewAction(e -> {
rendererConsumer.accept(rendererFactory.getRenderer(dataSource, dateRangePanel.getSelectedDateRange()));
});
}
}