OtsGtuColorPanel.java

package org.opentrafficsim.swing.gui;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.geom.Rectangle2D;
import java.text.NumberFormat;
import java.util.function.Predicate;

import javax.swing.JComboBox;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.border.Border;
import javax.swing.border.LineBorder;

import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.axis.NumberTickUnit;
import org.jfree.chart.axis.TickUnit;
import org.jfree.chart.axis.TickUnitSource;
import org.jfree.chart.title.PaintScaleLegend;
import org.jfree.chart.ui.RectangleEdge;
import org.jfree.chart.ui.RectangleInsets;
import org.jfree.data.Range;
import org.opentrafficsim.animation.colorer.ColorbarColorer;
import org.opentrafficsim.animation.colorer.Colorer;
import org.opentrafficsim.animation.colorer.LegendColorer;
import org.opentrafficsim.animation.colorer.LegendColorer.LegendEntry;
import org.opentrafficsim.animation.data.gtu.GtuColorerManager;
import org.opentrafficsim.animation.data.gtu.GtuColorerManager.PredicatedColorer;
import org.opentrafficsim.core.gtu.Gtu;

/**
 * Let the user select what the GTU color in the animation means.
 * <p>
 * Copyright (c) 2013-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
 * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
 * </p>
 * @author Peter Knoppers
 * @author Wouter Schakel
 */
public class OtsGtuColorPanel extends JPanel implements ActionListener, AppearanceControl
{

    /** Serialization version UID. */
    private static final long serialVersionUID = 20150527L;

    /** Combo box that sets the coloring for the GTUs. */
    private final JComboBox<PredicatedColorer> comboBoxGTUColor = new AppearanceControlComboBox<>();

    /** Panel that holds the legend for the currently selected GTU colorer. */
    private final JPanel legendPanel;

    /** GTU colorer manager. */
    private final GtuColorerManager gtuColorerManager = new GtuColorerManager(Color.WHITE);

    /** GTU colorer that is currently active. */
    private Colorer<? super Gtu> gtuColorer;

    /** GTU colorer from properties. */
    private String lastGtuColorer;

    /**
     * Constructor.
     */
    public OtsGtuColorPanel()
    {
        this.setLayout(new FlowLayout(FlowLayout.LEFT));
        this.legendPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 5, 0));
        this.add(this.comboBoxGTUColor);
        this.add(this.legendPanel);
        this.comboBoxGTUColor.addActionListener(this);
        // remember this state, as adding each colorer triggers an event that overwrites this property
        this.lastGtuColorer = OtsSimulationPanel.PROPERTIES.getProperty("gtuColorer");
    }

    /**
     * Add GTU colorer.
     * @param colorer the GTU colorer to add
     */
    public void addGtuColorer(final Colorer<? super Gtu> colorer)
    {
        Predicate<Gtu> predicate = (gtu) -> colorer.equals(OtsGtuColorPanel.this.gtuColorer);
        PredicatedColorer predicatedColorer = new PredicatedColorer(predicate, colorer);
        this.comboBoxGTUColor.addItem(predicatedColorer);
        this.gtuColorerManager.add(predicate, colorer);
        if (this.gtuColorer == null || colorer.getName().equals(this.lastGtuColorer))
        {
            this.gtuColorer = colorer;
            this.comboBoxGTUColor.setSelectedItem(predicatedColorer);
        }
        rebuildLegend();
    }

    /**
     * Returns the GTU colorer manager.
     * @return GTU colorer manager
     */
    public GtuColorerManager getGtuColorerManager()
    {
        return this.gtuColorerManager;
    }

    @Override
    public void actionPerformed(final ActionEvent e)
    {
        PredicatedColorer newColorerWrapper = (PredicatedColorer) this.comboBoxGTUColor.getSelectedItem();
        if (null != newColorerWrapper)
        {
            this.gtuColorer = newColorerWrapper.colorer();
            OtsSimulationPanel.PROPERTIES.setProperty("gtuColorer", this.gtuColorer.getName());
            rebuildLegend();
        }
    }

    /**
     * Build or rebuild the legend on the screen.
     */
    private void rebuildLegend()
    {
        this.legendPanel.removeAll();
        if (this.gtuColorer instanceof ColorbarColorer<?> colorbarColorer)
        {
            NumberAxis scaleAxis = new DivisibleNumberAxis();
            scaleAxis.setNumberFormatOverride(colorbarColorer.getNumberFormat());
            // reduce tick insets from [t=2.0,l=4.0,b=2.0,r=4.0] to cramp legend in small ColorControlPanel
            scaleAxis.setTickLabelInsets(new RectangleInsets(0.0, 4.0, 0.0, 4.0));
            scaleAxis.setTickLabelPaint(getForeground());
            scaleAxis.setStandardTickUnits(new CustomTickUnitSource());
            PaintScaleLegend colorbar = new PaintScaleLegend(colorbarColorer.getBoundsPaintScale(), scaleAxis);
            // 13px is all that fits without increasing the height of the ColorControlPanel, 1px margin for other computers
            colorbar.setStripWidth(12.0);
            colorbar.setSubdivisionCount(256);
            colorbar.setPadding(0.0, 25.0, 0.0, 25.0); // horizontal padding for numbers (and units) centered at extreme ticks
            colorbar.setBackgroundPaint(new Color(0, 0, 0, 0));

            JPanel panel = new JPanel()
            {
                private static final long serialVersionUID = 1L;

                @Override
                public void paint(final Graphics g)
                {
                    colorbar.draw((Graphics2D) g, getBounds());
                }
            };
            panel.setPreferredSize(new Dimension(400, 26));
            this.legendPanel.add(panel);
        }
        else if (this.gtuColorer instanceof LegendColorer<?> legendColorer)
        {
            for (LegendEntry legendEntry : legendColorer.getLegend())
            {
                JPanel panel = new JPanel(new BorderLayout());
                /** ColorBox for AppearanceControl. */
                class ColorBox extends JLabel implements AppearanceControl
                {
                    /** Serialization version UID. */
                    private static final long serialVersionUID = 20180206L;

                    /** Constructor. */
                    ColorBox()
                    {
                        super("     ");
                    }

                    @Override
                    public String toString()
                    {
                        return "ColorBox []";
                    }
                }
                ColorBox colorBox = new ColorBox();
                colorBox.setOpaque(true); // By default, the label is transparent
                colorBox.setBackground(legendEntry.color());
                Border border = LineBorder.createBlackLineBorder();
                colorBox.setBorder(border);
                panel.add(colorBox, BorderLayout.LINE_START);
                JLabel name = new JLabel(" " + legendEntry.name().trim());
                panel.add(name, BorderLayout.CENTER);
                name.setOpaque(true);
                name.setForeground(getForeground());
                name.setBackground(getBackground());
                panel.setToolTipText(legendEntry.description());
                this.legendPanel.add(panel);
            }
        }
        this.legendPanel.revalidate();

        Container parentPanel = this.getParent();
        if (parentPanel != null)
        {
            parentPanel.getParent().repaint();
        }
    }

    @Override
    public boolean isBackground()
    {
        return true;
    }

    @Override
    public boolean isForeground()
    {
        return true;
    }

    @Override
    public void setForeground(final Color fg)
    {
        super.setForeground(fg);
        if (this.legendPanel != null)
        {
            // update tick label color which is based on the foreground color
            rebuildLegend();
        }
    }

    @Override
    public String toString()
    {
        return "OtsGtucolorPanel [gtuColorer=" + this.gtuColorer + "]";
    }

    /**
     * Tick unit logic that follows non-default steps per decade.
     */
    private static class CustomTickUnitSource implements TickUnitSource
    {

        /** Steps per decade. */
        private static final double[] STEPS = {1.0, 2.0, 3.0, 4.0, 5.0, 10.0};

        /**
         * Constructor.
         */
        CustomTickUnitSource()
        {
            //
        }

        @Override
        public TickUnit getLargerTickUnit(final TickUnit unit)
        {
            double next = nextCeil(unit.getSize(), false);
            return new NumberTickUnit(next, NumberFormat.getNumberInstance());
        }

        @Override
        public TickUnit getCeilingTickUnit(final double size)
        {
            double next = nextCeil(size, true);
            return new NumberTickUnit(next, NumberFormat.getNumberInstance());
        }

        @Override
        public TickUnit getCeilingTickUnit(final TickUnit unit)
        {
            return getCeilingTickUnit(unit.getSize());
        }

        /**
         * Returns the next tick size.
         * @param size current size
         * @param equalOk whether candidate may be equal to size
         * @return the next tick size
         */
        private static double nextCeil(final double size, final boolean equalOk)
        {
            int exp = (int) Math.floor(Math.log10(size));
            double pow = Math.pow(10.0, exp);

            for (double step : STEPS)
            {
                double candidate = step * pow;
                if (candidate > size || (equalOk && candidate == size))
                {
                    return candidate;
                }
            }
            // If none in this decade is strictly greater, go to the next decade.
            return Math.pow(10.0, exp + 1);
        }

    }

    /**
     * NumberAxis that prefers auto tick units which divide the current range length. It defers to the normal JFreeChart
     * algorithm to satisfy label spacing, then walks upward through available tick units until it finds one that results in an
     * integer number of intervals across the axis range, with a minimum number of accepted intervals.
     */
    private static class DivisibleNumberAxis extends NumberAxis
    {

        /** Serialization version UID. */
        private static final long serialVersionUID = 1L;

        /** Integer interval margin. */
        private static final double EPS = 1e-9;

        /** Minimum number of major ticks we'll tolerate after promotion. */
        private int minTicks = 4;

        /** Maximum number of major ticks we'll tolerate after promotion. */
        private int maxTicks = 15;

        /**
         * Constructor.
         */
        DivisibleNumberAxis()
        {
            super("");
        }

        @Override
        protected void selectAutoTickUnit(final Graphics2D g2, final Rectangle2D dataArea, final RectangleEdge edge)
        {
            // Let JFreeChart do its normal auto-selection first (respects label sizes/orientation).
            super.selectAutoTickUnit(g2, dataArea, edge);

            // Then "nudge" to a unit that divides the range if possible.
            Range r = getRange();
            double length = r.getLength();
            if (!(length > 0))
            {
                return;
            }

            NumberTickUnit current = getTickUnit();
            double baseSize = current.getSize();

            TickUnitSource source = getStandardTickUnits();

            // Search upward through available units for the first one that:
            // (1) is >= the base size (to keep label-fit constraints), and
            // (2) divides the range length (within EPS), and
            // (3) yields a reasonable number of ticks.
            NumberTickUnit best = null;
            int bestScore = -1;

            NumberTickUnit candidate = current;
            final int maxHops = 10;
            for (int i = 0; i < maxHops; i++)
            {
                double size = candidate.getSize();
                if (size + EPS < baseSize)
                {
                    candidate = (NumberTickUnit) source.getLargerTickUnit(candidate);
                    continue;
                }

                double q = length / size;
                int approxTicks = (int) Math.round(q);
                boolean dividesRange = nearlyInteger(q);
                boolean ticksOk = approxTicks >= this.minTicks && approxTicks <= this.maxTicks;

                if (dividesRange && ticksOk)
                {
                    int score = 1;

                    // Bonus if the lower bound is (almost) a multiple of the candidate size,
                    // so ticks line up nicely with the lower edge.
                    double lower = r.getLowerBound();
                    double k = lower / size;
                    if (nearlyInteger(k))
                    {
                        score = 2;
                    }

                    if (score > bestScore)
                    {
                        bestScore = score;
                        best = candidate;
                        // If we found the highest score possible, we can stop early.
                        if (score == 2)
                        {
                            break;
                        }
                    }
                }

                candidate = (NumberTickUnit) source.getLargerTickUnit(candidate);
            }

            if (best != null && best.getSize() >= baseSize - EPS)
            {
                // Don't notify; we're still inside the selection pass.
                setTickUnit(best, false, false);
            }
        }

        /**
         * Returns {@code true} when the value is nearly an integer.
         * @param x value
         * @return {@code true} when the value is nearly an integer
         */
        private boolean nearlyInteger(final double x)
        {
            return Math.abs(x - Math.rint(x)) <= EPS * Math.max(1.0, Math.abs(x));
        }

    }

}