Improve readability of the daily burnt BSQ chart

Relevant issue thread: #3753

Currently the daily burnt BSQ chart under 'DAO -> Facts and Figures' is
distorted by outliers. This introduces a 'Zoom to inliers' toggle (off
by default), which, when toggled on, effectively zooms the chart to
inliers, thus removing the distortion. Also, a moving average is
plotted, to further improve the chart's readibility.

The chart is also changed from an area chart to a line chart, on the
presumption that it was an area chart for cosmetic reasons, but now that
there are two series in it (the moving average was added) an area chart
makes less sense.

Another noteworthy change is that the other chart in the screen, monthly
issued BSQ, has its Y axis set to start at zero, so as to improve
readability. This might seem outside the scope of this commit, but the
other changes involved some refactoring, which involved cleaning up some
duplicated logic, which involved configuring both of these charts
together, which involved forcing zero to be on the axis.

This implementation mixes some plotting logic (responsible for zooming
in on inliers) into the view logic, because I opted to implement said
zooming as an external manipulation of a chart's axis. I chose this in
favor of implementing a new Chart, because it would have required
including multiple large classes (relevant JavaFX's classes can't be
ergonomically extended) to the code base. I presumed that my chosen
solution will be easier to maintain.

I am not entirely happy with this choice and can see myself introducing
some plotting-related classes to encapsulate creating charts like these,
thus unmixing plotting logic from view logic. In the meantime this is a
working solution, and I plan to continue working on these charts in the
near future.
This commit is contained in:
Dominykas Mostauskis 2020-01-18 15:25:10 +02:00
parent c6941cf412
commit 86489e0d74
No known key found for this signature in database
GPG key ID: B5DA7DD87C5D5FB0
5 changed files with 309 additions and 51 deletions

View file

@ -2251,6 +2251,8 @@ dao.factsAndFigures.supply.compRequestIssueAmount=BSQ issued for compensation re
dao.factsAndFigures.supply.reimbursementAmount=BSQ issued for reimbursement requests
dao.factsAndFigures.supply.burnt=BSQ burnt
dao.factsAndFigures.supply.burntMovingAverage=15 days moving average
dao.factsAndFigures.supply.burntZoomToInliers=Zoom to inliers
dao.factsAndFigures.supply.locked=Global state of locked BSQ
dao.factsAndFigures.supply.totalLockedUpAmount=Locked up in bonds

View file

@ -1621,6 +1621,17 @@ textfield */
-fx-stroke: -bs-buy;
}
/* The .chart-line-symbol rules change the color of the legend symbol */
#charts-dao .default-color0.chart-series-line { -fx-stroke: -bs-chart-dao-line1; }
#charts-dao .default-color0.chart-line-symbol { -fx-background-color: -bs-chart-dao-line1, -bs-background-color; }
#charts-dao .default-color1.chart-series-line { -fx-stroke: -bs-chart-dao-line2; }
#charts-dao .default-color1.chart-line-symbol { -fx-background-color: -bs-chart-dao-line2, -bs-background-color; }
#charts-dao .chart-series-line {
-fx-stroke-width: 1px;
}
#charts .default-color0.chart-series-area-fill {
-fx-fill: -bs-sell-transparent;
}

View file

@ -20,7 +20,9 @@ package bisq.desktop.main.dao.economy.supply;
import bisq.desktop.common.view.ActivatableView;
import bisq.desktop.common.view.FxmlView;
import bisq.desktop.components.TitledGroupBg;
import bisq.desktop.util.AxisInlierUtils;
import bisq.desktop.util.Layout;
import bisq.desktop.util.MovingAverageUtils;
import bisq.core.dao.DaoFacade;
import bisq.core.dao.state.DaoStateListener;
@ -39,18 +41,24 @@ import org.bitcoinj.core.Coin;
import javax.inject.Inject;
import javafx.scene.Node;
import javafx.scene.chart.AreaChart;
import javafx.scene.chart.LineChart;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.XYChart;
import javafx.scene.control.Label;
import javafx.scene.control.TextField;
import javafx.scene.control.ToggleButton;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.VBox;
import javafx.geometry.Insets;
import javafx.geometry.Side;
import javafx.collections.ListChangeListener;
import javafx.util.StringConverter;
import java.time.LocalDate;
@ -68,9 +76,15 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Spliterators.AbstractSpliterator;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import static bisq.desktop.util.FormBuilder.addSlideToggleButton;
import static bisq.desktop.util.FormBuilder.addTitledGroupBg;
import static bisq.desktop.util.FormBuilder.addTopLabelReadOnlyTextField;
@ -92,7 +106,17 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
private TextField genesisIssueAmountTextField, compRequestIssueAmountTextField, reimbursementAmountTextField,
totalBurntFeeAmountTextField, totalLockedUpAmountTextField, totalUnlockingAmountTextField,
totalUnlockedAmountTextField, totalConfiscatedAmountTextField, totalAmountOfInvalidatedBsqTextField;
private XYChart.Series<Number, Number> seriesBSQIssued, seriesBSQBurnt;
private XYChart.Series<Number, Number> seriesBSQIssued, seriesBSQBurnt, seriesBSQBurntMA;
private ListChangeListener changeListenerBSQBurnt;
private NumberAxis yAxisBSQBurnt;
private ToggleButton zoomToInliersSlide;
private boolean isZoomingToInliers = false;
// Parameters for zooming to inliers; explanations in AxisInlierUtils.
private int chartMaxNumberOfTicks = 10;
private double chartPercentToTrim = 5;
private double chartHowManyStdDevsConstituteOutlier = 10;
private static final Map<String, TemporalAdjuster> ADJUSTERS = new HashMap<>();
@ -112,12 +136,12 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
@Override
public void initialize() {
ADJUSTERS.put(MONTH, TemporalAdjusters.firstDayOfMonth());
ADJUSTERS.put(DAY, TemporalAdjusters.ofDateAdjuster(d -> d));
createSupplyIncreasedInformation();
createSupplyReducedInformation();
createSupplyLockedInformation();
}
@ -125,12 +149,22 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
protected void activate() {
daoFacade.addBsqStateListener(this);
if (isZoomingToInliers) {
activateZoomingToInliers();
}
updateWithBsqBlockChainData();
activateButtons();
}
@Override
protected void deactivate() {
daoFacade.removeBsqStateListener(this);
deactivateZoomingToInliers();
deactivateButtons();
}
@ -163,7 +197,11 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
seriesBSQIssued = new XYChart.Series<>();
createChart(seriesBSQIssued, Res.get("dao.factsAndFigures.supply.issued"), "MMM uu");
var chart = createBSQIssuedChart(seriesBSQIssued);
var chartPane = wrapInChartPane(chart);
root.getChildren().add(chartPane);
}
private void createSupplyReducedInformation() {
@ -174,8 +212,16 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
totalAmountOfInvalidatedBsqTextField = addTopLabelReadOnlyTextField(root, gridRow, 1,
Res.get("dao.factsAndFigures.supply.invalidTxs"), Layout.FIRST_ROW_AND_GROUP_DISTANCE).second;
var buttonTitle = Res.get("dao.factsAndFigures.supply.burntZoomToInliers");
zoomToInliersSlide = addSlideToggleButton(root, ++gridRow, buttonTitle);
seriesBSQBurnt = new XYChart.Series<>();
createChart(seriesBSQBurnt, Res.get("dao.factsAndFigures.supply.burnt"), "d MMM");
seriesBSQBurntMA = new XYChart.Series<>();
var chart = createBSQBurntChart(seriesBSQBurnt, seriesBSQBurntMA);
var chartPane = wrapInChartPane(chart);
root.getChildren().add(chartPane);
}
private void createSupplyLockedInformation() {
@ -195,14 +241,87 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
Res.get("dao.factsAndFigures.supply.totalConfiscatedAmount")).second;
}
private void createChart(XYChart.Series<Number, Number> series, String seriesLabel, String datePattern) {
private Node createBSQIssuedChart(XYChart.Series<Number, Number> series) {
NumberAxis xAxis = new NumberAxis();
xAxis.setForceZeroInRange(false);
xAxis.setAutoRanging(true);
xAxis.setTickLabelGap(6);
xAxis.setTickMarkVisible(false);
xAxis.setMinorTickVisible(false);
xAxis.setTickLabelFormatter(new StringConverter<>() {
configureAxis(xAxis);
xAxis.setTickLabelFormatter(getTimestampTickLabelFormatter("MMM uu"));
NumberAxis yAxis = new NumberAxis();
configureYAxis(yAxis);
yAxis.setTickLabelFormatter(BSQPriceTickLabelFormatter);
AreaChart<Number, Number> chart = new AreaChart<>(xAxis, yAxis);
configureChart(chart);
series.setName(Res.get("dao.factsAndFigures.supply.issued"));
chart.getData().add(series);
return chart;
}
private Node createBSQBurntChart(
XYChart.Series<Number, Number> seriesBSQBurnt,
XYChart.Series<Number, Number> seriesBSQBurntMA
) {
Supplier<NumberAxis> makeXAxis = () -> {
NumberAxis xAxis = new NumberAxis();
configureAxis(xAxis);
xAxis.setTickLabelFormatter(getTimestampTickLabelFormatter("d MMM"));
return xAxis;
};
Supplier<NumberAxis> makeYAxis = () -> {
NumberAxis yAxis = new NumberAxis();
configureYAxis(yAxis);
yAxis.setTickLabelFormatter(BSQPriceTickLabelFormatter);
return yAxis;
};
seriesBSQBurnt.setName(Res.get("dao.factsAndFigures.supply.burnt"));
var burntMALabel = Res.get("dao.factsAndFigures.supply.burntMovingAverage");
seriesBSQBurntMA.setName(burntMALabel);
var yAxis = makeYAxis.get();
initializeChangeListener(yAxis);
var chart = new LineChart<>(makeXAxis.get(), yAxis);
chart.getData().addAll(seriesBSQBurnt, seriesBSQBurntMA);
configureChart(chart);
chart.setCreateSymbols(false);
chart.setLegendVisible(true);
return chart;
}
private void initializeChangeListener(NumberAxis axis) {
// Keep a class-scope reference. Needed for switching between inliers-only and full chart.
yAxisBSQBurnt = axis;
changeListenerBSQBurnt = AxisInlierUtils.getListenerThatZoomsToInliers(
yAxisBSQBurnt, chartMaxNumberOfTicks, chartPercentToTrim, chartHowManyStdDevsConstituteOutlier);
}
private void configureYAxis(NumberAxis axis) {
configureAxis(axis);
axis.setForceZeroInRange(true);
axis.setTickLabelGap(5);
axis.setSide(Side.RIGHT);
}
private void configureAxis(NumberAxis axis) {
axis.setForceZeroInRange(false);
axis.setAutoRanging(true);
axis.setTickMarkVisible(false);
axis.setMinorTickVisible(false);
axis.setTickLabelGap(6);
}
private StringConverter<Number> getTimestampTickLabelFormatter(String datePattern) {
return new StringConverter<Number>() {
@Override
public String toString(Number timestamp) {
LocalDateTime localDateTime = LocalDateTime.ofEpochSecond(timestamp.longValue(),
@ -214,54 +333,48 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
public Number fromString(String string) {
return 0;
}
});
};
}
NumberAxis yAxis = new NumberAxis();
yAxis.setForceZeroInRange(false);
yAxis.setSide(Side.RIGHT);
yAxis.setAutoRanging(true);
yAxis.setTickMarkVisible(false);
yAxis.setMinorTickVisible(false);
yAxis.setTickLabelGap(5);
yAxis.setTickLabelFormatter(new StringConverter<>() {
@Override
public String toString(Number marketPrice) {
return bsqFormatter.formatBSQSatoshisWithCode(marketPrice.longValue());
}
private StringConverter<Number> BSQPriceTickLabelFormatter =
new StringConverter<Number>() {
@Override
public String toString(Number marketPrice) {
return bsqFormatter.formatBSQSatoshisWithCode(marketPrice.longValue());
}
@Override
public Number fromString(String string) {
return 0;
}
});
@Override
public Number fromString(String string) {
return 0;
}
};
series.setName(seriesLabel);
AreaChart<Number, Number> chart = new AreaChart<>(xAxis, yAxis);
private <X, Y> void configureChart(XYChart<X, Y> chart) {
chart.setLegendVisible(false);
chart.setAnimated(false);
chart.setId("charts-dao");
chart.setMinHeight(250);
chart.setPrefHeight(250);
chart.setCreateSymbols(true);
chart.setPadding(new Insets(0));
chart.getData().add(series);
chart.setMinHeight(300);
chart.setPrefHeight(300);
chart.setPadding(new Insets(0));
}
private Pane wrapInChartPane(Node child) {
AnchorPane chartPane = new AnchorPane();
chartPane.getStyleClass().add("chart-pane");
AnchorPane.setTopAnchor(chart, 15d);
AnchorPane.setBottomAnchor(chart, 10d);
AnchorPane.setLeftAnchor(chart, 25d);
AnchorPane.setRightAnchor(chart, 10d);
AnchorPane.setTopAnchor(child, 15d);
AnchorPane.setBottomAnchor(child, 10d);
AnchorPane.setLeftAnchor(child, 25d);
AnchorPane.setRightAnchor(child, 10d);
chartPane.getChildren().add(chart);
chartPane.getChildren().add(child);
GridPane.setColumnSpan(chartPane, 2);
GridPane.setRowIndex(chartPane, ++gridRow);
GridPane.setMargin(chartPane, new Insets(10, 0, 0, 0));
root.getChildren().add(chartPane);
return chartPane;
}
private void updateWithBsqBlockChainData() {
@ -288,11 +401,16 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
String minusSign = totalAmountOfInvalidatedBsq.isPositive() ? "-" : "";
totalAmountOfInvalidatedBsqTextField.setText(minusSign + bsqFormatter.formatAmountWithGroupSeparatorAndCode(totalAmountOfInvalidatedBsq));
updateCharts();
updateChartSeries();
}
private void updateCharts() {
seriesBSQIssued.getData().clear();
private void updateChartSeries() {
var updatedBurntBsq = updateBSQBurnt();
updateBSQBurntMA(updatedBurntBsq);
updateBSQIssued();
}
private List<XYChart.Data<Number, Number>> updateBSQBurnt() {
seriesBSQBurnt.getData().clear();
Set<Tx> burntTxs = new HashSet<>(daoStateService.getBurntFeeTxs());
@ -306,16 +424,61 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
List<XYChart.Data<Number, Number>> updatedBurntBsq = burntBsqByDay.keySet().stream()
.map(date -> {
ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.systemDefault());
return new XYChart.Data<Number, Number>(zonedDateTime.toInstant().getEpochSecond(), burntBsqByDay.get(date)
.stream()
.mapToDouble(Tx::getBurntBsq)
.sum()
return new XYChart.Data<Number, Number>(
zonedDateTime.toInstant().getEpochSecond(),
burntBsqByDay.get(date)
.stream()
.mapToDouble(Tx::getBurntBsq)
.sum()
);
})
.collect(Collectors.toList());
seriesBSQBurnt.getData().setAll(updatedBurntBsq);
return updatedBurntBsq;
}
private void updateBSQBurntMA(List<XYChart.Data<Number, Number>> updatedBurntBsq) {
seriesBSQBurntMA.getData().clear();
Comparator<Number> compareXChronology =
(x1, x2) -> x1.intValue() - x2.intValue();
Comparator<XYChart.Data<Number, Number>> compareXyDataChronology =
(xyData1, xyData2) ->
compareXChronology.compare(
xyData1.getXValue(),
xyData2.getXValue());
var sortedUpdatedBurntBsq = updatedBurntBsq
.stream()
.sorted(compareXyDataChronology)
.collect(Collectors.toList());
var burntBsqXValues = sortedUpdatedBurntBsq.stream().map(xyData -> xyData.getXValue());
var burntBsqYValues = sortedUpdatedBurntBsq.stream().map(xyData -> xyData.getYValue());
var maPeriod = 15;
var burntBsqMAYValues =
MovingAverageUtils.simpleMovingAverage(
burntBsqYValues,
maPeriod);
BiFunction<Number, Double, XYChart.Data<Number, Number>> xyToXyData =
(xValue, yValue) -> new XYChart.Data<Number, Number>(xValue, yValue);
List<XYChart.Data<Number, Number>> burntBsqMA =
zip(burntBsqXValues, burntBsqMAYValues, xyToXyData)
.filter(xyData -> Double.isFinite(xyData.getYValue().doubleValue()))
.collect(Collectors.toList());
seriesBSQBurntMA.getData().setAll(burntBsqMA);
}
private void updateBSQIssued() {
seriesBSQIssued.getData().clear();
Stream<Issuance> bsqByCompensation = daoStateService.getIssuanceSet(IssuanceType.COMPENSATION).stream()
.sorted(Comparator.comparing(Issuance::getChainHeight));
@ -338,5 +501,79 @@ public class SupplyView extends ActivatableView<GridPane, Void> implements DaoSt
seriesBSQIssued.getData().setAll(updatedAddedBSQ);
}
}
private void activateButtons() {
zoomToInliersSlide.setSelected(isZoomingToInliers);
zoomToInliersSlide.setOnAction(e -> handleZoomToInliersSlide(!isZoomingToInliers));
}
private void deactivateButtons() {
zoomToInliersSlide.setOnAction(null);
}
private void handleZoomToInliersSlide(boolean shouldActivate) {
isZoomingToInliers = !isZoomingToInliers;
if (shouldActivate) {
activateZoomingToInliers();
} else {
deactivateZoomingToInliers();
}
}
private void activateZoomingToInliers() {
seriesBSQBurnt.getData().addListener(changeListenerBSQBurnt);
// Initial zoom has to be triggered manually; otherwise, it
// would be triggered only on a change event in the series
triggerZoomToInliers();
}
private void deactivateZoomingToInliers() {
seriesBSQBurnt.getData().removeListener(changeListenerBSQBurnt);
// Reactivate automatic ranging
yAxisBSQBurnt.autoRangingProperty().set(true);
}
private void triggerZoomToInliers() {
var xyValues = seriesBSQBurnt.getData();
AxisInlierUtils.zoomToInliers(
yAxisBSQBurnt,
xyValues,
chartMaxNumberOfTicks,
chartPercentToTrim,
chartHowManyStdDevsConstituteOutlier
);
}
// When Guava version is bumped to at least 21.0,
// can be replaced with com.google.common.collect.Streams.zip
public static <L, R, T> Stream<T> zip(
Stream<L> leftStream,
Stream<R> rightStream,
BiFunction<L, R, T> combiner
) {
var lefts = leftStream.spliterator();
var rights = rightStream.spliterator();
var spliterator =
new AbstractSpliterator<T>(
Long.min(
lefts.estimateSize(),
rights.estimateSize()
),
lefts.characteristics() & rights.characteristics()
) {
@Override
public boolean tryAdvance(Consumer<? super T> action) {
return lefts.tryAdvance(
left -> rights.tryAdvance(
right -> action.accept(combiner.apply(left, right))
)
);
}
};
var isParallel = false;
var stream = StreamSupport.stream(spliterator, isParallel);
return stream;
}
}

View file

@ -128,6 +128,10 @@
-bs-chart-tick: rgba(255, 255, 255, 0.7);
-bs-chart-lines: rgba(0, 0, 0, 0.3);
-bs-white: white;
/* dao chart colors */
-bs-chart-dao-line1: -bs-color-green-5;
-bs-chart-dao-line2: -bs-color-blue-2;
}
/* list view */

View file

@ -101,4 +101,8 @@
-fx-default-button: derive(-fx-accent, 95%);
-bs-progress-bar-track: #e0e0e0;
-bs-white: white;
/* dao chart colors */
-bs-chart-dao-line1: -bs-color-green-3;
-bs-chart-dao-line2: -bs-color-blue-5;
}