SwingFundamentalDiagram.java

package org.opentrafficsim.swing.graphs;

import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import javax.swing.ButtonGroup;
import javax.swing.JMenu;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JRadioButtonMenuItem;

import org.djunits.value.vdouble.scalar.Duration;
import org.djutils.event.Event;
import org.djutils.event.EventListener;
import org.jfree.chart.ChartMouseEvent;
import org.jfree.chart.ChartMouseListener;
import org.jfree.chart.annotations.XYAnnotation;
import org.jfree.chart.annotations.XYLineAnnotation;
import org.jfree.chart.annotations.XYTextAnnotation;
import org.jfree.chart.entity.AxisEntity;
import org.jfree.chart.entity.XYItemEntity;
import org.jfree.chart.ui.TextAnchor;
import org.jfree.data.Range;
import org.opentrafficsim.animation.graphs.FdDataSource;
import org.opentrafficsim.animation.graphs.FundamentalDiagram;
import org.opentrafficsim.animation.graphs.GraphUtil;
import org.opentrafficsim.animation.graphs.FundamentalDiagram.Quantity;

/**
 * Embed a {@link FundamentalDiagram} in a swing {@link JPanel}.
 * <p>
 * Copyright (c) 2023-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 Alexander Verbraeck
 * @author Peter Knoppers
 * @author Wouter Schakel
 */
public class SwingFundamentalDiagram extends SwingPlot implements EventListener
{
    /** Serialization version UID. */
    private static final long serialVersionUID = 20190823L;

    /** Whether to ignore events as this is itself the plot causing change of aggregationPeriod/updatesPerPeriod. */
    private boolean ignoreEvent = false;

    /** Aggregation period UI buttons. */
    private Map<JRadioButtonMenuItem, Double> aggregationPeriodButtons;

    /** Updates per period UI buttons. */
    private Map<JRadioButtonMenuItem, Integer> updatesPerPeriodButtons;

    /**
     * Construct a new Swing container for FundamentalDiagram plot.
     * @param plot the plot to embed
     */
    public SwingFundamentalDiagram(final FundamentalDiagram plot)
    {
        super(plot);
        plot.addListener(this, FdDataSource.UPDATES_PER_PERIOD);
        plot.addListener(this, FdDataSource.AGGREGATION_PERIOD);
    }

    @Override
    protected Optional<ChartMouseListener> getChartMouseListener()
    {
        ChartMouseListener toggle = !getPlot().hasLineFD() && getPlot().getNumberOfSeries() < 2 ? null
                : GraphUtil.getToggleSeriesByLegendListener(getPlot(), getPlot().getLegend(), getPlot().getLaneVisible());
        return Optional.of(new ChartMouseListener()
        {
            @Override
            public void chartMouseClicked(final ChartMouseEvent event)
            {
                if (toggle != null)
                {
                    toggle.chartMouseClicked(event); // forward as we use two listeners
                }
                // remove any line annotations
                for (XYAnnotation annotation : ((List<XYAnnotation>) getPlot().getChart().getXYPlot().getAnnotations()))
                {
                    if (annotation instanceof XYLineAnnotation)
                    {
                        getPlot().getChart().getXYPlot().removeAnnotation(annotation);
                    }
                }
                // add line annotation for each item in series if the user clicked in an item
                if (event.getEntity() instanceof XYItemEntity)
                {
                    XYItemEntity itemEntity = (XYItemEntity) event.getEntity();
                    int series = itemEntity.getSeriesIndex();
                    for (int i = 0; i < getPlot().getItemCount(series) - 1; i++)
                    {
                        double x0 = getPlot().getXValue(series, i);
                        double y0 = getPlot().getYValue(series, i);
                        double x1 = getPlot().getXValue(series, i + 1);
                        double y1 = getPlot().getYValue(series, i + 1);
                        if (!Double.isNaN(x0) && !Double.isNaN(y0) && !Double.isNaN(x1) && !Double.isNaN(y1))
                        {
                            XYLineAnnotation annotation =
                                    new XYLineAnnotation(x0, y0, x1, y1, new BasicStroke(1.0f), Color.WHITE);
                            getPlot().getChart().getXYPlot().addAnnotation(annotation);
                        }
                    }
                }
                else if (event.getEntity() instanceof AxisEntity)
                {
                    if (((AxisEntity) event.getEntity()).getAxis().equals(getPlot().getChart().getXYPlot().getDomainAxis()))
                    {
                        Quantity old = getPlot().getDomainQuantity();
                        getPlot().setDomainQuantity(getPlot().getOtherQuantity());
                        getPlot().setOtherQuantity(old);
                        getPlot().getChart().getXYPlot().getDomainAxis().setLabel(getPlot().getDomainQuantity().label());
                        getPlot().getChart().getXYPlot().zoomDomainAxes(0.0, null, null);
                    }
                    else
                    {
                        Quantity old = getPlot().getRangeQuantity();
                        getPlot().setRangeQuantity(getPlot().getOtherQuantity());
                        getPlot().setOtherQuantity(old);
                        getPlot().getChart().getXYPlot().getRangeAxis().setLabel(getPlot().getRangeQuantity().label());
                        getPlot().getChart().getXYPlot().zoomRangeAxes(0.0, null, null);
                    }
                }
            }

            @Override
            public void chartMouseMoved(final ChartMouseEvent event)
            {
                if (toggle != null)
                {
                    toggle.chartMouseMoved(event); // forward as we use two listeners
                }
                boolean clearText = true;
                // set text annotation and status text to time of item
                if (event.getEntity() instanceof XYItemEntity)
                {
                    // create time info for status label
                    XYItemEntity itemEntity = (XYItemEntity) event.getEntity();
                    int series = itemEntity.getSeriesIndex();
                    if (!getPlot().hasLineFD() || series != getPlot().getSeriesCount() - 1)
                    {
                        clearText = false;
                        int item = itemEntity.getItem();
                        double t = item * getPlot().getUpdateInterval().si;
                        getPlot().setTimeInfo(String.format(", %.0fs", t));
                        double x = getPlot().getXValue(series, item);
                        double y = getPlot().getYValue(series, item);
                        Range domain = getPlot().getChart().getXYPlot().getDomainAxis().getRange();
                        Range range = getPlot().getChart().getXYPlot().getRangeAxis().getRange();
                        TextAnchor anchor;
                        if (range.getUpperBound() - y < y - range.getLowerBound())
                        {
                            // upper half
                            if (domain.getUpperBound() - x < x - domain.getLowerBound())
                            {
                                // upper right quadrant
                                anchor = TextAnchor.TOP_RIGHT;
                            }
                            else
                            {
                                // upper left quadrant, can't use TOP_LEFT as text will be under mouse pointer
                                if ((range.getUpperBound() - y)
                                        / (range.getUpperBound() - range.getLowerBound()) < (x - domain.getLowerBound())
                                                / (domain.getUpperBound() - domain.getLowerBound()))
                                {
                                    // closer to top (at least relatively) so move text down
                                    anchor = TextAnchor.TOP_RIGHT;
                                }
                                else
                                {
                                    // closer to left (at least relatively) so move text right
                                    anchor = TextAnchor.BOTTOM_LEFT;
                                }
                            }
                        }
                        else if (domain.getUpperBound() - x < x - domain.getLowerBound())
                        {
                            // lower right quadrant
                            anchor = TextAnchor.BOTTOM_RIGHT;
                        }
                        else
                        {
                            // lower left quadrant
                            anchor = TextAnchor.BOTTOM_LEFT;
                        }
                        XYTextAnnotation textAnnotation = new XYTextAnnotation(String.format("%.0fs", t), x, y);
                        textAnnotation.setTextAnchor(anchor);
                        textAnnotation.setFont(textAnnotation.getFont().deriveFont(14.0f).deriveFont(Font.BOLD));
                        getPlot().getChart().getXYPlot().addAnnotation(textAnnotation);
                    }
                }
                // remove texts when mouse is elsewhere, or on FD line
                if (clearText)
                {
                    for (XYAnnotation annotation : ((List<XYAnnotation>) getPlot().getChart().getXYPlot().getAnnotations()))
                    {
                        if (annotation instanceof XYTextAnnotation)
                        {
                            getPlot().getChart().getXYPlot().removeAnnotation(annotation);
                        }
                    }
                    getPlot().setTimeInfo("");
                }
            }
        });
    }

    @Override
    protected void addPopUpMenuItems(final JPopupMenu popupMenu)
    {
        super.addPopUpMenuItems(popupMenu);
        popupMenu.insert(new JPopupMenu.Separator(), 0);

        this.aggregationPeriodButtons = new LinkedHashMap<>();
        this.updatesPerPeriodButtons = new LinkedHashMap<>();

        JMenu updMenu = new JMenu("Update frequency");
        ButtonGroup updGroup = new ButtonGroup();
        for (int f : getPlot().getPossibleUpdatesPerPeriod())
        {
            String format = "%dx";
            JRadioButtonMenuItem item = new JRadioButtonMenuItem(String.format(format, f));
            this.updatesPerPeriodButtons.put(item, f);
            item.setSelected(f == getPlot().getDefaultUpdatesPerPeriod());
            item.addActionListener(new ActionListener()
            {
                @Override
                public void actionPerformed(final ActionEvent e)
                {
                    SwingFundamentalDiagram.this.ignoreEvent = true;
                    getPlot().getChart().getXYPlot().clearAnnotations();
                    getPlot().setUpdatesPerPeriod(f);
                    SwingFundamentalDiagram.this.ignoreEvent = false;
                }
            });
            updGroup.add(item);
            updMenu.add(item);
        }
        popupMenu.insert(updMenu, 0);

        JMenu aggMenu = new JMenu("Aggregation period");
        ButtonGroup aggGroup = new ButtonGroup();
        for (Duration t : getPlot().getPossibleAggregationPeriods())
        {
            double t2 = t.si;
            String format = "%.0f s";
            if (t.si >= 60.0)
            {
                t2 = t.si / 60.0;
                format = "%.0f min";
            }
            JRadioButtonMenuItem item = new JRadioButtonMenuItem(String.format(format, t2));
            this.aggregationPeriodButtons.put(item, t.si);
            item.setSelected(t.si == getPlot().getDefaultAggregationPeriod().si);
            item.addActionListener(new ActionListener()
            {
                @Override
                public void actionPerformed(final ActionEvent e)
                {
                    SwingFundamentalDiagram.this.ignoreEvent = true;
                    getPlot().getChart().getXYPlot().clearAnnotations();
                    getPlot().setAggregationPeriod(t);
                    SwingFundamentalDiagram.this.ignoreEvent = false;
                }

            });
            aggGroup.add(item);
            aggMenu.add(item);
        }
        popupMenu.insert(aggMenu, 0);
    }

    /**
     * Retrieve the plot.
     * @return the plot
     */
    @Override
    public FundamentalDiagram getPlot()
    {
        return (FundamentalDiagram) super.getPlot();
    }

    @Override
    public void notify(final Event event)
    {
        if (this.ignoreEvent)
        {
            return;
        }
        if (event.getType().equals(FdDataSource.AGGREGATION_PERIOD))
        {
            Object[] payload = (Object[]) event.getContent();
            Duration aggregation = (Duration) payload[0];
            for (JRadioButtonMenuItem button : this.aggregationPeriodButtons.keySet())
            {
                button.setSelected(Math.abs(this.aggregationPeriodButtons.get(button) - aggregation.si) < 0.001);
            }
        }
        else if (event.getType().equals(FdDataSource.UPDATES_PER_PERIOD))
        {
            Object[] payload = (Object[]) event.getContent();
            int n = (int) payload[0];
            for (JRadioButtonMenuItem button : this.updatesPerPeriodButtons.keySet())
            {
                button.setSelected(this.updatesPerPeriodButtons.get(button) == n);
            }
        }
    }

}