// Add rectViewport.getMinY() instead of subtracting because we're mirroring the Y coordinate around the X-axis.
protected void paint(GraphicsContext gc) {
        // Get a sorted copy of the series data.  Once we have this we can release locks.
        Rectangle2D rectViewport = viewport;
        HashMap<Series, LinkedList<T>> data = new HashMap<>();
        for(Series<T> series : this.series) {
            LinkedList<T> seriesData = new LinkedList<>(series.getData());
            seriesData.sort((o1, o2) -> fnXValue.apply(o1).compareTo(fnXValue.apply(o2)));
            data.put(series, seriesData);
        }
        //Make sure we have data before continuing.
        if(data.size() == 0) {
            return;
        }
        if(data.values().stream().flatMap(LinkedList::stream).count() == 0) {
            return;
        }
        // Calculate the range on each axis.
        Range<TX> axisX;
        Range<TY> axisY;
        //TODO: Since we just sorted by X, we can optimize this a bit.
        if(rangeX != null) {
            axisX = new Range<>(
                    rangeX.min == null ? data.values().stream().flatMap(LinkedList::stream).map(fnXValue).min(TX::compareTo).get() : rangeX.min,
                    rangeX.max == null ? data.values().stream().flatMap(LinkedList::stream).map(fnXValue).max(TX::compareTo).get() : rangeX.max
            );
        } else {
            axisX = new Range<>(
                    data.values().stream().flatMap(LinkedList::stream).map(fnXValue).min(TX::compareTo).get(),
                    data.values().stream().flatMap(LinkedList::stream).map(fnXValue).max(TX::compareTo).get()
            );
        }
        if(rangeY != null) {
            axisY = new Range<>(
                    rangeY.min == null ? data.values().stream().flatMap(LinkedList::stream).map(fnYValue).min(TY::compareTo).get() : rangeY.min,
                    rangeY.max == null ? data.values().stream().flatMap(LinkedList::stream).map(fnYValue).max(TY::compareTo).get() : rangeY.max
            );
        } else {
            axisY = new Range<>(
                    data.values().stream().flatMap(LinkedList::stream).map(fnYValue).min(TY::compareTo).get(),
                    data.values().stream().flatMap(LinkedList::stream).map(fnYValue).max(TY::compareTo).get()
            );
        }
        final List<TX> ticksX = fnTicksX.generateTicks(axisX, viewport.getMinX(), viewport.getMaxX());
        //axisX = new Range<>(ticksX.get(0), ticksX.get(ticksX.size() - 1));
        //rangeXRendered = axisX;
        final List<TY> ticksY = fnTicksY.generateTicks(axisY, viewport.getMinY(), viewport.getMaxY());
        //axisY = new Range<>(ticksY.get(0), ticksY.get(ticksY.size() - 1));
        // Calculate the width of the widest Y-axis label and the height of the tallest X-axis label.
        double maxWidth = 0.0;
        double pxMinSpacingBetweenTicks = 0.0;
        for(TY tickY : ticksY) {
            textForMeasuring.setText(fnFormatY.apply(tickY));
            maxWidth = Math.max(maxWidth, textForMeasuring.getLayoutBounds().getWidth());
            pxMinSpacingBetweenTicks = Math.max(pxMinSpacingBetweenTicks, 2.0 * textForMeasuring.getLayoutBounds().getHeight());
        }
        double maxHeight = 0.0;
        for(TX tickX : ticksX) {
            //X-labels are displayed at a 30-degree incline.
            //The approximate width of the rotated text is 0.87*{width}
            //The distance from the top of the bounding to the origin from which text should be drawn is 0.5*{length} + 0.87*{height}
            textForMeasuring.setText(fnFormatX.apply(tickX));
            final Bounds boundsText = textForMeasuring.getLayoutBounds();
            maxHeight = Math.max(maxHeight, 0.5 * boundsText.getWidth() + 0.87 * boundsText.getHeight());
            //TODO: Also check maxWidth against the amount by which this would underflow the X=0 line
        }
        final Rectangle2D sizeAxisLabel = new Rectangle2D(0.0, 0.0, maxWidth, maxHeight);
        if(getWidth() <= sizeAxisLabel.getWidth() || getHeight() <= sizeAxisLabel.getHeight()) {
            return;
        }
        rectChart = new Rectangle2D(sizeAxisLabel.getWidth() + pxTickLength, 0.0, getWidth() - sizeAxisLabel.getWidth() - pxTickLength, getHeight() - sizeAxisLabel.getHeight() - pxTickLength);
        // Render series data, build tooltip cache
        renderedPoints.clear();
        for(Map.Entry<Series, LinkedList<T>> entry : data.entrySet()) {
            Point2D ptPrev = null;
            gc.setStroke(entry.getKey().getColor());
            //TODO: Make this customizable
            gc.setLineWidth(2.0);
            for(T value : entry.getValue()) {
                TX x = fnXValue.apply(value);
                TY y = fnYValue.apply(value);
                // Add rectViewport.getMinY() instead of subtracting because we're mirroring the Y coordinate around the X-axis.
                Point2D ptNew = new Point2D(
                        chartXFromDataX(axisX, x),
                        chartYFromDataY(axisY, y));
                Point<T, TX, TY> pt = new Point<>(
                        value,
                        x,
                        y,
                        ptNew,
                        entry.getKey().getColor()
                );
                renderedPoints.add(pt);
                if(ptPrev != null) {
                    gc.strokeLine(ptPrev.getX(), ptPrev.getY(), ptNew.getX(), ptNew.getY());
                }
                gc.strokeOval(ptNew.getX() - pointRadius, ptNew.getY() - pointRadius, pointRadius * 2, pointRadius * 2);
                ptPrev = ptNew;
            }
        }
        // Render axes (last so it overwrites any values near an axis)
        //Clear the axis area
        gc.clearRect(0.0, 0.0, sizeAxisLabel.getWidth() + pxTickLength, getHeight());
        gc.clearRect(0.0, getHeight() - sizeAxisLabel.getHeight() - pxTickLength, getWidth(), sizeAxisLabel.getHeight());
        //Draw the axes
        gc.setStroke(Color.BLACK);
        gc.setLineWidth(0.5);
        gc.strokeLine(rectChart.getMinX(), 0.0, rectChart.getMinX(), rectChart.getMaxY());
        gc.strokeLine(rectChart.getMinX(), rectChart.getMaxY(), rectChart.getMaxX(), rectChart.getMaxY());
        Font font = Font.font("MONOSPACE", 12.0);
        gc.setFont(font);
        //ticksX and ticksY are lists of the corresponding values; they need to be handed of to the corresponding fnNormalize and then scaled for display.
        double pxLast = -pxMinSpacingBetweenTicks;
        for(TX tickX : ticksX) {
            final double pxX = chartXFromDataX(axisX, tickX);
            if(pxLast + pxMinSpacingBetweenTicks > pxX) {
                continue;
            }
            pxLast = pxX;
            gc.strokeLine(pxX, rectChart.getMaxY(), pxX, rectChart.getMaxY() + pxTickLength);
            final String textLabel = fnFormatX.apply(tickX);
            textForMeasuring.setText(textLabel);
            final Bounds boundsText = textForMeasuring.getLayoutBounds();
            double offsetY = 0.5 * boundsText.getWidth() + 0.87 * boundsText.getHeight();
            double offsetX = -0.87 * boundsText.getWidth();
            gc.save();
            // Translate then rotate to rotate text around local origin rather than rotating around the canvas origin.
            // Rotating and drawing at an offset results in a rotation around the origin.
            gc.translate(pxX + offsetX, rectChart.getMaxY() + offsetY);
            gc.rotate(-30.0);
            gc.strokeText(textLabel, 0.0, 0.0);
            gc.restore();
        }
        for(TY tickY : ticksY) {
            final double pxY = chartYFromDataY(axisY, tickY);
            gc.strokeLine(rectChart.getMinX() - pxTickLength, pxY, rectChart.getMinX(), pxY);
            final String textLabel = fnFormatY.apply(tickY);
            textForMeasuring.setText(textLabel);
            gc.strokeText(fnFormatY.apply(tickY), 0.0, pxY + textForMeasuring.getLayoutBounds().getHeight());
        }
    }
TX x = fnXValue.apply(value);
TY y = fnYValue.apply(value);
// Add rectViewport.getMinY() instead of subtracting because we're mirroring the Y coordinate around the X-axis.
Point2D ptNew = new Point2D(
chartXFromDataX(axisX, x),
// Render series data, build tooltip cache
renderedPoints.clear();
for(Map.Entry<Series, LinkedList<T>> entry : data.entrySet()) {
Point2D ptPrev = null;
gc.setStroke(entry.getKey().getColor());
//TODO: Make this customizable
gc.setLineWidth(2.0);
for(T value : entry.getValue()) {
TX x = fnXValue.apply(value);
TY y = fnYValue.apply(value);
// Add rectViewport.getMinY() instead of subtracting because we're mirroring the Y coordinate around the X-axis.
Point2D ptNew = new Point2D(
chartXFromDataX(axisX, x),
chartYFromDataY(axisY, y));
Point<T, TX, TY> pt = new Point<>(
value,
x,
y,
ptNew,
entry.getKey().getColor()
);
textForMeasuring.setText(fnFormatX.apply(tickX));
final Bounds boundsText = textForMeasuring.getLayoutBounds();
maxHeight = Math.max(maxHeight, 0.5 * boundsText.getWidth() + 0.87 * boundsText.getHeight());
//TODO: Also check maxWidth against the amount by which this would underflow the X=0 line
}
final Rectangle2D sizeAxisLabel = new Rectangle2D(0.0, 0.0, maxWidth, maxHeight);
if(getWidth() <= sizeAxisLabel.getWidth() || getHeight() <= sizeAxisLabel.getHeight()) {
return;
}
rectChart = new Rectangle2D(sizeAxisLabel.getWidth() + pxTickLength, 0.0, getWidth() - sizeAxisLabel.getWidth() - pxTickLength, getHeight() - sizeAxisLabel.getHeight() - pxTickLength);
// Render series data, build tooltip cache
renderedPoints.clear();
for(Map.Entry<Series, LinkedList<T>> entry : data.entrySet()) {
Point2D ptPrev = null;
gc.setStroke(entry.getKey().getColor());
//TODO: Make this customizable
gc.setLineWidth(2.0);
for(T value : entry.getValue()) {
TX x = fnXValue.apply(value);
TY y = fnYValue.apply(value);
// Add rectViewport.getMinY() instead of subtracting because we're mirroring the Y coordinate around the X-axis.
Point2D ptNew = new Point2D(
chartXFromDataX(axisX, x),
chartYFromDataY(axisY, y));
Point<T, TX, TY> pt = new Point<>(
value,
x,
y,
ptNew,
entry.getKey().getColor()
);
renderedPoints.add(pt);
if(ptPrev != null) {
gc.strokeLine(ptPrev.getX(), ptPrev.getY(), ptNew.getX(), ptNew.getY());
}
gc.strokeOval(ptNew.getX() - pointRadius, ptNew.getY() - pointRadius, pointRadius * 2, pointRadius * 2);
ptPrev = ptNew;
}
}
// Render axes (last so it overwrites any values near an axis)
//Clear the axis area