package name.abuchen.portfolio.ui.util.chart; import java.time.Instant; import java.time.LocalDate; import java.time.Period; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; import org.eclipse.jface.action.IMenuManager; import org.eclipse.jface.resource.JFaceResources; import org.eclipse.jface.resource.LocalResourceManager; import org.eclipse.swt.SWT; import org.eclipse.swt.events.PaintEvent; import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Display; import org.swtchart.Chart; import org.swtchart.IAxis; import org.swtchart.IAxis.Position; import org.swtchart.IBarSeries; import org.swtchart.ICustomPaintListener; import org.swtchart.ILineSeries; import org.swtchart.ILineSeries.PlotSymbolType; import org.swtchart.IPlotArea; import org.swtchart.ISeries.SeriesType; import org.swtchart.LineStyle; import org.swtchart.Range; import name.abuchen.portfolio.ui.util.Colors; public class TimelineChart extends Chart { private static class MarkerLine { private LocalDate date; private Color color; private String label; private Double value; private MarkerLine(LocalDate date, Color color, String label, Double value) { this.date = date; this.color = color; this.label = label; this.value = value; } public long getTimeMillis() { return Date.from(date.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()).getTime(); } } private List<MarkerLine> markerLines = new ArrayList<>(); private TimelineChartToolTip toolTip; private final LocalResourceManager resources; private ChartContextMenu contextMenu; public TimelineChart(Composite parent) { super(parent, SWT.NONE); resources = new LocalResourceManager(JFaceResources.getResources(), this); setBackground(Display.getDefault().getSystemColor(SWT.COLOR_WHITE)); getTitle().setForeground(Display.getDefault().getSystemColor(SWT.COLOR_BLACK)); getLegend().setVisible(false); // x axis IAxis xAxis = getAxisSet().getXAxis(0); xAxis.getTitle().setVisible(false); xAxis.getTick().setVisible(false); xAxis.getGrid().setStyle(LineStyle.NONE); // y axis IAxis yAxis = getAxisSet().getYAxis(0); yAxis.getTitle().setVisible(false); yAxis.getTick().setForeground(Display.getDefault().getSystemColor(SWT.COLOR_BLACK)); yAxis.setPosition(Position.Secondary); ((IPlotArea) getPlotArea()).addCustomPaintListener(new ICustomPaintListener() { @Override public void paintControl(PaintEvent e) { paintTimeGrid(e); } @Override public boolean drawBehindSeries() { return true; } }); getPlotArea().addPaintListener(this::paintMarkerLines); toolTip = new TimelineChartToolTip(this); ZoomMouseWheelListener.attachTo(this); MovePlotKeyListener.attachTo(this); ZoomInAreaListener.attachTo(this); this.contextMenu = new ChartContextMenu(this); } public void addMarkerLine(LocalDate date, Color color, String label) { addMarkerLine(date, color, label, null); } public void addMarkerLine(LocalDate date, Color color, String label, Double value) { this.markerLines.add(new MarkerLine(date, color, label, value)); Collections.sort(this.markerLines, (ml1, ml2) -> ml1.date.compareTo(ml2.date)); } public void clearMarkerLines() { this.markerLines.clear(); } public ILineSeries addDateSeries(LocalDate[] dates, double[] values, String label) { return addDateSeries(dates, values, Display.getDefault().getSystemColor(SWT.COLOR_BLACK), false, label); } public ILineSeries addDateSeries(LocalDate[] dates, double[] values, Colors color, String label) { return addDateSeries(dates, values, resources.createColor(color.swt()), false, label); } public ILineSeries addDateSeries(LocalDate[] dates, double[] values, Color color, String label) { return addDateSeries(dates, values, color, false, label); } public void addDateSeries(LocalDate[] dates, double[] values, Colors color, boolean showArea) { addDateSeries(dates, values, resources.createColor(color.swt()), showArea, color.name()); } private ILineSeries addDateSeries(LocalDate[] dates, double[] values, Color color, boolean showArea, String label) { ILineSeries lineSeries = (ILineSeries) getSeriesSet().createSeries(SeriesType.LINE, label); lineSeries.setXDateSeries(toJavaUtilDate(dates)); lineSeries.enableArea(showArea); lineSeries.setLineWidth(2); lineSeries.setSymbolType(PlotSymbolType.NONE); lineSeries.setYSeries(values); lineSeries.setLineColor(color); lineSeries.setAntialias(SWT.ON); return lineSeries; } public IBarSeries addDateBarSeries(LocalDate[] dates, double[] values, String label) { IBarSeries barSeries = (IBarSeries) getSeriesSet().createSeries(SeriesType.BAR, label); barSeries.setXDateSeries(toJavaUtilDate(dates)); barSeries.setYSeries(values); barSeries.setBarColor(Display.getDefault().getSystemColor(SWT.COLOR_DARK_GRAY)); barSeries.setBarPadding(100); return barSeries; } public TimelineChartToolTip getToolTip() { return toolTip; } private void paintTimeGrid(PaintEvent e) { IAxis xAxis = getAxisSet().getXAxis(0); Range range = xAxis.getRange(); ZoneId zoneId = ZoneId.systemDefault(); LocalDate start = Instant.ofEpochMilli((long) range.lower).atZone(zoneId).toLocalDate(); LocalDate end = Instant.ofEpochMilli((long) range.upper).atZone(zoneId).toLocalDate(); LocalDate cursor = start.getDayOfMonth() == 1 ? start : start.plusMonths(1).withDayOfMonth(1); Period period; DateTimeFormatter format; long days = ChronoUnit.DAYS.between(start, end); if (days < 250) { period = Period.ofMonths(1); format = DateTimeFormatter.ofPattern("MMMM yyyy"); //$NON-NLS-1$ } else if (days < 800) { period = Period.ofMonths(3); format = DateTimeFormatter.ofPattern("QQQ yyyy"); //$NON-NLS-1$ cursor = cursor.plusMonths((12 - cursor.getMonthValue() + 1) % 3); } else if (days < 1200) { period = Period.ofMonths(6); format = DateTimeFormatter.ofPattern("QQQ yyyy"); //$NON-NLS-1$ cursor = cursor.plusMonths((12 - cursor.getMonthValue() + 1) % 6); } else { period = Period.ofYears(days > 5000 ? 2 : 1); format = DateTimeFormatter.ofPattern("yyyy"); //$NON-NLS-1$ if (cursor.getMonthValue() > 1) cursor = cursor.plusYears(1).withDayOfYear(1); } while (cursor.isBefore(end)) { int y = xAxis.getPixelCoordinate((double) cursor.atStartOfDay(zoneId).toInstant().toEpochMilli()); e.gc.drawLine(y, 0, y, e.height); e.gc.drawText(format.format(cursor), y + 5, 5); cursor = cursor.plus(period); } } private void paintMarkerLines(PaintEvent e) // NOSONAR { if (markerLines.isEmpty()) return; IAxis xAxis = getAxisSet().getXAxis(0); IAxis yAxis = getAxisSet().getYAxis(0); int labelExtentX = 0; int labelStackY = 0; for (MarkerLine marker : markerLines) { int x = xAxis.getPixelCoordinate((double) marker.getTimeMillis()); Point textExtent = e.gc.textExtent(marker.label); boolean flip = x + 5 + textExtent.x > e.width; int textX = flip ? x - 5 - textExtent.x : x + 5; labelStackY = labelExtentX > textX ? labelStackY + textExtent.y : 0; labelExtentX = x + 5 + textExtent.x; e.gc.setLineStyle(SWT.LINE_SOLID); e.gc.setForeground(marker.color); e.gc.setLineWidth(2); e.gc.drawLine(x, 0, x, e.height); e.gc.drawText(marker.label, textX, e.height - 20 - labelStackY, true); if (marker.value != null) { int y = yAxis.getPixelCoordinate(marker.value); e.gc.drawLine(x - 5, y, x + 5, y); } } } public void exportMenuAboutToShow(IMenuManager manager, String label) { this.contextMenu.exportMenuAboutToShow(manager, label); } public static Date[] toJavaUtilDate(LocalDate[] dates) { ZoneId zoneId = ZoneId.systemDefault(); Date[] answer = new Date[dates.length]; for (int ii = 0; ii < answer.length; ii++) answer[ii] = Date.from(dates[ii].atStartOfDay().atZone(zoneId).toInstant()); return answer; } public void adjustRange() { try { setRedraw(false); getAxisSet().adjustRange(); ChartUtil.addYMargins(this, 0.03); } finally { setRedraw(true); } } }