diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index ccced5fd06..a009138de6 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -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 diff --git a/desktop/src/main/java/bisq/desktop/bisq.css b/desktop/src/main/java/bisq/desktop/bisq.css index 24f389dd53..44904b9f67 100644 --- a/desktop/src/main/java/bisq/desktop/bisq.css +++ b/desktop/src/main/java/bisq/desktop/bisq.css @@ -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; } diff --git a/desktop/src/main/java/bisq/desktop/main/dao/economy/supply/SupplyView.java b/desktop/src/main/java/bisq/desktop/main/dao/economy/supply/SupplyView.java index ae6bf1fb58..8d37a9f151 100644 --- a/desktop/src/main/java/bisq/desktop/main/dao/economy/supply/SupplyView.java +++ b/desktop/src/main/java/bisq/desktop/main/dao/economy/supply/SupplyView.java @@ -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 implements DaoSt private TextField genesisIssueAmountTextField, compRequestIssueAmountTextField, reimbursementAmountTextField, totalBurntFeeAmountTextField, totalLockedUpAmountTextField, totalUnlockingAmountTextField, totalUnlockedAmountTextField, totalConfiscatedAmountTextField, totalAmountOfInvalidatedBsqTextField; - private XYChart.Series seriesBSQIssued, seriesBSQBurnt; + private XYChart.Series 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 ADJUSTERS = new HashMap<>(); @@ -112,12 +136,12 @@ public class SupplyView extends ActivatableView 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 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 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 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 implements DaoSt Res.get("dao.factsAndFigures.supply.totalConfiscatedAmount")).second; } - private void createChart(XYChart.Series series, String seriesLabel, String datePattern) { + private Node createBSQIssuedChart(XYChart.Series 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 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 seriesBSQBurnt, + XYChart.Series seriesBSQBurntMA + ) { + Supplier makeXAxis = () -> { + NumberAxis xAxis = new NumberAxis(); + configureAxis(xAxis); + xAxis.setTickLabelFormatter(getTimestampTickLabelFormatter("d MMM")); + return xAxis; + }; + + Supplier 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 getTimestampTickLabelFormatter(String datePattern) { + return new StringConverter() { @Override public String toString(Number timestamp) { LocalDateTime localDateTime = LocalDateTime.ofEpochSecond(timestamp.longValue(), @@ -214,54 +333,48 @@ public class SupplyView extends ActivatableView 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 BSQPriceTickLabelFormatter = + new StringConverter() { + @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 chart = new AreaChart<>(xAxis, yAxis); + private void configureChart(XYChart 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 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> updateBSQBurnt() { seriesBSQBurnt.getData().clear(); Set burntTxs = new HashSet<>(daoStateService.getBurntFeeTxs()); @@ -306,16 +424,61 @@ public class SupplyView extends ActivatableView implements DaoSt List> updatedBurntBsq = burntBsqByDay.keySet().stream() .map(date -> { ZonedDateTime zonedDateTime = date.atStartOfDay(ZoneId.systemDefault()); - return new XYChart.Data(zonedDateTime.toInstant().getEpochSecond(), burntBsqByDay.get(date) - .stream() - .mapToDouble(Tx::getBurntBsq) - .sum() + return new XYChart.Data( + zonedDateTime.toInstant().getEpochSecond(), + burntBsqByDay.get(date) + .stream() + .mapToDouble(Tx::getBurntBsq) + .sum() ); }) .collect(Collectors.toList()); seriesBSQBurnt.getData().setAll(updatedBurntBsq); + return updatedBurntBsq; + } + + private void updateBSQBurntMA(List> updatedBurntBsq) { + seriesBSQBurntMA.getData().clear(); + + Comparator compareXChronology = + (x1, x2) -> x1.intValue() - x2.intValue(); + + Comparator> 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> xyToXyData = + (xValue, yValue) -> new XYChart.Data(xValue, yValue); + + List> 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 bsqByCompensation = daoStateService.getIssuanceSet(IssuanceType.COMPENSATION).stream() .sorted(Comparator.comparing(Issuance::getChainHeight)); @@ -338,5 +501,79 @@ public class SupplyView extends ActivatableView 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 Stream zip( + Stream leftStream, + Stream rightStream, + BiFunction combiner + ) { + var lefts = leftStream.spliterator(); + var rights = rightStream.spliterator(); + var spliterator = + new AbstractSpliterator( + Long.min( + lefts.estimateSize(), + rights.estimateSize() + ), + lefts.characteristics() & rights.characteristics() + ) { + @Override + public boolean tryAdvance(Consumer 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; + } +} diff --git a/desktop/src/main/java/bisq/desktop/theme-dark.css b/desktop/src/main/java/bisq/desktop/theme-dark.css index f2cde23af2..b238adfea3 100644 --- a/desktop/src/main/java/bisq/desktop/theme-dark.css +++ b/desktop/src/main/java/bisq/desktop/theme-dark.css @@ -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 */ diff --git a/desktop/src/main/java/bisq/desktop/theme-light.css b/desktop/src/main/java/bisq/desktop/theme-light.css index 751b56aa49..dcd86a7323 100644 --- a/desktop/src/main/java/bisq/desktop/theme-light.css +++ b/desktop/src/main/java/bisq/desktop/theme-light.css @@ -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; }