AbstractPlot.java
package org.opentrafficsim.animation.graphs;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import javax.swing.SwingUtilities;
import org.djunits.value.vdouble.scalar.Duration;
import org.djutils.base.Identifiable;
import org.djutils.event.EventType;
import org.djutils.metadata.MetaData;
import org.djutils.metadata.ObjectDescriptor;
import org.jfree.chart.ChartUtils;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.title.TextTitle;
import org.jfree.data.general.Dataset;
import org.jfree.data.general.DatasetChangeEvent;
import org.jfree.data.general.DatasetChangeListener;
import org.jfree.data.general.DatasetGroup;
import org.opentrafficsim.animation.graphs.AbstractPlot.PaintState;
import org.opentrafficsim.base.logger.Logger;
/**
* Super class of all plots. This schedules regular updates, creates menus and deals with listeners. There are a number of
* methods for sub-classes to implement.
* <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 Alexander Verbraeck
* @author Peter Knoppers
* @author Wouter Schakel
* @param <S> paint state implementation
*/
public abstract class AbstractPlot<S extends PaintState> implements Identifiable, Dataset
{
/**
* The (regular, not timed) event type for pub/sub indicating the addition of a graph. Not used internally.<br>
* Payload: String graph caption (not an array, just a String)
*/
public static final EventType GRAPH_ADD_EVENT = new EventType("GRAPH.ADD",
new MetaData("Graph add", "Graph added", new ObjectDescriptor("Graph id", "Id of the graph", String.class)));
/**
* The (regular, not timed) event type for pub/sub indicating the removal of a graph. Not used internally.<br>
* Payload: String Graph caption (not an array, just a String)
*/
public static final EventType GRAPH_REMOVE_EVENT = new EventType("GRAPH.REMOVE",
new MetaData("Graph remove", "Graph removed", new ObjectDescriptor("Graph id", "Id of the graph", String.class)));
/** Initial upper bound for the time scale. */
public static final Duration DEFAULT_INITIAL_UPPER_TIME_BOUND = Duration.ofSI(300.0);
/** Scheduler. */
private final PlotScheduler scheduler;
/** Unique ID of the chart. */
private final String id = UUID.randomUUID().toString();
/** Caption. */
private final String caption;
/** The chart, so we can export it. */
private JFreeChart chart;
/** List of parties interested in changes of this plot. */
private Set<DatasetChangeListener> listeners = new LinkedHashSet<>();
/** Delay so critical future events have occurred, e.g. GTU's next move to extend trajectories. */
private final Duration delay;
/** Update interval. */
private Duration updateInterval;
/** New update interval to use. */
private volatile Duration suggestedUpdateInterval;
/** Queue for the worker thread. */
private BlockingQueue<Duration> workerQueue = new LinkedBlockingQueue<>();
/** Current paint state. */
private volatile S paintState;
/** Thread safe offered paint state. */
private final AtomicReference<S> pendingPaintState = new AtomicReference<>();
/** Makes sure the paint state is only set once. */
private final AtomicBoolean adoptionPosted = new AtomicBoolean(false);
/**
* Constructor.
* @param scheduler scheduler
* @param caption caption
* @param updateInterval regular update interval (simulation time)
* @param delay amount of time that chart runs behind simulation to prevent gaps in the charted data
*/
public AbstractPlot(final PlotScheduler scheduler, final String caption, final Duration updateInterval,
final Duration delay)
{
this.scheduler = scheduler;
this.caption = caption;
this.updateInterval = updateInterval;
this.delay = delay;
this.paintState = emptyPaintState();
scheduleUpdateEvent(); // start redraw chain
// worker thread
Thread invokingThread = Thread.currentThread();
Thread thread = new Thread(new Runnable()
{
@Override
public void run()
{
while (!invokingThread.isInterrupted())
{
try
{
Duration time = AbstractPlot.this.workerQueue.take();
if (time != null && AbstractPlot.this.workerQueue.isEmpty()) // only take last update request
{
calculatePaintState(time);
}
}
catch (InterruptedException exception)
{
Logger.ots().error(exception, "Worker thread for plot {} stopped.", AbstractPlot.this.caption);
break;
}
}
}
}, AbstractPlot.this.caption);
thread.setDaemon(true);
thread.start();
}
/**
* Returns an empty paint state. This is used at plot initialization.
* @return empty paint state.
*/
protected abstract S emptyPaintState();
/**
* Sets the chart and adds menus and listeners.
* @param chart chart
*/
protected void setChart(final JFreeChart chart)
{
this.chart = chart;
// make title somewhat smaller
chart.setTitle(new TextTitle(chart.getTitle().getText(), new Font("SansSerif", java.awt.Font.BOLD, 16)));
// default colors and zoom behavior
chart.getPlot().setBackgroundPaint(Color.LIGHT_GRAY);
chart.setBackgroundPaint(Color.WHITE);
if (chart.getPlot() instanceof XYPlot)
{
chart.getXYPlot().setDomainGridlinePaint(Color.WHITE);
chart.getXYPlot().setRangeGridlinePaint(Color.WHITE);
}
}
/**
* Returns the chart as a byte array representing a PNG image.
* @param width width
* @param height height
* @param fontSize font size (16 is the original on screen size)
* @return the chart as a byte array representing a PNG image
* @throws IOException on IO exception
*/
public byte[] encodeAsPng(final int width, final int height, final double fontSize) throws IOException
{
// to double the font size, we halve the base dimensions
// JFreeChart will the assign more area (relatively) to the fixed actual font size
double baseWidth = width / (fontSize / 16);
double baseHeight = height / (fontSize / 16);
// this code is from ChartUtils.writeScaledChartAsPNG
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g2 = image.createGraphics();
// to compensate for the base dimensions which are not w x h, we scale the drawing
AffineTransform saved = g2.getTransform();
g2.transform(AffineTransform.getScaleInstance(width / baseWidth, height / baseHeight));
getChart().draw(g2, new Rectangle2D.Double(0, 0, baseWidth, baseHeight), null, null);
g2.setTransform(saved);
g2.dispose();
return ChartUtils.encodeAsPNG(image);
}
@Override
public final DatasetGroup getGroup()
{
return null; // not used
}
@Override
public final void setGroup(final DatasetGroup group)
{
// not used
}
/**
* Overridable; activates auto bounds on domain axis from user input. This class does not force the use of {@link XYPlot}s,
* but the auto bounds command comes from the {@code ChartPanel} that shows this plot. In case the used plot is a
* {@link XYPlot}, this method is then invoked. Sub classes with auto domain bounds that work with an {@link XYPlot} should
* implement this. The method is not abstract as the use of {@code XYPlot} is not obligated.
* @param plot plot
*/
public void setAutoBoundDomain(final XYPlot plot)
{
throw new UnsupportedOperationException("Plot is a XYPlot but does not implement setAutoBoundDomain");
}
/**
* Overridable; activates auto bounds on range axis from user input. This class does not force the use of {@link XYPlot}s,
* but the auto bounds command comes from the {@code ChartPanel} that shows this plot. In case the used plot is a
* {@link XYPlot}, this method is then invoked. Sub classes with auto range bounds that work with an {@link XYPlot} should
* implement this. The method is not abstract as the use of {@code XYPlot} is not obligated.
* @param plot plot
*/
public void setAutoBoundRange(final XYPlot plot)
{
throw new UnsupportedOperationException("Plot is a XYPlot but does not implement setAutoBoundRange");
}
/**
* Return the graph type for transceiver.
* @return the graph type.
*/
public abstract GraphType getGraphType();
/**
* Returns the status label when the mouse is over the given location.
* @param domainValue domain value (x-axis)
* @param rangeValue range value (y-axis)
* @return status label when the mouse is over the given location
*/
public abstract String getStatusLabel(double domainValue, double rangeValue);
/**
* Returns the chart.
* @return chart
*/
public JFreeChart getChart()
{
return this.chart;
}
@Override
public String getId()
{
return this.id;
}
/**
* Retrieve the caption.
* @return the caption of the plot
*/
public String getCaption()
{
return this.caption;
}
// ===== Listeners =====
@Override
public void addChangeListener(final DatasetChangeListener listener)
{
this.listeners.add(listener);
}
@Override
public void removeChangeListener(final DatasetChangeListener listener)
{
this.listeners.remove(listener);
}
/**
* Notify all change listeners.
*/
public void notifyPlotChange()
{
// take a snapshot to avoid concurrent modification during iteration
final List<DatasetChangeListener> snapshot;
synchronized (this)
{
snapshot = new ArrayList<>(this.listeners);
}
Runnable r = () ->
{
DatasetChangeEvent event = new DatasetChangeEvent(this, this);
for (DatasetChangeListener dcl : snapshot)
{
dcl.datasetChanged(event);
}
};
// invoke only on Swing EDT
if (SwingUtilities.isEventDispatchThread())
{
r.run();
}
else
{
SwingUtilities.invokeLater(r);
}
}
// ===== Paint state =====
/**
* Requests a calculation of the paint state. May be invoked by sub-classes whenever a setting was changed that needs a
* recalculation.
*/
protected void invalidate()
{
this.workerQueue.offer(this.scheduler.getTime());
}
/**
* Calculates the paint state object and offers it through {@link #offerPaintState}, or delegates this work to a delegate.
* This method is invoked by the worker thread and can thus perform heavy calculations outside of the Swing EDT.
* Intermediate paint states during long calculations may also be offered. It is up to the implementation to either
* calculate a complete paint state, or cumulatively built on the results from previous calls. It is also up to the
* implementation to know when the whole time span needs to be recalculated due to property changes.
* @param time time until which data in the paint state should be calculated
*/
protected abstract void calculatePaintState(Duration time);
/**
* Offer new paint state. This method can be invoked by any thread, and will make sure the actual setting of the paint state
* will occur on the Swing EDT. This assures that no paint state is changed as Swing is painting (i.e. as the plot is asked
* for data to paint). Listeners are notified on the Swing EDT as soon as the paint state has been set.
* @param paintState paint state
*/
@SuppressWarnings("hiddenfield")
public void offerPaintState(final S paintState)
{
Logger.ots().trace("Offering paint state on plot: {}", this.caption);
this.pendingPaintState.set(paintState);
if (this.adoptionPosted.compareAndSet(false, true))
{
if (SwingUtilities.isEventDispatchThread())
{
setPaintState();
}
else
{
SwingUtilities.invokeLater(() -> setPaintState());
}
}
}
/**
* Sets the paint state in a thread safe manner and notifies the listeners. This method is always invoked on the Swing EDT.
* This method may be overridden to use a newly set paint state (after calling {@code super.setPaintState()}) to set
* internal properties. For example, setting the block size of an internal block renderer based on the granularity of the
* data.
*/
protected void setPaintState()
{
Logger.ots().trace("Setting paint state on plot: {}", this.caption);
try
{
S s = this.pendingPaintState.getAndSet(null);
if (s != null)
{
// single point where the visible paint state changes
this.paintState = s;
// notify on Swing EDT; painting will occur after this completes
Logger.ots().trace("Notifying plot changed: {}", this.caption);
notifyPlotChange();
}
}
finally
{
this.adoptionPosted.set(false);
}
}
/**
* Returns the current paint state that should be used to return paint data (i.e. x-values, etc.)
* @return current paint state
*/
protected S getPaintState()
{
return this.paintState;
}
/**
* Returns up to what time data is available for painting.
* @return up to what time data is available for painting
*/
public Duration getAvailableTime()
{
return this.paintState.getAvailableTime();
}
// ===== Update chain =====
/**
* Suggests a new update interval. This does not affect any time granularity, but only when update events occur. This method
* will ask the {@link PlotScheduler} to schedule an update now. The next update will set the update interval and schedule
* the next regular update aligning with the new interval. If an update is also desired right now {@link #invalidate} needs
* to be invoked. This method is typically called on the Swing EDT and will request the scheduler asynchronously. This
* method does not block.
* @param interval update interval
*/
public void offerUpdateInterval(final Duration interval)
{
this.suggestedUpdateInterval = interval;
// run asynchronous because we do not want the Swing EDT to wait for the simulation thread semaphore
CompletableFuture.runAsync(() ->
{
this.scheduler.scheduleUpdateNow(this); // divert to scheduling thread
});
}
/*
* Implementation note: A specific problem is prevented by using offerUpdateInterval() to schedule an update, and update()
* to then take up the new update interval. When the interval is changed, the next time to schedule an update needs to be
* determined. If this is done by offerUpdateInterval() by taking the current simulation time and adding a delta within the
* Swing EDT thread, the scheduler thread may progress time beyond the resulting update time before the event is actually
* scheduled. Only the scheduler thread should be in control of time and update event scheduling. Furthermore the scheduling
* is parallelized to allow the Swing EDT to not wait for a potentially very busy simulator thread.
*/
/**
* Requests the worker thread to perform calculations up to the current time and (in the Swing EDT thread) update the plots.
* The worker thread will take on this request once any current calculations are done. If multiple updates are requested
* before the worker thread is done, only the update with latest time is executed. This method should only be invoked by the
* thread that governs time (typically by the {@link PlotScheduler}). Otherwise events may be erroneously scheduled in the
* past.
* <p>
* After a new update interval was suggested through {@link #offerUpdateInterval} this method does not do the above, but
* instead only schedules the next update aligning with the new interval.
*/
public void update()
{
if (this.updateInterval != null && this.suggestedUpdateInterval != null
&& !this.updateInterval.equals(this.suggestedUpdateInterval))
{
// take up new update interval and reset 'updates' to fall in alignment
this.updateInterval = this.suggestedUpdateInterval;
this.suggestedUpdateInterval = null;
}
else
{
invalidate();
}
scheduleUpdateEvent();
}
/**
* Schedules the next update event.
*/
private void scheduleUpdateEvent()
{
double t = this.scheduler.getTime().si;
int n = (int) (t / this.updateInterval.si) + 1; // robust to accidental duplicate/out-of-tempo updates
// events are scheduled slightly later, so all influencing movements have occurred
double tNext = this.updateInterval.si * n + this.delay.si;
if (tNext <= t)
{
tNext += this.updateInterval.si;
}
this.scheduler.scheduleUpdate(Duration.ofSI(tNext), this);
}
/**
* Interface for paint state objects.
*/
interface PaintState
{
/**
* Returns up to what time data is available for painting.
* @return up to what time data is available for painting
*/
Duration getAvailableTime();
}
}