All Downloads are FREE. Search and download functionalities are using the official Maven repository.

ai.timefold.solver.benchmark.impl.report.LineChart Maven / Gradle / Ivy

package ai.timefold.solver.benchmark.impl.report;

import static ai.timefold.solver.benchmark.impl.report.BenchmarkReport.LOG_SCALE_MIN_DATASETS_COUNT;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.math.BigDecimal;
import java.math.MathContext;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.NavigableSet;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.IntStream;

import ai.timefold.solver.core.impl.util.Pair;

import freemarker.template.TemplateException;

public record LineChart, Y extends Number & Comparable>(String id, String title,
        String xLabel, String yLabel, List keys, List> datasets, boolean stepped, boolean timeOnX,
        boolean timeOnY)
        implements
            Chart {

    public LineChart {
        id = Chart.makeIdUnique(id);
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public BigDecimal xMin() {
        return min(keys);
    }

    static > BigDecimal min(List values) {
        if (values.isEmpty()) {
            return BigDecimal.ZERO;
        }
        var min = Collections.min(values).doubleValue();
        if (min > 0) { // Always start with zero.
            return BigDecimal.ZERO;
        } else {
            return BigDecimal.valueOf(min);
        }
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public BigDecimal xMax() {
        return max(keys);
    }

    static > BigDecimal max(List values) {
        if (values.isEmpty()) {
            return BigDecimal.ZERO;
        }
        var max = Collections.max(values).doubleValue();
        if (max < 0) { // Always start with zero.
            return BigDecimal.ZERO;
        } else {
            return BigDecimal.valueOf(max);
        }
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public BigDecimal yMin() {
        return min(getYValues());
    }

    private List getYValues() {
        return datasets.stream()
                .flatMap(d -> d.data().stream())
                .filter(Objects::nonNull)
                .toList();
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public BigDecimal yMax() {
        return max(getYValues());
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public BigDecimal xStepSize() {
        return stepSize(xMin(), xMax());
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public BigDecimal yStepSize() {
        return stepSize(yMin(), yMax());
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public boolean xLogarithmic() {
        if (timeOnX) { // Logarithmic time doesn't make sense.
            return false;
        }
        return useLogarithmicProblemScale(keys);
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public boolean yLogarithmic() {
        if (timeOnY) { // Logarithmic time doesn't make sense.
            return false;
        }
        return useLogarithmicProblemScale(getYValues());
    }

    @SuppressWarnings("unused") // Used by FreeMarker.
    public List> points(String label) {
        var dataset = datasets().stream().filter(d -> d.label().equals(label)).findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Dataset %s not found.".formatted(label)));
        return IntStream.range(0, dataset.data().size())
                .filter(i -> dataset.data().get(i) != null)
                .mapToObj(i -> new Pair<>(keys().get(i), dataset.data().get(i)))
                .toList();
    }

    static > boolean useLogarithmicProblemScale(List seriesList) {
        NavigableSet valueSet = new TreeSet<>();
        for (var dataItem : seriesList) {
            var value = dataItem.doubleValue();
            if (value <= 0) { // Logarithm undefined.
                return false;
            }
            valueSet.add(value);
        }
        if (valueSet.size() < LOG_SCALE_MIN_DATASETS_COUNT) {
            return false;
        }
        // If 60% of the points are in 20% of the value space, use a logarithmic scale.
        var threshold = 0.2 * (valueSet.last() - valueSet.first());
        var belowThresholdCount = valueSet.headSet(threshold).size();
        return belowThresholdCount >= (0.6 * valueSet.size());
    }

    static BigDecimal stepSize(BigDecimal min, BigDecimal max) {
        // Prevents ticks of ugly values.
        // For example, if the diff is 123_456_789, the step size will be 1_000_000.
        var diff = max.subtract(min).abs();
        if (diff.signum() == 0) {
            return BigDecimal.ONE;
        } else {
            var nearestPowerOfTen = (int) Math.round(Math.log10(diff.doubleValue()));
            return BigDecimal.TEN.pow(nearestPowerOfTen - 2, MathContext.DECIMAL64);
        }
    }

    @Override
    public void writeToFile(Path parentFolder) {
        var file = new File(parentFolder.toFile(), id() + ".js");
        file.getParentFile().mkdirs();

        var freeMarkerCfg = BenchmarkReport.createFreeMarkerConfiguration();
        freeMarkerCfg.setClassForTemplateLoading(getClass(), "");

        var templateFilename = "chart-line.js.ftl";
        Map model = new HashMap<>();
        model.put("chart", this);

        try (Writer writer = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) {
            var template = freeMarkerCfg.getTemplate(templateFilename);
            template.process(model, writer);
        } catch (IOException e) {
            throw new IllegalArgumentException("Can not read templateFilename (" + templateFilename
                    + ") or write chart file (" + file + ").", e);
        } catch (TemplateException e) {
            throw new IllegalArgumentException("Can not process Freemarker templateFilename (" + templateFilename
                    + ") to chart file (" + id + ").", e);
        }
    }

    public static final class Builder, Y extends Number & Comparable> {
        private static final int MAX_CHART_WIDTH = 3840;
        private final Map> data = new LinkedHashMap<>();
        private final Set favoriteSet = new HashSet<>();

        public Builder add(String dataset, X x, Y y) {
            data.computeIfAbsent(dataset, k -> new TreeMap<>())
                    .put(x, y);
            return this;
        }

        public Set keys() {
            return data.keySet();
        }

        public int count(String dataset) {
            return data.getOrDefault(dataset, Collections.emptyNavigableMap()).size();
        }

        public Y getLastValue(String dataset) {
            return data.getOrDefault(dataset, Collections.emptyNavigableMap())
                    .lastEntry()
                    .getValue();
        }

        public Builder markFavorite(String dataset) {
            favoriteSet.add(dataset);
            return this;
        }

        public LineChart build(String fileName, String title, String xLabel, String yLabel, boolean stepped,
                boolean timeOnX, boolean timeOnY) {
            /*
             * If two of the same value have another of that value between them, remove it.
             * This allows Chart.js to only draw the absolute minimum necessary points.
             */
            data.values().forEach(map -> {
                var entries = map.entrySet().stream().toList();
                if (entries.size() < 3) {
                    return;
                }
                for (var i = 0; i < entries.size() - 2; i++) {
                    var entry1 = entries.get(i);
                    var entry2 = entries.get(i + 1);
                    if (!entry1.getValue().equals(entry2.getValue())) {
                        continue;
                    }
                    var entry3 = entries.get(i + 2);
                    if (entry2.getValue().equals(entry3.getValue())) {
                        map.remove(entry2.getKey());
                    }
                }
            });
            /*
             * Sometimes, when the dataset size is large, it can cause the browser to freeze or use excessive memory
             * while rendering the line chart. To solve the issue of a large volume of data points, we use the
             * Largest-Triangle-Three-Buckets algorithm to down-sample the data.
             */
            Map> datasetMap = new LinkedHashMap<>(data.size());
            for (var entry : data.entrySet()) {
                datasetMap.put(entry.getKey(), largestTriangleThreeBuckets(entry.getValue(), MAX_CHART_WIDTH));
            }
            // We need to merge all the keys after the down-sampling process to create a consistent X values list.
            // The xValues list size can be "MAX_CHART_WIDTH * data.size" in the worst case.
            var xValues = data.values().stream()
                    .flatMap(k -> k.keySet().stream())
                    .distinct()
                    .sorted(Comparable::compareTo)
                    .toList();
            /*
             * Finally gather Y values for every such X, even if some are null.
             * Specifying the data like this helps avoid Chart.js quirks during rendering.
             */
            List> datasetList = new ArrayList<>(data.size());
            for (var entry : datasetMap.entrySet()) {
                List datasetData = new ArrayList<>(xValues.size());
                var dataset = entry.getValue();
                for (var xValue : xValues) {
                    var yValue = dataset.get(xValue);
                    datasetData.add(yValue);
                }
                datasetList.add(new Dataset<>(entry.getKey(), datasetData, favoriteSet.contains(entry.getKey())));
            }
            return new LineChart<>(fileName, title, xLabel, yLabel, xValues, datasetList, stepped, timeOnX, timeOnY);
        }

        /**
         * The method uses the Largest-Triangle-Three-Buckets approach to reduce the size of the data points list.
         * 
         * @param datasetDataMap The ordered map of data points
         * @param sampleSize The final sample size
         * 
         * @return The compressed data
         *
         * @see https://github.com/sveinn-steinarsson/flot-downsample/
         */
        private Map largestTriangleThreeBuckets(NavigableMap datasetDataMap, int sampleSize) {
            if (datasetDataMap.size() <= sampleSize) {
                return datasetDataMap;
            }
            var sampled = new LinkedHashMap(sampleSize);
            List keys = new ArrayList<>(datasetDataMap.keySet());

            // Bucket size. Leave room for start and end data points
            var every = (double) (datasetDataMap.size() - 2) / (double) (sampleSize - 2);

            var a = 0; // Initially a is the first point in the triangle
            var nextA = 0;
            Y maxAreaPoint = null;
            double maxArea;
            double area;

            // Always add the first point
            datasetDataMap.entrySet().stream().findFirst().ifPresent(e -> sampled.put(e.getKey(), e.getValue()));

            for (var i = 0; i < sampleSize - 2; i++) {

                // Calculate point average for next bucket (containing c)
                var avgX = 0.0D;
                var avgY = 0.0D;
                var avgRangeStart = (int) Math.floor((i + 1) * every) + 1;
                var avgRangeEnd = (int) Math.floor((i + 2) * every) + 1;
                avgRangeEnd = Math.min(avgRangeEnd, datasetDataMap.size());

                var avgRangeLength = avgRangeEnd - avgRangeStart;

                while (avgRangeStart < avgRangeEnd) {
                    avgX += keys.get(avgRangeStart).doubleValue();
                    avgY += datasetDataMap.get(keys.get(avgRangeStart)).doubleValue();
                    avgRangeStart++;
                }
                avgX /= avgRangeLength;
                avgY /= avgRangeLength;

                // Get the range for this bucket
                var rangeOffs = (int) Math.floor(i * every) + 1;
                var rangeTo = (int) Math.floor((i + 1) * every) + 1;

                // Point a
                var pointAX = keys.get(a).doubleValue();
                var pointAY = datasetDataMap.get(keys.get(a)).doubleValue();

                maxArea = -1;

                while (rangeOffs < rangeTo) {
                    // Calculate triangle area over three buckets
                    area = Math.abs((pointAX - avgX) * (datasetDataMap.get(keys.get(rangeOffs)).doubleValue() - pointAY)
                            - (pointAX - keys.get(rangeOffs).doubleValue()) * (avgY - pointAY)) * 0.5D;
                    if (area > maxArea) {
                        maxArea = area;
                        maxAreaPoint = datasetDataMap.get(keys.get(rangeOffs));
                        // Next a is this b
                        nextA = rangeOffs;
                    }
                    rangeOffs++;
                }
                // Pick this point from the bucket
                sampled.put(keys.get(nextA), maxAreaPoint);
                // This a is the next a (chosen b)
                a = nextA;
            }

            // Always add last
            sampled.put(keys.get(keys.size() - 1), datasetDataMap.get(keys.get(keys.size() - 1)));
            return sampled;
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy