package name.abuchen.portfolio.ui.views.taxonomy; import java.text.DecimalFormat; import java.time.LocalDate; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; import javax.inject.Inject; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.IStatus; import org.eclipse.core.runtime.Status; import org.eclipse.core.runtime.jobs.Job; import org.eclipse.jface.action.Action; import org.eclipse.jface.action.IMenuManager; import org.eclipse.swt.SWT; import org.eclipse.swt.layout.FillLayout; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.swtchart.ISeries; import org.swtchart.Range; import name.abuchen.portfolio.model.Classification; import name.abuchen.portfolio.model.InvestmentVehicle; import name.abuchen.portfolio.snapshot.Aggregation; import name.abuchen.portfolio.snapshot.Aggregation.Period; import name.abuchen.portfolio.snapshot.AssetPosition; import name.abuchen.portfolio.snapshot.ClientSnapshot; import name.abuchen.portfolio.ui.Messages; import name.abuchen.portfolio.ui.PortfolioPart; import name.abuchen.portfolio.ui.util.SimpleAction; import name.abuchen.portfolio.ui.util.chart.StackedTimelineChart; import name.abuchen.portfolio.ui.views.taxonomy.TaxonomyModel.NodeVisitor; import name.abuchen.portfolio.util.Interval; public class StackedChartViewer extends AbstractChartPage { private static class VehicleBuilder { private List<Integer> weights = new ArrayList<>(); private List<SeriesBuilder> series = new ArrayList<>(); public void add(int weight, SeriesBuilder series) { this.weights.add(weight); this.series.add(series); } public void book(int index, AssetPosition pos) { long value = pos.getValuation().getAmount(); for (int ii = 0; ii < weights.size(); ii++) series.get(ii).book(index, value * weights.get(ii) / Classification.ONE_HUNDRED_PERCENT); } } private static class SeriesBuilder implements Comparable<SeriesBuilder> { private TaxonomyNode node; private long[] values; private boolean hasValues = false; public SeriesBuilder(TaxonomyNode node, int size) { this.node = node; this.values = new long[size]; } public boolean hasValues() { return hasValues; } public void book(int index, long l) { // stacked charts cannot handle negative values. // Therefore we ignore them here, which in turn means that the other // data series will stack up to over 100% in the chart if (l < 0) return; hasValues = true; values[index] += l; } public double[] getValues(long[] totals) { double[] answer = new double[values.length]; for (int ii = 0; ii < answer.length; ii++) { if (totals[ii] == 0) answer[ii] = 0d; else answer[ii] = values[ii] / (double) totals[ii]; } return answer; } @Override public int compareTo(SeriesBuilder other) { long l1 = values[values.length - 1]; long l2 = other.values[other.values.length - 1]; return Long.compare(l1, l2) * -1; } } private StackedTimelineChart chart; private boolean isVisible = false; private boolean isDirty = true; private List<LocalDate> dates; @Inject public StackedChartViewer(PortfolioPart part, TaxonomyModel model, TaxonomyNodeRenderer renderer) { super(model, renderer); Interval interval = part.loadReportingPeriods().getFirst().toInterval(); Period weekly = Aggregation.Period.WEEKLY; final LocalDate start = interval.getStart(); final LocalDate now = LocalDate.now(); final LocalDate end = interval.getEnd().isAfter(now) ? now : interval.getEnd(); LocalDate current = weekly.getStartDateFor(start); dates = new ArrayList<>(); while (current.isBefore(end)) { dates.add(current); current = current.plus(weekly.getPeriod()); } dates.add(end); } @Override public Control createControl(Composite container) { Composite composite = new Composite(container, SWT.NONE); composite.setBackground(Display.getDefault().getSystemColor(SWT.COLOR_WHITE)); composite.setLayout(new FillLayout()); chart = new StackedTimelineChart(composite, dates); chart.getTitle().setVisible(false); chart.getLegend().setPosition(SWT.BOTTOM); chart.getLegend().setVisible(true); chart.getAxisSet().getYAxis(0).getTick().setFormat(new DecimalFormat("#0.0%")); //$NON-NLS-1$ return composite; } @Override public void configMenuAboutToShow(IMenuManager manager) { super.configMenuAboutToShow(manager); Action action = new SimpleAction(Messages.LabelOrderByTaxonomy, a -> { getModel().setOrderByTaxonomyInStackChart(!getModel().isOrderByTaxonomyInStackChart()); onConfigChanged(); }); action.setChecked(getModel().isOrderByTaxonomyInStackChart()); manager.add(action); } @Override public void nodeChange(TaxonomyNode node) { onConfigChanged(); } @Override public void onConfigChanged() { isDirty = true; if (isVisible) asyncUpdateChart(); } @Override public void beforePage() { isVisible = true; if (isDirty) asyncUpdateChart(); } @Override public void afterPage() { isVisible = false; } private void asyncUpdateChart() { new Job(Messages.JobLabelUpdateStackedLineChart) { @Override protected IStatus run(IProgressMonitor monitor) { updateChart(); return Status.OK_STATUS; } }.schedule(); } private void updateChart() { final Map<InvestmentVehicle, VehicleBuilder> vehicle2builder = new HashMap<>(); final Map<TaxonomyNode, SeriesBuilder> node2series = new LinkedHashMap<>(); getModel().visitAll(new NodeVisitor() { @Override public void visit(TaxonomyNode node) { if (node.isClassification()) { node2series.put(node, new SeriesBuilder(node, dates.size())); } else { InvestmentVehicle vehicle = node.getAssignment().getInvestmentVehicle(); VehicleBuilder builder = vehicle2builder.get(vehicle); if (builder == null) { builder = new VehicleBuilder(); vehicle2builder.put(vehicle, builder); } builder.add(node.getWeight(), node2series.get(node.getParent())); } } }); final long[] totals = new long[dates.size()]; int index = 0; for (LocalDate current : dates) { ClientSnapshot snapshot = ClientSnapshot.create(getModel().getClient(), getModel().getCurrencyConverter(), current); totals[index] = snapshot.getMonetaryAssets().getAmount(); Map<InvestmentVehicle, AssetPosition> p = snapshot.getPositionsByVehicle(); for (Map.Entry<InvestmentVehicle, VehicleBuilder> entry : vehicle2builder.entrySet()) { AssetPosition pos = p.get(entry.getKey()); if (pos != null) entry.getValue().book(index, pos); } index++; } // if the unassigned category is excluded, reduce the total values if (getModel().isUnassignedCategoryInChartsExcluded()) { SeriesBuilder unassigned = node2series.get(getModel().getUnassignedNode()); for (int ii = 0; ii < totals.length; ii++) totals[ii] -= unassigned.values[ii]; } Stream<SeriesBuilder> seriesStream = node2series.values().stream().filter(s -> s.hasValues()); if (getModel().isUnassignedCategoryInChartsExcluded()) seriesStream = seriesStream.filter(s -> !s.node.isUnassignedCategory()); List<SeriesBuilder> series = seriesStream.collect(Collectors.toList()); if (getModel().isOrderByTaxonomyInStackChart()) { // reverse because chart is stacked bottom-up Collections.reverse(series); } else { Collections.sort(series); } Display.getDefault().asyncExec(() -> rebuildChartSeries(totals, series)); } private void rebuildChartSeries(long[] totals, List<SeriesBuilder> series) { if (chart.isDisposed()) return; try { chart.suspendUpdate(true); for (ISeries s : chart.getSeriesSet().getSeries()) chart.getSeriesSet().deleteSeries(s.getId()); for (SeriesBuilder serie : series) { chart.addSeries(serie.node.getClassification().getPathName(false), // serie.getValues(totals), // getRenderer().getColorFor(serie.node)); } chart.getAxisSet().adjustRange(); chart.getAxisSet().getYAxis(0).setRange(new Range(-0.01, 1.01)); } finally { chart.suspendUpdate(false); chart.redraw(); } isDirty = false; } }