package market.guess.ui.desktop.util; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.List; import javafx.scene.chart.LineChart; import javafx.scene.chart.NumberAxis; import javafx.scene.chart.XYChart; import javafx.util.StringConverter; import market.guess.ui.desktop.model.ChartPoint; /** Utilities for populating LineChart series and formatting time/value axes. */ public final class Charts { private Charts() {} public static final long BASE_TIME = LocalDate.of(2026, 9, 12).atTime(9, 0).atZone(ZoneId.systemDefault()).toEpochSecond(); public static double toEpochSecond(double x) { if (x >= 1_000_000_000) { return x; } return BASE_TIME + (long) (x * 1800); } public static void setSingleSeries( LineChart chart, String name, List pts) { configureTimeXAxis(chart, pts); chart.getData().setAll(List.of(series(name, pts))); } public static void setDualSeries( LineChart chart, String name1, List pts1, String name2, List pts2) { configureTimeXAxis(chart, (pts1 != null && !pts1.isEmpty()) ? pts1 : pts2); chart.getData().setAll(List.of(series(name1, pts1), series(name2, pts2))); } public static void configureTimeXAxis(LineChart chart, List pts) { if (!(chart.getXAxis() instanceof NumberAxis xAxis)) return; xAxis.setTickMarkVisible(true); xAxis.setTickLabelsVisible(true); xAxis.setMinorTickVisible(false); xAxis.setForceZeroInRange(false); if (pts == null || pts.isEmpty()) { xAxis.setAutoRanging(true); return; } double min = pts.stream().mapToDouble(p -> toEpochSecond(p.x())).min().orElse(BASE_TIME); double max = pts.stream().mapToDouble(p -> toEpochSecond(p.x())).max().orElse(BASE_TIME + 3600); if (min == max) { min -= 60; max += 60; } double range = max - min; double targetInterval = range / 5.5; double[] cleanIntervals = { 1, 5, 10, 15, 30, 60, 300, 600, 900, 1800, 3600, 7200, 14400, 21600, 43200, 86400 }; double tickUnit = cleanIntervals[cleanIntervals.length - 1]; for (double unit : cleanIntervals) { if (unit >= targetInterval) { tickUnit = unit; break; } } double lower = Math.floor(min / tickUnit) * tickUnit; double upper = Math.ceil(max / tickUnit) * tickUnit; if (upper <= max) { upper += tickUnit; } xAxis.setAutoRanging(false); xAxis.setLowerBound(lower); xAxis.setUpperBound(upper); xAxis.setTickUnit(tickUnit); DateTimeFormatter fmt = (range > 86400) ? DateTimeFormatter.ofPattern("MM-dd HH:mm").withZone(ZoneId.systemDefault()) : (range < 300) ? DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault()) : DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()); xAxis.setTickLabelFormatter( new StringConverter() { @Override public String toString(Number n) { if (n == null) return ""; try { return fmt.format(Instant.ofEpochSecond(n.longValue())); } catch (Exception e) { return n.toString(); } } @Override public Number fromString(String s) { return 0; } }); } /** * Scales the Y axis dynamically with sensible headroom and round tick marks so values (like * balances > $1,500) never get cut off at the top or bottom. */ public static void scaleYAxis(NumberAxis yAxis, List pts, Double currentVal) { if (yAxis == null) return; if ((pts == null || pts.isEmpty()) && currentVal == null) { yAxis.setAutoRanging(true); return; } double min = (pts != null && !pts.isEmpty()) ? pts.stream().mapToDouble(ChartPoint::y).min().orElse(0.0) : (currentVal != null ? currentVal : 0.0); double max = (pts != null && !pts.isEmpty()) ? pts.stream().mapToDouble(ChartPoint::y).max().orElse(100.0) : (currentVal != null ? currentVal : 100.0); if (currentVal != null) { min = Math.min(min, currentVal); max = Math.max(max, currentVal); } if (max <= 0 && min <= 0) { double absMax = Math.abs(min); double unit = niceNum(absMax / 4.0, true); if (unit <= 0) unit = 50; double lower = Math.floor(min / unit) * unit - unit; yAxis.setAutoRanging(false); yAxis.setLowerBound(lower); yAxis.setUpperBound(0.0); yAxis.setTickUnit(unit); return; } double targetMax = max > 0 ? max * 1.15 : 100.0; double targetMin = min < 0 ? min * 1.20 : 0.0; double range = targetMax - targetMin; double unit = niceNum(range / 5.0, true); if (unit <= 0) unit = 50; double lower = targetMin < 0 ? Math.floor(targetMin / unit) * unit : 0.0; double upper = Math.ceil(targetMax / unit) * unit; if (upper <= max) { upper += unit; } yAxis.setAutoRanging(false); yAxis.setLowerBound(lower); yAxis.setUpperBound(upper); yAxis.setTickUnit(unit); } private static double niceNum(double range, boolean round) { if (range <= 0) return 1.0; double exponent = Math.floor(Math.log10(range)); double fraction = range / Math.pow(10, exponent); double niceFraction; if (round) { if (fraction < 1.5) niceFraction = 1.0; else if (fraction < 3.0) niceFraction = 2.0; else if (fraction < 7.0) niceFraction = 5.0; else niceFraction = 10.0; } else { if (fraction <= 1.0) niceFraction = 1.0; else if (fraction <= 2.0) niceFraction = 2.0; else if (fraction <= 5.0) niceFraction = 5.0; else niceFraction = 10.0; } return niceFraction * Math.pow(10, exponent); } private static XYChart.Series series(String name, List pts) { var s = new XYChart.Series(); s.setName(name); if (pts != null) { for (var p : pts) { s.getData().add(new XYChart.Data<>(toEpochSecond(p.x()), p.y())); } } return s; } }