From 824258e39f95bee90ee059c6612e8cd23b159f48 Mon Sep 17 00:00:00 2001 From: carbotaniuman <41451839+carbotaniuman@users.noreply.github.com> Date: Sat, 4 Jul 2020 15:24:52 -0500 Subject: [PATCH] Change GraphWidget to ChartFX (#657) * Initial switch to chart-fx * Added autoscroll toggle, range slider pans x-axis (#1) * Added autoscroll toggle, range slider pans x-axis * Took out random spaces * Checkstyle fixes * Bump BasePlugin version * Fix small bug with max and autoscroll * Increase the refresh rate of the graph slightly. * Remove ToggleButton and move autoscroll to the action menu * Add stuff to settings along with initial dark mode CSS * Dark mode works! * Update chart-fx * Address review comments * Address checkstyle and encapsulate graph updater Co-authored-by: Xzibit --- .../edu/wpi/first/shuffleboard/app/dark.css | 13 + .../wpi/first/shuffleboard/app/midnight.css | 12 + plugins/base/base.gradle.kts | 4 + .../shuffleboard/plugin/base/BasePlugin.java | 44 +- .../plugin/base/widget/GraphWidget.java | 431 ++++++++---------- .../base/widget/PrimitiveDoubleArrayList.java | 179 -------- .../plugin/base/widget/GraphWidget.fxml | 22 +- .../widget/PrimitiveDoubleArrayListTest.java | 91 ---- 8 files changed, 267 insertions(+), 529 deletions(-) delete mode 100644 plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/widget/PrimitiveDoubleArrayList.java delete mode 100644 plugins/base/src/test/java/edu/wpi/first/shuffleboard/plugin/base/widget/PrimitiveDoubleArrayListTest.java diff --git a/app/src/main/resources/edu/wpi/first/shuffleboard/app/dark.css b/app/src/main/resources/edu/wpi/first/shuffleboard/app/dark.css index d6df51683..e9ff71f5d 100644 --- a/app/src/main/resources/edu/wpi/first/shuffleboard/app/dark.css +++ b/app/src/main/resources/edu/wpi/first/shuffleboard/app/dark.css @@ -350,3 +350,16 @@ .settings-pane { -fx-border-color: -swatch-dark-gray; } + +/******************************************************************************* + * * + * Graph (axis) labels * + * * + ******************************************************************************/ +.axis-label { + -fx-fill: white; +} + +.axis { + -fx-tick-label-fill: white; +} \ No newline at end of file diff --git a/app/src/main/resources/edu/wpi/first/shuffleboard/app/midnight.css b/app/src/main/resources/edu/wpi/first/shuffleboard/app/midnight.css index 27960d9c9..6848fcaac 100644 --- a/app/src/main/resources/edu/wpi/first/shuffleboard/app/midnight.css +++ b/app/src/main/resources/edu/wpi/first/shuffleboard/app/midnight.css @@ -404,3 +404,15 @@ -fx-border-color: #171F2F; } +/******************************************************************************* + * * + * Graph (axis) labels * + * * + ******************************************************************************/ +.axis-label { + -fx-fill: white; +} + +.axis { + -fx-tick-label-fill: white; +} \ No newline at end of file diff --git a/plugins/base/base.gradle.kts b/plugins/base/base.gradle.kts index d1a6a0ec9..6c1586c68 100644 --- a/plugins/base/base.gradle.kts +++ b/plugins/base/base.gradle.kts @@ -1,3 +1,7 @@ description = """ Base shuffleboard plugin that provides the default data types and widgets. """.trimMargin() + +dependencies { + compile("de.gsi.chart:chartfx-chart:11.1.5") +} diff --git a/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/BasePlugin.java b/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/BasePlugin.java index 78482c6c9..4660054d4 100644 --- a/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/BasePlugin.java +++ b/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/BasePlugin.java @@ -1,12 +1,18 @@ package edu.wpi.first.shuffleboard.plugin.base; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; import edu.wpi.first.shuffleboard.api.PropertyParser; import edu.wpi.first.shuffleboard.api.data.DataType; import edu.wpi.first.shuffleboard.api.data.DataTypes; import edu.wpi.first.shuffleboard.api.json.ElementTypeAdapter; import edu.wpi.first.shuffleboard.api.plugin.Description; import edu.wpi.first.shuffleboard.api.plugin.Plugin; +import edu.wpi.first.shuffleboard.api.prefs.Group; +import edu.wpi.first.shuffleboard.api.prefs.Setting; import edu.wpi.first.shuffleboard.api.tab.TabInfo; +import edu.wpi.first.shuffleboard.api.util.PreferencesUtils; import edu.wpi.first.shuffleboard.api.widget.ComponentType; import edu.wpi.first.shuffleboard.api.widget.LayoutClass; import edu.wpi.first.shuffleboard.api.widget.WidgetType; @@ -61,23 +67,39 @@ import edu.wpi.first.shuffleboard.plugin.base.widget.ToggleSwitchWidget; import edu.wpi.first.shuffleboard.plugin.base.widget.UltrasonicWidget; import edu.wpi.first.shuffleboard.plugin.base.widget.VoltageViewWidget; - -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableSet; +import javafx.beans.InvalidationListener; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.prefs.Preferences; @Description( group = "edu.wpi.first.shuffleboard", name = "Base", - version = "1.2.0", + version = "1.3.0", summary = "Defines all the WPILib data types and stock widgets" ) @SuppressWarnings("PMD.CouplingBetweenObjects") public class BasePlugin extends Plugin { + private final Preferences preferences = Preferences.userNodeForPackage(getClass()); + private GraphWidget.Updater updater; + private InvalidationListener graphSaver; + + @Override + public void onLoad() { + this.updater = new GraphWidget.Updater(); + + this.graphSaver = n -> PreferencesUtils.save(updater.graphUpdateRateProperty(), preferences); + PreferencesUtils.read(updater.graphUpdateRateProperty(), preferences); + updater.graphUpdateRateProperty().addListener(graphSaver); + } + + @Override + public void onUnload() { + updater.graphUpdateRateProperty().removeListener(graphSaver); + updater.close(); + } @Override public List getDataTypes() { @@ -202,4 +224,16 @@ public List> getCustomTypeAdapters() { ); } + @Override + public List getSettings() { + return ImmutableList.of( + Group.of("Graph settings", + Setting.of("Graph Update Rate", + "How many times a second graph widgets update at. " + + "Faster update rates may cause performance issues", + updater.graphUpdateRateProperty() + ) + ) + ); + } } diff --git a/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/widget/GraphWidget.java b/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/widget/GraphWidget.java index 949a90636..73fcedf8b 100644 --- a/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/widget/GraphWidget.java +++ b/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/widget/GraphWidget.java @@ -18,18 +18,24 @@ import com.google.common.collect.ImmutableList; -import org.fxmisc.easybind.EasyBind; +import de.gsi.chart.XYChart; +import de.gsi.chart.axes.spi.DefaultNumericAxis; +import de.gsi.dataset.DataSet; +import de.gsi.dataset.spi.DoubleDataSet; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.OptionalLong; +import java.util.OptionalDouble; import java.util.WeakHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -37,51 +43,47 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; +import javafx.beans.property.IntegerProperty; import javafx.beans.property.Property; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; import javafx.fxml.FXML; -import javafx.scene.CacheHint; -import javafx.scene.Parent; -import javafx.scene.chart.NumberAxis; -import javafx.scene.chart.XYChart; -import javafx.scene.chart.XYChart.Data; -import javafx.scene.chart.XYChart.Series; import javafx.scene.layout.Pane; import javafx.util.StringConverter; + @Description(name = "Graph", dataTypes = {Number.class, double[].class}) @ParametrizedController("GraphWidget.fxml") @SuppressWarnings({"PMD.GodClass", "PMD.TooManyFields", "PMD.ExcessiveMethodLength"}) public class GraphWidget extends AbstractWidget implements AnnotatedWidget { - @FXML private Pane root; @FXML - private XYChart chart; + private XYChart chart; @FXML - private NumberAxis xAxis; + private DefaultNumericAxis xAxis; @FXML - private NumberAxis yAxis; + private DefaultNumericAxis yAxis; + private final BooleanProperty xAxisAutoScrolling = new SimpleBooleanProperty(true); private final BooleanProperty yAxisAutoRanging = new SimpleBooleanProperty(true); private final DoubleProperty yAxisMinBound = new SimpleDoubleProperty(-1); private final DoubleProperty yAxisMaxBound = new SimpleDoubleProperty(1); + private final StringProperty yAxisUnit = new SimpleStringProperty("ul"); + private final DoubleProperty visibleTime = new SimpleDoubleProperty(30); - private final Map, Series> numberSeriesMap = new HashMap<>(); - private final Map, List>> arraySeriesMap = new HashMap<>(); - private final DoubleProperty visibleTime = new SimpleDoubleProperty(this, "Visible time", 30); + private final Map, DoubleDataSet> numberSeriesMap = new HashMap<>(); + private final Map, List> arraySeriesMap = new HashMap<>(); - private final Map, BooleanProperty> visibleSeries = new HashMap<>(); + private final Map visibleSeries = new IdentityHashMap<>(); - private final Object queueLock = new Object(); - private final Map, List>> queuedData = new HashMap<>(); - - private final ChangeListener numberChangeLister = (property, oldNumber, newNumber) -> { + private final ChangeListener numberChangeListener = (property, oldNumber, newNumber) -> { final DataSource source = sourceFor(property); updateFromNumberSource(source); }; @@ -91,17 +93,15 @@ public class GraphWidget extends AbstractWidget implements AnnotatedWidget { updateFromArraySource(source); }; - private final Map, SimpleData> realData = new HashMap<>(); - - private final Function, BooleanProperty> createVisibleProperty = s -> { + private final Function createVisibleProperty = s -> { SimpleBooleanProperty visible = new SimpleBooleanProperty(this, s.getName(), true); visible.addListener((__, was, is) -> { if (is) { - if (!chart.getData().contains(s)) { - chart.getData().add(s); + if (!chart.getDatasets().contains(s)) { + chart.getDatasets().add(s); } } else { - chart.getData().remove(s); + chart.getDatasets().remove(s); } }); return visible; @@ -110,42 +110,77 @@ public class GraphWidget extends AbstractWidget implements AnnotatedWidget { /** * Keep track of all graph widgets so they update at the same time. * It's jarring to see a bunch of graphs all updating at different times + * */ private static final Collection graphWidgets = Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>())); - /** - * How often graphs should be redrawn, in milliseconds. - */ - private static final long UPDATE_PERIOD = 250; - - static { - ThreadUtils.newDaemonScheduledExecutorService() - .scheduleAtFixedRate(() -> { - synchronized (graphWidgets) { - graphWidgets.forEach(GraphWidget::update); - } - }, 500, UPDATE_PERIOD, TimeUnit.MILLISECONDS); + public static class Updater implements AutoCloseable { + + private final IntegerProperty graphUpdateRate = new SimpleIntegerProperty(this, "graphUpdateRate", 10); + private final ScheduledExecutorService executorService = ThreadUtils.newDaemonScheduledExecutorService(); + private volatile ScheduledFuture currentFuture; + + private final ChangeListener updateCreator = (observable, oldValue, newValue) -> { + if (currentFuture != null) { + currentFuture.cancel(false); + } + + long amount = 1000L / newValue.intValue(); + if (amount == 0) { + amount = 1; + } + + currentFuture = executorService + .scheduleAtFixedRate(this::updateAll, 500, amount, TimeUnit.MILLISECONDS); + }; + + public Updater() { + graphUpdateRate.addListener(updateCreator); + updateCreator.changed(null, null, graphUpdateRate.get()); + } + + private void updateAll() { + graphWidgets.forEach(GraphWidget::update); + } + + public int getGraphUpdateRate() { + return graphUpdateRate.get(); + } + + public IntegerProperty graphUpdateRateProperty() { + return graphUpdateRate; + } + + public void setGraphUpdateRate(int graphUpdateRate) { + this.graphUpdateRate.set(graphUpdateRate); + } + + @Override + public void close() { + graphUpdateRate.removeListener(updateCreator); + executorService.shutdown(); + } } @FXML private void initialize() { + chart.setAutoNotification(false); + yAxis.unitProperty().bind(yAxisUnit); + yAxisAutoRanging.addListener((__, was, useAutoRanging) -> { if (useAutoRanging) { - yAxis.lowerBoundProperty().unbind(); - yAxis.upperBoundProperty().unbind(); - yAxis.tickUnitProperty().unbind(); + yAxis.minProperty().unbind(); + yAxis.maxProperty().unbind(); + yAxis.setAutoRanging(true); } else { yAxis.setAutoRanging(false); - yAxis.lowerBoundProperty().bind(yAxisMinBound); - yAxis.upperBoundProperty().bind(yAxisMaxBound); - - // Enforce 11 tick marks like the default autoranging behavior - yAxis.tickUnitProperty().bind( - EasyBind.combine(yAxisMinBound, yAxisMaxBound, (min, max) -> (max.doubleValue() - min.doubleValue()) / 10)); + yAxis.minProperty().bind(yAxisMinBound); + yAxis.maxProperty().bind(yAxisMaxBound); } }); + chart.legendVisibleProperty().bind( Bindings.createBooleanBinding(() -> sources.size() > 1, sources)); sources.addListener((ListChangeListener) c -> { @@ -153,9 +188,9 @@ private void initialize() { if (c.wasAdded()) { c.getAddedSubList().forEach(source -> { if (source.getDataType() == NumberType.Instance) { - source.dataProperty().addListener(numberChangeLister); + source.dataProperty().addListener(numberChangeListener); if (source.isConnected()) { - numberChangeLister.changed(source.dataProperty(), null, (Number) source.getData()); + numberChangeListener.changed(source.dataProperty(), null, (Number) source.getData()); } } else if (source.getDataType() == NumberArrayType.Instance) { source.dataProperty().addListener(numberArrayChangeListener); @@ -168,13 +203,14 @@ private void initialize() { }); } else if (c.wasRemoved()) { c.getRemoved().forEach(source -> { - source.dataProperty().removeListener(numberChangeLister); + source.dataProperty().removeListener(numberChangeListener); source.dataProperty().removeListener(numberArrayChangeListener); }); } } }); - xAxis.setTickLabelFormatter(new StringConverter() { + + xAxis.setTickLabelFormatter(new StringConverter<>() { @Override public String toString(Number num) { final int seconds = num.intValue() / 1000; @@ -191,54 +227,30 @@ public Number fromString(String string) { } }); - xAxis.lowerBoundProperty().bind(xAxis.upperBoundProperty().subtract(visibleTime.multiply(1e3))); - - // Make sure data gets re-added to the chart - visibleTime.addListener((__, prev, cur) -> { - if (cur.doubleValue() > prev.doubleValue()) { - // insert data at the beginning of each series - realData.forEach((series, dataList) -> { - List> toAdd = new ArrayList<>(); - for (int i = 0; i < dataList.getXValues().size(); i++) { - double x = dataList.getXValues().get(i); - if (x >= xAxis.getLowerBound()) { - if (x < series.getData().get(0).getXValue().doubleValue()) { - Data data = dataList.asData(i); - if (!toAdd.isEmpty()) { - Data squarifier = new Data<>( - data.getXValue().doubleValue() - 1, - toAdd.get(toAdd.size() - 1).getYValue().doubleValue() - ); - toAdd.add(squarifier); - } - toAdd.add(data); - } else { - break; - } - } - } - series.getData().addAll(0, toAdd); - }); - } - }); + ActionList.registerSupplier(root, () -> + ActionList.withName(getTitle()) + .addAction("Clear", this::clear)); + + ActionList.registerSupplier(root, () -> + ActionList.withName(getTitle()) + .addAction("Toggle X-Axis Autoscroll", + () -> this.xAxisAutoScrolling.set(!this.xAxisAutoScrolling.get()))); - ActionList.registerSupplier(root, () -> { - return ActionList.withName(getTitle()) - .addAction("Clear", () -> { - synchronized (queueLock) { - chart.getData().forEach(s -> s.getData().clear()); - queuedData.forEach((s, q) -> q.clear()); - realData.forEach((s, d) -> d.clear()); - } - }); - }); // Add this widget to the list only after everything is initialized to prevent occasional null pointers when // the update thread runs after construction but before FXML injection or initialization synchronized (graphWidgets) { graphWidgets.add(this); } + } + private void clear() { + chart.getDatasets().forEach(s -> { + var doubleDataSet = (DoubleDataSet) s; + doubleDataSet.lock().writeLockGuard( + doubleDataSet::clearData + ); + }); } @SuppressWarnings("unchecked") @@ -259,19 +271,17 @@ private DataSource sourceFor(ObservableValue property) { private void updateFromNumberSource(DataSource source) { final long now = Time.now(); - final Series series = getNumberSeries(source); + final DoubleDataSet series = getNumberSeries(source); // The update HAS TO run on the FX thread, otherwise we run the risk of ConcurrentModificationExceptions // when the chart goes to lay out the data - FxUtils.runOnFxThread(() -> { - updateSeries(series, now, source.getData().doubleValue()); - }); + FxUtils.runOnFxThread(() -> updateSeries(series, now, source.getData().doubleValue())); } private void updateFromArraySource(DataSource source) { final long now = System.currentTimeMillis(); final double[] data = source.getData(); - final List> series = getArraySeries(source); + final List series = getArraySeries(source); // The update HAS TO run on the FX thread, otherwise we run the risk of ConcurrentModificationExceptions // when the chart goes to lay out the data @@ -282,114 +292,115 @@ private void updateFromArraySource(DataSource source) { }); } - private void updateSeries(Series series, long now, double newData) { + private void updateSeries(DoubleDataSet data, long now, double nextValue) { final long elapsed = now - Time.getStartTime(); - final Data point = new Data<>(elapsed, newData); - final ObservableList> dataList = series.getData(); - Data squarifier = null; - realData.computeIfAbsent(series, __ -> new SimpleData()).add(point); - synchronized (queueLock) { - List> queue = queuedData.computeIfAbsent(series, __ -> new ArrayList<>()); - if (queue.isEmpty()) { - if (!dataList.isEmpty()) { - squarifier = createSquarifier(newData, elapsed, dataList); - } - } else { - squarifier = createSquarifier(newData, elapsed, queue); - } - if (squarifier != null) { - queue.add(squarifier); + + + data.lock().writeLockGuard(() -> { + // So getValues() does not return an array of all the elements, but instead returns the + // backing array of the ArrayList?? This means it can be followed by trailing zeros, + // hence the call to getDataCount(). + + // This code here makes the graph square wave and prevents discrete points + // from appearing continuous. + double[] yValues = data.getValues(DataSet.DIM_Y); + if (data.getDataCount(DataSet.DIM_Y) > 1 && yValues[yValues.length - 1] != nextValue) { + data.add(elapsed - 1, yValues[data.getDataCount(DataSet.DIM_Y) - 1]); } - queue.add(point); - } - if (!chart.getData().contains(series) - && Optional.ofNullable(visibleSeries.get(series)).map(Property::getValue).orElse(true)) { - chart.getData().add(series); + + data.add(elapsed, nextValue); + }); + + boolean dataVisible = Optional.ofNullable(visibleSeries.get(data)).map(Property::getValue).orElseThrow(); + + // if listeners do not work (initial value mainly) + if (!chart.getDatasets().contains(data) && dataVisible) { + chart.getDatasets().add(data); } - } - private Data createSquarifier(double newData, long elapsed, List> queue) { - // Make the graph a square wave - // This prevents the graph from appearing to be continuous when the data is discreet - // Note this only affects the chart; the actual data is not changed - Data squarifier = null; - double prev = queue.get(queue.size() - 1).getYValue().doubleValue(); - if (prev != newData) { - squarifier = new Data<>(elapsed - 1, prev); + if (chart.getDatasets().contains(data) && !dataVisible) { + chart.getDatasets().remove(data); } - return squarifier; } - private Series getNumberSeries(DataSource source) { + private DoubleDataSet getNumberSeries(DataSource source) { if (!numberSeriesMap.containsKey(source)) { - Series series = new Series<>(); - series.setName(source.getName()); + DoubleDataSet series = new DoubleDataSet(source.getName()); numberSeriesMap.put(source, series); - realData.put(series, new SimpleData()); visibleSeries.computeIfAbsent(series, createVisibleProperty); - series.nodeProperty().addListener((__, old, node) -> { - if (node instanceof Parent) { - Parent parent = (Parent) node; - parent.getChildrenUnmodifiable().forEach(child -> { - child.setCache(true); - child.setCacheHint(CacheHint.SPEED); - }); - parent.setCache(true); - parent.setCacheHint(CacheHint.SPEED); - } - }); } return numberSeriesMap.get(source); } - private List> getArraySeries(DataSource source) { - List> series = arraySeriesMap.computeIfAbsent(source, __ -> new ArrayList<>()); + private List getArraySeries(DataSource source) { + List series = arraySeriesMap.computeIfAbsent(source, __ -> new ArrayList<>()); final double[] data = source.getData(); if (data.length < series.size()) { while (series.size() != data.length) { - Series removed = series.remove(series.size() - 1); - realData.remove(removed); + DoubleDataSet removed = series.remove(series.size() - 1); visibleSeries.remove(removed); } } else if (data.length > series.size()) { for (int i = series.size(); i < data.length; i++) { - Series newSeries = new Series<>(); - newSeries.setName(source.getName() + "[" + i + "]"); // eg "array[0]", "array[1]", etc + DoubleDataSet newSeries = new DoubleDataSet(source.getName() + "[" + i + "]"); series.add(newSeries); - realData.put(newSeries, new SimpleData()); visibleSeries.computeIfAbsent(newSeries, createVisibleProperty); } } return series; } - private void updateBounds(long elapsed) { - xAxis.setUpperBound(elapsed); - removeInvisibleData(); - } - private void update() { FxUtils.runOnFxThread(() -> { - if (chart.getData().isEmpty()) { - return; + // Data is only pushed to the graph via listeners, so this prevents the graph + // from staying still during a period of no updates. + for (var source : numberSeriesMap.keySet()) { + numberChangeListener.changed(source.dataProperty(), null, source.getData()); } - synchronized (queueLock) { - queuedData.forEach((series, queuedData) -> series.getData().addAll(queuedData)); - queuedData.forEach((series, queuedData) -> queuedData.clear()); - OptionalLong maxX = chart.getData().stream() - .map(Series::getData) - .filter(d -> !d.isEmpty()) - .map(d -> d.get(d.size() - 1)) - .map(Data::getXValue) - .mapToLong(Number::longValue) - .max(); - if (maxX.isPresent()) { - updateBounds(maxX.getAsLong()); - } + + for (var source : arraySeriesMap.keySet()) { + numberArrayChangeListener.changed(source.dataProperty(), null, source.getData()); } + + rerenderGraph(); }); } + private void rerenderGraph() { + OptionalDouble globalMax = OptionalDouble.empty(); + for (DataSet s : chart.getDatasets()) { + var doubleDataSet = (DoubleDataSet) s; + + OptionalDouble dataSetMax = doubleDataSet.lock().readLockGuard(() -> { + + if (doubleDataSet.getDataCount(DataSet.DIM_X) == 0) { + return OptionalDouble.empty(); + } + + double[] xValues = doubleDataSet.getValues(DataSet.DIM_X); + return OptionalDouble.of(xValues[doubleDataSet.getDataCount(DataSet.DIM_X) - 1]); + }); + + if (dataSetMax.isPresent()) { + if (globalMax.isPresent() && dataSetMax.getAsDouble() > globalMax.getAsDouble()) { + globalMax = dataSetMax; + } else if (globalMax.isEmpty()) { + globalMax = dataSetMax; + } + } + + doubleDataSet.fireInvalidated(null); + } + + if (xAxisAutoScrolling.get() && globalMax.isPresent()) { + xAxis.maxProperty().set(globalMax.getAsDouble()); + xAxis.minProperty().bind(xAxis.maxProperty().subtract(visibleTime.multiply(1e3))); + } else { + xAxis.maxProperty().unbind(); + xAxis.minProperty().unbind(); + } + } + @Override public Pane getView() { return root; @@ -408,7 +419,8 @@ public void addSource(DataSource source) throws IncompatibleSourceException { public List getSettings() { return ImmutableList.of( Group.of("Graph", - Setting.of("Visible time", visibleTime, Double.class) + Setting.of("Visible time", visibleTime, Double.class), + Setting.of("X-axis auto scrolling", "Automatically scroll the x-axis", xAxisAutoScrolling, Boolean.class) ), // Note: users can set the lower bound to be greater than the upper bound, resulting in an upside-down graph Group.of("Y-axis", @@ -429,6 +441,12 @@ public List getSettings() { "Force a minimum value. Requires 'Automatic bounds' to be disabled", yAxisMinBound, Double.class + ), + Setting.of( + "Unit", + "The unit displayed on the y-axis", + yAxisUnit, + String.class ) ), Group.of("Visible data", @@ -440,75 +458,4 @@ public List getSettings() { ) ); } - - public double getVisibleTime() { - return visibleTime.get(); - } - - public long getVisibleTimeMs() { - return (long) (getVisibleTime() * 1000); - } - - public DoubleProperty visibleTimeProperty() { - return visibleTime; - } - - public void setVisibleTime(double visibleTime) { - this.visibleTime.set(visibleTime); - } - - /** - * Removes data from the data series that is outside the visible chart area to improve performance. - */ - private void removeInvisibleData() { - final double lower = xAxis.getLowerBound(); - realData.forEach((series, dataList) -> { - int firstBeforeOutOfRange = -1; - for (int i = 0; i < series.getData().size(); i++) { - Data data = series.getData().get(i); - if (data.getXValue().doubleValue() >= lower) { - firstBeforeOutOfRange = i; - break; - } - } - if (firstBeforeOutOfRange > 0) { - series.getData().remove(0, firstBeforeOutOfRange); - } - }); - } - - /** - * Stores data in two parallel arrays. - */ - private static final class SimpleData { - private final PrimitiveDoubleArrayList xValues = new PrimitiveDoubleArrayList(); - private final PrimitiveDoubleArrayList yValues = new PrimitiveDoubleArrayList(); - - public void add(double x, double y) { - xValues.add(x); - yValues.add(y); - } - - public void add(Data point) { - add(point.getXValue().doubleValue(), point.getYValue().doubleValue()); - } - - public PrimitiveDoubleArrayList getXValues() { - return xValues; - } - - public PrimitiveDoubleArrayList getYValues() { - return yValues; - } - - public Data asData(int index) { - return new Data<>(xValues.get(index), yValues.get(index)); - } - - public void clear() { - xValues.clear(); - yValues.clear(); - } - } - } diff --git a/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/widget/PrimitiveDoubleArrayList.java b/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/widget/PrimitiveDoubleArrayList.java deleted file mode 100644 index caeade9f6..000000000 --- a/plugins/base/src/main/java/edu/wpi/first/shuffleboard/plugin/base/widget/PrimitiveDoubleArrayList.java +++ /dev/null @@ -1,179 +0,0 @@ -package edu.wpi.first.shuffleboard.plugin.base.widget; - -import java.util.Arrays; -import java.util.Iterator; -import java.util.NoSuchElementException; -import java.util.function.Consumer; -import java.util.stream.DoubleStream; - -/** - * An arraylist of primitive doubles. This class behaves much like {@link java.util.ArrayList}. - * - *

The size, isEmpty, get, and - * iterator operations run in constant - * time. The add operation runs in amortized constant time, - * that is, adding n elements requires O(n) time. All of the other operations - * run in linear time (roughly speaking). The constant factor is low compared - * to that for the LinkedList implementation. - */ -public class PrimitiveDoubleArrayList implements Iterable { - - private static final int INITIAL_SIZE = 16; - - private double[] array = new double[INITIAL_SIZE]; - private int size = 0; - - /** - * Creates a new array list with space for 16 initial values. - */ - public PrimitiveDoubleArrayList() { - this(INITIAL_SIZE); - } - - /** - * Creates a new array list with a given size. - * - * @param initialSize the initial number of values the list should contain before needing to be resized - */ - public PrimitiveDoubleArrayList(int initialSize) { - if (initialSize <= 0) { - throw new IllegalArgumentException("Initial size must be at least 1, was given: " + initialSize); - } - array = new double[initialSize]; - } - - /** - * Adds a value to the end of this list. - * - * @param value the value to add - */ - public void add(double value) { - ensureCapacity(size + 1); - array[size] = value; - size++; - } - - /** - * Gets the value at the given index. - * - * @param index the index to get the value at - * - * @return the value at the given index - * - * @throws IndexOutOfBoundsException if index is negative or greater than the upper index - */ - public double get(int index) { - if (index < 0 || index >= size) { - throw new IndexOutOfBoundsException("Index must be in [0," + size + "), but was " + index); - } - return array[index]; - } - - /** - * Removes excess entries from the backing array. - */ - public void trimToSize() { - if (size == array.length) { - return; - } - double[] trimmed = new double[size]; - System.arraycopy(array, 0, trimmed, 0, size); - array = trimmed; - } - - /** - * Gets the number of elements in this list. - * - * @return the number of elements in this list - */ - public int size() { - return size; - } - - /** - * Checks if this list contains any elements. - * - * @return false if this list contains anything, true if this list contains at least one element - */ - public boolean isEmpty() { - return size == 0; - } - - /** - * Removes all elements from this list. - */ - public void clear() { - array = new double[INITIAL_SIZE]; - size = 0; - } - - /** - * Creates a stream of all the elements in this list. - */ - public DoubleStream stream() { - if (size == 0) { - return DoubleStream.empty(); - } - return Arrays.stream(array, 0, size); - } - - /** - * Ensures that this list can contain at least capacity number of elements. This will grow the backing array - * if needed. - * - * @param capacity the desired capacity of the list - */ - private void ensureCapacity(int capacity) { - if (array.length < capacity) { - double[] bigger; - if (array.length > Integer.MAX_VALUE / 2) { - bigger = new double[Integer.MAX_VALUE]; - } else { - bigger = new double[array.length * 2]; - } - System.arraycopy(array, 0, bigger, 0, array.length); - array = bigger; - } - } - - @Override - public Iterator iterator() { - return new Iterator() { - private int index = 0; - - @Override - public boolean hasNext() { - return index < size(); - } - - @Override - public Double next() { - if (!hasNext()) { - throw new NoSuchElementException(); - } - return get(index++); - } - }; - } - - @Override - public void forEach(Consumer action) { - for (int i = 0; i < size; i++) { - action.accept(array[i]); - } - } - - /** - * Creates a new primitive double array containing all the values in this list. - * - * @return a new double array - */ - public double[] toArray() { - double[] arr = new double[size]; - if (size > 0) { - System.arraycopy(array, 0, arr, 0, size); - } - return arr; - } - -} diff --git a/plugins/base/src/main/resources/edu/wpi/first/shuffleboard/plugin/base/widget/GraphWidget.fxml b/plugins/base/src/main/resources/edu/wpi/first/shuffleboard/plugin/base/widget/GraphWidget.fxml index bb7c93798..8b483ebd4 100644 --- a/plugins/base/src/main/resources/edu/wpi/first/shuffleboard/plugin/base/widget/GraphWidget.fxml +++ b/plugins/base/src/main/resources/edu/wpi/first/shuffleboard/plugin/base/widget/GraphWidget.fxml @@ -1,20 +1,18 @@ - - + + + - - - - - - - - + + + + + + \ No newline at end of file diff --git a/plugins/base/src/test/java/edu/wpi/first/shuffleboard/plugin/base/widget/PrimitiveDoubleArrayListTest.java b/plugins/base/src/test/java/edu/wpi/first/shuffleboard/plugin/base/widget/PrimitiveDoubleArrayListTest.java deleted file mode 100644 index 4bf48b063..000000000 --- a/plugins/base/src/test/java/edu/wpi/first/shuffleboard/plugin/base/widget/PrimitiveDoubleArrayListTest.java +++ /dev/null @@ -1,91 +0,0 @@ -package edu.wpi.first.shuffleboard.plugin.base.widget; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.function.Executable; - -import java.util.stream.IntStream; - -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -public class PrimitiveDoubleArrayListTest { - - @Test - public void testEmpty() { - PrimitiveDoubleArrayList list = new PrimitiveDoubleArrayList(); - assertAll( - () -> assertEquals(0, list.size(), "Size is not zero"), - () -> assertTrue(list.isEmpty(), "List is not empty") - ); - } - - @Test - public void testAdd() { - PrimitiveDoubleArrayList list = new PrimitiveDoubleArrayList(); - list.add(12.34); - list.add(Math.PI); - assertAll( - () -> assertEquals(2, list.size(), "Size is not two"), - () -> assertFalse(list.isEmpty(), "List should not be empty"), - () -> assertEquals(12.34, list.get(0)), - () -> assertEquals(Math.PI, list.get(1)) - ); - } - - @Test - public void testAddALot() { - PrimitiveDoubleArrayList list = new PrimitiveDoubleArrayList(); - for (int i = 0; i < 256; i++) { - list.add(i); - } - assertEquals(256, list.size()); - assertAll(IntStream.range(0, 256).mapToObj(i -> (Executable) () -> assertEquals((double) i, list.get(i)))); - } - - @Test - public void testToArray() { - PrimitiveDoubleArrayList list = new PrimitiveDoubleArrayList(); - for (int i = 0; i < 256; i++) { - list.add(i); - } - double[] array = list.toArray(); - assertEquals(256, array.length); - assertAll(IntStream.range(0, 256).mapToObj(i -> (Executable) () -> assertEquals(list.get(i), array[i]))); - } - - @Test - public void testStream() { - PrimitiveDoubleArrayList list = new PrimitiveDoubleArrayList(); - for (int i = 0; i < 256; i++) { - list.add(i); - } - assertAll(list.stream() - .mapToObj(d -> (Executable) () -> assertEquals(d, list.get((int) d)))); - } - - @Test - public void testEmptyIterator() { - int count = 0; - PrimitiveDoubleArrayList list = new PrimitiveDoubleArrayList(); - for (double value : list) { - count++; - } - assertEquals(0, count); - } - - @Test - public void testPopulatedIterator() { - PrimitiveDoubleArrayList list = new PrimitiveDoubleArrayList(); - for (int i = 0; i < 256; i++) { - list.add(i); - } - int count = 0; - for (double value : list) { - count++; - } - assertEquals(count, list.size()); - } - -}