package de.saring.exerciseviewer.gui.panels; import java.text.DecimalFormat; import java.text.SimpleDateFormat; import java.util.Date; import java.util.TimeZone; import de.saring.exerciseviewer.gui.EVDocument; import de.saring.util.gui.jfreechart.ChartUtils; import javafx.event.ActionEvent; import javafx.fxml.FXML; import javafx.scene.control.ChoiceBox; import javafx.scene.layout.StackPane; import javafx.util.StringConverter; import org.jfree.chart.ChartFactory; import org.jfree.chart.JFreeChart; import org.jfree.chart.axis.AxisLocation; import org.jfree.chart.axis.DateAxis; import org.jfree.chart.axis.NumberAxis; import org.jfree.chart.axis.ValueAxis; import org.jfree.chart.fx.ChartViewer; import org.jfree.chart.labels.StandardXYToolTipGenerator; import org.jfree.chart.plot.IntervalMarker; import org.jfree.chart.plot.Marker; import org.jfree.chart.plot.PlotOrientation; import org.jfree.chart.plot.ValueMarker; import org.jfree.chart.plot.XYPlot; import org.jfree.chart.renderer.xy.StandardXYItemRenderer; import org.jfree.chart.renderer.xy.XYItemRenderer; import org.jfree.data.general.Series; import org.jfree.data.time.Second; import org.jfree.data.time.TimeSeries; import org.jfree.data.time.TimeSeriesCollection; import org.jfree.data.xy.XYDataset; import org.jfree.data.xy.XYSeries; import org.jfree.data.xy.XYSeriesCollection; import org.jfree.ui.RectangleAnchor; import org.jfree.ui.TextAnchor; import de.saring.exerciseviewer.data.EVExercise; import de.saring.exerciseviewer.data.ExerciseSample; import de.saring.exerciseviewer.data.HeartRateLimit; import de.saring.exerciseviewer.data.Lap; import de.saring.exerciseviewer.gui.EVContext; import de.saring.util.AppResources; import de.saring.util.unitcalc.ConvertUtils; import de.saring.util.unitcalc.FormatUtils; /** * Controller (MVC) class of the "Samples" panel, which displays the exercise graphically * (heartrate, altitude, speed and cadence). * * @author Stefan Saring */ public class DiagramPanelController extends AbstractPanelController { // The colors of the chart. private static final java.awt.Color COLOR_AXIS_LEFT = java.awt.Color.RED; private static final java.awt.Color COLOR_AXIS_RIGHT = java.awt.Color.BLUE; private static final java.awt.Color COLOR_MARKER_LAP = new java.awt.Color(0f, 0.73f, 0f); private static final java.awt.Color COLOR_MARKER_HEARTRATE = new java.awt.Color(0.8f, 0.8f, 0.8f, 0.3f); private static final TimeZone TIMEZONE_GMT = TimeZone.getTimeZone("GMT"); private final AxisTypeStringConverter axisTypeStringConverter; /** The viewer for the chart. */ private ChartViewer chartViewer; /** The exercise heartrate range to be highlighted (null for no highlighting). */ private HeartRateLimit highlightHeartrateRange = null; /** The size of the average range if smoothed charts are enabled (otherwise 0). */ private int averagedRangeSteps; @FXML private StackPane spDiagram; @FXML private ChoiceBox<AxisType> cbLeftAxis; @FXML private ChoiceBox<AxisType> cbRightAxis; @FXML private ChoiceBox<AxisType> cbBottomAxis; /** * Standard c'tor for dependency injection. * * @param context the ExerciseViewer UI context * @param document the ExerciseViewer document / model */ public DiagramPanelController(final EVContext context, final EVDocument document) { super(context, document); axisTypeStringConverter = new AxisTypeStringConverter(getContext().getResources(), getContext().getFormatUtils()); } @Override protected String getFxmlFilename() { return "/fxml/panels/DiagramPanel.fxml"; } /** * Updates the diagram and highlights the specified heartrate range. * * @param heartrateRange heartrate range to highlight */ public void displayDiagramForHeartrateRange(final HeartRateLimit heartrateRange) { highlightHeartrateRange = heartrateRange; // don't update the diagram when this panel was not initialized yet if (chartViewer != null) { updateDiagram(); } } @Override protected void setupPanel() { setupAxisChoiceBoxes(); computeAveragedFilterRange(); updateDiagram(); } private void setupAxisChoiceBoxes() { EVExercise exercise = getDocument().getExercise(); // setup axis type name converter cbLeftAxis.setConverter(axisTypeStringConverter); cbRightAxis.setConverter(axisTypeStringConverter); cbBottomAxis.setConverter(axisTypeStringConverter); // fill axes with all possible types depending on the exercise recording mode cbLeftAxis.getItems().add(AxisType.HEARTRATE); cbRightAxis.getItems().addAll(AxisType.NOTHING, AxisType.HEARTRATE); cbBottomAxis.getItems().add(AxisType.TIME); cbLeftAxis.getSelectionModel().select(0); cbRightAxis.getSelectionModel().select(0); cbBottomAxis.getSelectionModel().select(0); // add altitude items if recorded if (exercise.getRecordingMode().isAltitude()) { cbLeftAxis.getItems().addAll(AxisType.ALTITUDE); cbRightAxis.getItems().add(AxisType.ALTITUDE); } // add speed and distance items if recorded if (exercise.getRecordingMode().isSpeed()) { cbLeftAxis.getItems().add(AxisType.SPEED); cbRightAxis.getItems().add(AxisType.SPEED); cbBottomAxis.getItems().add(AxisType.DISTANCE); } // add cadence items if recorded if (exercise.getRecordingMode().isCadence()) { cbLeftAxis.getItems().add(AxisType.CADENCE); cbRightAxis.getItems().add(AxisType.CADENCE); } // add temperature items if recorded if (exercise.getRecordingMode().isTemperature()) { cbLeftAxis.getItems().add(AxisType.TEMPERATURE); cbRightAxis.getItems().add(AxisType.TEMPERATURE); } // do we need to display the second diagram too? if (getDocument().getOptions().isDisplaySecondChart()) { // it's only possible when additional data is available (first 2 entries // are nothing and heartrate, which is already displayed) if (cbRightAxis.getItems().size() > 2) { cbRightAxis.getSelectionModel().select(2); } } // set listeners for updating the diagram on selection changes cbLeftAxis.addEventHandler(ActionEvent.ACTION, event -> updateDiagram()); cbRightAxis.addEventHandler(ActionEvent.ACTION, event -> updateDiagram()); cbBottomAxis.addEventHandler(ActionEvent.ACTION, event -> updateDiagram()); } /** * Compute the number of steps to be used for the averaged filter is smoothed charts are enabled. The size of * the average range depends on the number of samples in the current exercise. */ private void computeAveragedFilterRange() { if (getDocument().getOptions().isDisplaySmoothedCharts()) { final ExerciseSample[] sampleList = getDocument().getExercise().getSampleList(); // results seem to be best when sample count is devided by 800 (tested with many exercises) averagedRangeSteps = Math.max(1, Math.round(sampleList.length / 800f)); } else { averagedRangeSteps = 0; } } /** * Draws the diagram according to the current axis type selection and configuration settings. */ private void updateDiagram() { final EVExercise exercise = getDocument().getExercise(); final AxisType axisTypeLeft = cbLeftAxis.getValue(); final AxisType axisTypeRight = cbRightAxis.getValue(); final AxisType axisTypeBottom = cbBottomAxis.getValue(); final boolean fDomainAxisTime = axisTypeBottom == AxisType.TIME; // create and fill data series according to axis type // (right axis only when user selected a different axis type) final Series sLeft = createSeries(fDomainAxisTime, "left"); Series sRight = null; if ((axisTypeRight != AxisType.NOTHING) && (axisTypeRight != axisTypeLeft)) { sRight = createSeries(fDomainAxisTime, "right"); } // fill data series with all recorded exercise samples if (exercise.getSampleList() != null) { for (int index = 0; index < exercise.getSampleList().length; index++) { final ExerciseSample sample = exercise.getSampleList()[index]; final Number valueLeft = getConvertedSampleValue(axisTypeLeft, index); final Number valueRight = getConvertedSampleValue(axisTypeRight, index); if (fDomainAxisTime) { // calculate current second final int timeSeconds = (int) (sample.getTimestamp() / 1000); final Second second = createJFreeChartSecond(timeSeconds); fillDataInTimeSeries((TimeSeries) sLeft, (TimeSeries) sRight, second, valueLeft, valueRight); } else { // get current distance of this sample double fDistance = sample.getDistance() / 1000f; if (getContext().getFormatUtils().getUnitSystem() != FormatUtils.UnitSystem.Metric) { fDistance = ConvertUtils.convertKilometer2Miles(fDistance, false); } fillDataInXYSeries((XYSeries) sLeft, (XYSeries) sRight, fDistance, valueLeft, valueRight); } } } // some Polar models only record lap data. no samples (e.g. RS200SD) else if (exercise.getLapList() != null) { // data starts with first lap => add 0 values (otherwise not displayed) if (fDomainAxisTime) { fillDataInTimeSeries((TimeSeries) sLeft, (TimeSeries) sRight, createJFreeChartSecond(0), 0, 0); } else { fillDataInXYSeries((XYSeries) sLeft, (XYSeries) sRight, 0, 0, 0); } // fill data series with all recorded exercise laps for (int i = 0; i < exercise.getLapList().length; i++) { final Lap lap = exercise.getLapList()[i]; final Number valueLeft = getLapValue(axisTypeLeft, lap); final Number valueRight = getLapValue(axisTypeRight, lap); if (fDomainAxisTime) { // calculate current second final int timeSeconds = Math.round(lap.getTimeSplit() / 10f); final Second second = createJFreeChartSecond(timeSeconds); fillDataInTimeSeries((TimeSeries) sLeft, (TimeSeries) sRight, second, valueLeft, valueRight); } else { // get current distance of this sample double fDistance = lap.getSpeed().getDistance() / 1000f; if (getContext().getFormatUtils().getUnitSystem() != FormatUtils.UnitSystem.Metric) { fDistance = ConvertUtils.convertKilometer2Miles(fDistance, false); } fillDataInXYSeries((XYSeries) sLeft, (XYSeries) sRight, fDistance, valueLeft, valueRight); } } } final XYDataset dataset = createDataSet(fDomainAxisTime, sLeft); // create chart depending on domain axis type JFreeChart chart = null; if (fDomainAxisTime) { chart = ChartFactory.createTimeSeriesChart(null, // Title axisTypeStringConverter.toString(axisTypeBottom), // Y-axis label axisTypeStringConverter.toString(axisTypeLeft), // X-axis label dataset, // primary dataset false, // display legend true, // display tooltips false); // URLs } else { chart = ChartFactory.createXYLineChart(null, // Title axisTypeStringConverter.toString(axisTypeBottom), // Y-axis label axisTypeStringConverter.toString(axisTypeLeft), // X-axis label dataset, // primary dataset PlotOrientation.VERTICAL, // plot orientation false, // display legend true, // display tooltips false); // URLs } // set format of time domain axis (if active) final XYPlot plot = (XYPlot) chart.getPlot(); // setup left axis final ValueAxis axisLeft = plot.getRangeAxis(0); axisLeft.setLabelPaint(COLOR_AXIS_LEFT); axisLeft.setTickLabelPaint(COLOR_AXIS_LEFT); final XYItemRenderer rendererLeft = plot.getRenderer(0); rendererLeft.setSeriesPaint(0, COLOR_AXIS_LEFT); setTooltipGenerator(rendererLeft, axisTypeBottom, axisTypeLeft); // setup right axis (when selected) if (sRight != null) { final NumberAxis axisRight = new NumberAxis(axisTypeStringConverter.toString(axisTypeRight)); axisRight.setAutoRangeIncludesZero(false); plot.setRangeAxis(1, axisRight); plot.setRangeAxisLocation(1, AxisLocation.BOTTOM_OR_RIGHT); axisRight.setLabelPaint(COLOR_AXIS_RIGHT); axisRight.setTickLabelPaint(COLOR_AXIS_RIGHT); // create dataset for right axis final XYDataset datasetRight = createDataSet(fDomainAxisTime, sRight); plot.setDataset(1, datasetRight); plot.mapDatasetToRangeAxis(1, 1); // set custom renderer final StandardXYItemRenderer rendererRight = new StandardXYItemRenderer(); rendererRight.setSeriesPaint(0, COLOR_AXIS_RIGHT); plot.setRenderer(1, rendererRight); setTooltipGenerator(rendererRight, axisTypeBottom, axisTypeRight); } // use TimeZone GMT on the time axis, because all Date value are GMT based if (fDomainAxisTime) { final DateAxis dateAxis = (DateAxis) plot.getDomainAxis(); dateAxis.setTimeZone(TIMEZONE_GMT); } // highlight current selected (if set) heartrate range when displayed on left axis if (highlightHeartrateRange != null && axisTypeLeft == AxisType.HEARTRATE) { // don't highlight percentual ranges (is not possible, the values // are absolute and the maximum heartrate is unknown) if (highlightHeartrateRange.isAbsoluteRange()) { final Marker hrRangeMarker = new IntervalMarker(highlightHeartrateRange.getLowerHeartRate(), highlightHeartrateRange.getUpperHeartRate()); hrRangeMarker.setPaint(COLOR_MARKER_HEARTRATE); plot.addRangeMarker(hrRangeMarker); } } // draw a vertical marker line for each lap (not for the last) if (exercise.getLapList().length > 0) { for (int i = 0; i < exercise.getLapList().length - 1; i++) { final Lap lap = exercise.getLapList()[i]; double lapSplitValue; // compute lap split value (different for time or distance mode) // (the value must be milliseconds for time domain axis) if (fDomainAxisTime) { final int lapSplitSeconds = lap.getTimeSplit() / 10; lapSplitValue = createJFreeChartSecond(lapSplitSeconds).getFirstMillisecond(); } else { lapSplitValue = lap.getSpeed().getDistance() / 1000D; if (getContext().getFormatUtils().getUnitSystem() == FormatUtils.UnitSystem.English) { lapSplitValue = ConvertUtils.convertKilometer2Miles(lapSplitValue, false); } } // create domain marker Marker lapMarker = new ValueMarker(lapSplitValue); lapMarker.setPaint(COLOR_MARKER_LAP); lapMarker.setStroke(new java.awt.BasicStroke(1.5f)); lapMarker.setLabel(getContext().getResources().getString("pv.diagram.lap", i + 1)); lapMarker.setLabelAnchor(RectangleAnchor.TOP_LEFT); lapMarker.setLabelTextAnchor(TextAnchor.TOP_RIGHT); plot.addDomainMarker(lapMarker); } } ChartUtils.customizeChart(chart); // display chart in viewer (chart viewer will be initialized lazily) if (chartViewer == null) { chartViewer = new ChartViewer(chart); spDiagram.getChildren().addAll(chartViewer); } else { chartViewer.setChart(chart); } } /** * Creates the JFreeChart Second instance for the specified number of seconds. * * @param seconds the number of seconds * @return the created Second instance */ private Second createJFreeChartSecond(final int seconds) { return new Second(new Date(seconds * 1000L)); } /** * Creates a data series object for the specified domain axis type. * * @param fDomainAxisTime true when domain axis is time of false when distance * @param name name of the data series * @return the created data series */ private Series createSeries(final boolean fDomainAxisTime, final String name) { if (fDomainAxisTime) { return new TimeSeries(name); } else { return new XYSeries(name); } } /** * Creates a dataset for the specified series and the domain axis type. * * @param fDomainAxisTime true when domain axis is time of false when distance * @param series the series to be addet to the dataset * @return the created dataset */ private XYDataset createDataSet(final boolean fDomainAxisTime, final Series series) { if (fDomainAxisTime) { return new TimeSeriesCollection((TimeSeries) series); } else { return new XYSeriesCollection((XYSeries) series); } } /** * Fills the specified data to the left and right time series. * * @param sLeft the left TimeSeries * @param sRight the right TimeSeries (optional, can be null) * @param second the second for the current values * @param valueLeft the value of the left time series * @param valueRight the value of the right time series */ private void fillDataInTimeSeries(final TimeSeries sLeft, final TimeSeries sRight, final Second second, final Number valueLeft, final Number valueRight) { // don't add the data when the specified second was allready added if (sLeft.getValue(second) == null) { sLeft.add(second, valueLeft); if (sRight != null) { sRight.add(second, valueRight); } } } /** * Fills the specified data to the left and right XY series. * * @param sLeft the left XYSeries * @param sRight the right XYSeries (optional, can be null) * @param valueBottom the value of the bottom domain axis * @param valueLeft the value of the left time series * @param valueRight the value of the right time series */ private void fillDataInXYSeries(final XYSeries sLeft, final XYSeries sRight, final double valueBottom, final Number valueLeft, final Number valueRight) { sLeft.add(valueBottom, valueLeft); if (sRight != null) { sRight.add(valueBottom, valueRight); } } /** * Sets the tooltip generator for the specified renderer. * * @param renderer the renderer for the tooltip * @param domainAxis type of the domain axis * @param valueAxis type of the value axis */ private void setTooltipGenerator(final XYItemRenderer renderer, final AxisType domainAxis, final AxisType valueAxis) { final String format = "" + axisTypeStringConverter.toString(domainAxis) + ": {1}, " + axisTypeStringConverter.toString(valueAxis) + ": {2}"; if (domainAxis == AxisType.TIME) { final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm"); // all time values are using timezone GMT, so the formatter needs too timeFormat.setTimeZone(TIMEZONE_GMT); renderer.setBaseToolTipGenerator(new StandardXYToolTipGenerator(format, timeFormat, new DecimalFormat())); } else { renderer.setBaseToolTipGenerator(new StandardXYToolTipGenerator(format, new DecimalFormat(), new DecimalFormat())); } } /** * Returns the value specified by the axis type of the exercise sample. It also converts the value to the current * unit system and speed view. * * @param axisType the axis type to be displayed * @param sampleIndex index of the sample in the exercise * @return the requested value */ private Number getConvertedSampleValue(AxisType axisType, int sampleIndex) { if (axisType == AxisType.NOTHING) { return 0; } final FormatUtils formatUtils = getContext().getFormatUtils(); final double sampleValue = getSampleValue(axisType, sampleIndex); switch (axisType) { case HEARTRATE: case CADENCE: return sampleValue; case ALTITUDE: if (formatUtils.getUnitSystem() == FormatUtils.UnitSystem.Metric) { return sampleValue; } else { return ConvertUtils.convertMeter2Feet((int) Math.round(sampleValue)); } case SPEED: double speedValue = sampleValue; if (formatUtils.getUnitSystem() != FormatUtils.UnitSystem.Metric) { speedValue = ConvertUtils.convertKilometer2Miles(speedValue, false); } if (formatUtils.getSpeedView() == FormatUtils.SpeedView.MinutesPerDistance) { // convert speed to minutes per distance if (speedValue != 0f) { speedValue = 60 / speedValue; } } return speedValue; case TEMPERATURE: if (formatUtils.getUnitSystem() == FormatUtils.UnitSystem.Metric) { return sampleValue; } else { return ConvertUtils.convertCelsius2Fahrenheit((short) Math.round(sampleValue)); } default: throw new IllegalArgumentException("Unknown axis type: " + axisType + "!"); } } /** * Returns the value specified by the axis type of the exercise sample. If smoothed charts are enabled, then * the smoothed value will be calculated by using the average filter of the computed size. * * @param axisType the axis type to be displayed * @param sampleIndex index of the sample in the exercise * @return the requested value */ private double getSampleValue(AxisType axisType, int sampleIndex) { if (averagedRangeSteps <= 0) { // smoothing is disabled, just return the raw value return getRawSampleValue(axisType, sampleIndex); } else { // the value of 0 stays 0, otherwise short stops will not be visible final double rawSampleValue = getRawSampleValue(axisType, sampleIndex); if (rawSampleValue == 0d) { return 0d; } final int rangeLength = (2 * averagedRangeSteps) + 1; final int lastSampleIndex = getDocument().getExercise().getSampleList().length - 1; // create sum for all range values double valueSum = 0d; for (int i = sampleIndex - averagedRangeSteps; i <= sampleIndex + averagedRangeSteps; i++) { // exclude indices out of range, use first or last sample instead int valueIndex = Math.max(0, i); valueIndex = Math.min(lastSampleIndex, valueIndex); // ignore range values of 0 for the average, use the specified index instead double valueAtIndex = getRawSampleValue(axisType, valueIndex); if (valueAtIndex == 0d) { valueAtIndex = getRawSampleValue(axisType, sampleIndex); } valueSum += valueAtIndex; } return valueSum / rangeLength; } } private double getRawSampleValue(AxisType axisType, int sampleIndex) { final ExerciseSample sample = getDocument().getExercise().getSampleList()[sampleIndex]; switch (axisType) { case HEARTRATE: return sample.getHeartRate(); case ALTITUDE: return sample.getAltitude(); case SPEED: return sample.getSpeed(); case CADENCE: return sample.getCadence(); case TEMPERATURE: return sample.getTemperature(); default: throw new IllegalArgumentException("Unknown axis type: " + axisType + "!"); } } /** * Returns the value specified by the axis type of the exercise lap. It * also converts the value to the current unit system and speed view. * * @param axisType the axis type to be displayed * @param lap the exercise lap to display * @return the requested value */ private Number getLapValue(AxisType axisType, Lap lap) { final FormatUtils formatUtils = getContext().getFormatUtils(); switch (axisType) { case HEARTRATE: return lap.getHeartRateAVG(); case SPEED: float speed = lap.getSpeed().getSpeedAVG(); if (formatUtils.getUnitSystem() != FormatUtils.UnitSystem.Metric) { speed = (float) ConvertUtils.convertKilometer2Miles(speed, false); } if (formatUtils.getSpeedView() == FormatUtils.SpeedView.MinutesPerDistance) { // convert speed to minutes per distance if (speed != 0f) { speed = 60 / speed; } } return speed; default: return 0; } } /** * The list of possible value types to be shown on the diagram axes. This enum also provides the * the localized displayed enum names. */ private enum AxisType { NOTHING, HEARTRATE, ALTITUDE, SPEED, CADENCE, TEMPERATURE, TIME, DISTANCE } /** * StringConverter for the axis type choice boxes. It returns the name to be displayed for all * the available axis types. */ private static class AxisTypeStringConverter extends StringConverter<AxisType> { private AppResources appResources; private FormatUtils formatUtils; /** * Default c'tor. * * @param appResources application resources for I18N * @param formatUtils current format utils instance */ public AxisTypeStringConverter(final AppResources appResources, final FormatUtils formatUtils) { this.appResources = appResources; this.formatUtils = formatUtils; } @Override public String toString(final AxisType axisType) { switch (axisType) { case NOTHING: return appResources.getString("pv.diagram.axis.nothing"); case HEARTRATE: return appResources.getString("pv.diagram.axis.heartrate"); case ALTITUDE: return appResources.getString("pv.diagram.axis.altitude", formatUtils.getAltitudeUnitName()); case SPEED: return appResources.getString("pv.diagram.axis.speed", formatUtils.getSpeedUnitName()); case CADENCE: return appResources.getString("pv.diagram.axis.cadence"); case TEMPERATURE: return appResources.getString("pv.diagram.axis.temperature", formatUtils.getTemperatureUnitName()); case TIME: return appResources.getString("pv.diagram.axis.time"); case DISTANCE: return appResources.getString("pv.diagram.axis.distance", formatUtils.getDistanceUnitName()); default: throw new IllegalArgumentException("Invalid AxisType: '" + axisType + "'!"); } } @Override public AxisType fromString(final String string) { throw new UnsupportedOperationException(); } } }