1 package org.opentrafficsim.animation.graphs;
2
3 import java.awt.Color;
4 import java.awt.Font;
5 import java.awt.Graphics2D;
6 import java.awt.geom.AffineTransform;
7 import java.awt.geom.Rectangle2D;
8 import java.awt.image.BufferedImage;
9 import java.io.IOException;
10 import java.util.ArrayList;
11 import java.util.LinkedHashSet;
12 import java.util.List;
13 import java.util.Set;
14 import java.util.UUID;
15 import java.util.concurrent.BlockingQueue;
16 import java.util.concurrent.CompletableFuture;
17 import java.util.concurrent.LinkedBlockingQueue;
18 import java.util.concurrent.atomic.AtomicBoolean;
19 import java.util.concurrent.atomic.AtomicReference;
20
21 import javax.swing.SwingUtilities;
22
23 import org.djunits.value.vdouble.scalar.Duration;
24 import org.djutils.base.Identifiable;
25 import org.djutils.event.EventType;
26 import org.djutils.metadata.MetaData;
27 import org.djutils.metadata.ObjectDescriptor;
28 import org.jfree.chart.ChartUtils;
29 import org.jfree.chart.JFreeChart;
30 import org.jfree.chart.plot.XYPlot;
31 import org.jfree.chart.title.TextTitle;
32 import org.jfree.data.general.Dataset;
33 import org.jfree.data.general.DatasetChangeEvent;
34 import org.jfree.data.general.DatasetChangeListener;
35 import org.jfree.data.general.DatasetGroup;
36 import org.opentrafficsim.animation.graphs.AbstractPlot.PaintState;
37 import org.opentrafficsim.base.logger.Logger;
38
39 /**
40 * Super class of all plots. This schedules regular updates, creates menus and deals with listeners. There are a number of
41 * methods for sub-classes to implement.
42 * <p>
43 * Copyright (c) 2013-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
44 * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
45 * </p>
46 * @author Alexander Verbraeck
47 * @author Peter Knoppers
48 * @author Wouter Schakel
49 * @param <S> paint state implementation
50 */
51 public abstract class AbstractPlot<S extends PaintState> implements Identifiable, Dataset
52 {
53
54 /**
55 * The (regular, not timed) event type for pub/sub indicating the addition of a graph. Not used internally.<br>
56 * Payload: String graph caption (not an array, just a String)
57 */
58 public static final EventType GRAPH_ADD_EVENT = new EventType("GRAPH.ADD",
59 new MetaData("Graph add", "Graph added", new ObjectDescriptor("Graph id", "Id of the graph", String.class)));
60
61 /**
62 * The (regular, not timed) event type for pub/sub indicating the removal of a graph. Not used internally.<br>
63 * Payload: String Graph caption (not an array, just a String)
64 */
65 public static final EventType GRAPH_REMOVE_EVENT = new EventType("GRAPH.REMOVE",
66 new MetaData("Graph remove", "Graph removed", new ObjectDescriptor("Graph id", "Id of the graph", String.class)));
67
68 /** Initial upper bound for the time scale. */
69 public static final Duration DEFAULT_INITIAL_UPPER_TIME_BOUND = Duration.ofSI(300.0);
70
71 /** Scheduler. */
72 private final PlotScheduler scheduler;
73
74 /** Unique ID of the chart. */
75 private final String id = UUID.randomUUID().toString();
76
77 /** Caption. */
78 private final String caption;
79
80 /** The chart, so we can export it. */
81 private JFreeChart chart;
82
83 /** List of parties interested in changes of this plot. */
84 private Set<DatasetChangeListener> listeners = new LinkedHashSet<>();
85
86 /** Delay so critical future events have occurred, e.g. GTU's next move to extend trajectories. */
87 private final Duration delay;
88
89 /** Update interval. */
90 private Duration updateInterval;
91
92 /** New update interval to use. */
93 private volatile Duration suggestedUpdateInterval;
94
95 /** Queue for the worker thread. */
96 private BlockingQueue<Duration> workerQueue = new LinkedBlockingQueue<>();
97
98 /** Current paint state. */
99 private volatile S paintState;
100
101 /** Thread safe offered paint state. */
102 private final AtomicReference<S> pendingPaintState = new AtomicReference<>();
103
104 /** Makes sure the paint state is only set once. */
105 private final AtomicBoolean adoptionPosted = new AtomicBoolean(false);
106
107 /**
108 * Constructor.
109 * @param scheduler scheduler
110 * @param caption caption
111 * @param updateInterval regular update interval (simulation time)
112 * @param delay amount of time that chart runs behind simulation to prevent gaps in the charted data
113 */
114 public AbstractPlot(final PlotScheduler scheduler, final String caption, final Duration updateInterval,
115 final Duration delay)
116 {
117 this.scheduler = scheduler;
118 this.caption = caption;
119 this.updateInterval = updateInterval;
120 this.delay = delay;
121 this.paintState = emptyPaintState();
122 scheduleUpdateEvent(); // start redraw chain
123
124 // worker thread
125 Thread invokingThread = Thread.currentThread();
126 Thread thread = new Thread(new Runnable()
127 {
128 @Override
129 public void run()
130 {
131 while (!invokingThread.isInterrupted())
132 {
133 try
134 {
135 Duration time = AbstractPlot.this.workerQueue.take();
136 if (time != null && AbstractPlot.this.workerQueue.isEmpty()) // only take last update request
137 {
138 calculatePaintState(time);
139 }
140 }
141 catch (InterruptedException exception)
142 {
143 Logger.ots().error(exception, "Worker thread for plot {} stopped.", AbstractPlot.this.caption);
144 break;
145 }
146 }
147 }
148 }, AbstractPlot.this.caption);
149 thread.setDaemon(true);
150 thread.start();
151 }
152
153 /**
154 * Returns an empty paint state. This is used at plot initialization.
155 * @return empty paint state.
156 */
157 protected abstract S emptyPaintState();
158
159 /**
160 * Sets the chart and adds menus and listeners.
161 * @param chart chart
162 */
163 protected void setChart(final JFreeChart chart)
164 {
165 this.chart = chart;
166
167 // make title somewhat smaller
168 chart.setTitle(new TextTitle(chart.getTitle().getText(), new Font("SansSerif", java.awt.Font.BOLD, 16)));
169
170 // default colors and zoom behavior
171 chart.getPlot().setBackgroundPaint(Color.LIGHT_GRAY);
172 chart.setBackgroundPaint(Color.WHITE);
173 if (chart.getPlot() instanceof XYPlot)
174 {
175 chart.getXYPlot().setDomainGridlinePaint(Color.WHITE);
176 chart.getXYPlot().setRangeGridlinePaint(Color.WHITE);
177 }
178 }
179
180 /**
181 * Returns the chart as a byte array representing a PNG image.
182 * @param width width
183 * @param height height
184 * @param fontSize font size (16 is the original on screen size)
185 * @return the chart as a byte array representing a PNG image
186 * @throws IOException on IO exception
187 */
188 public byte[] encodeAsPng(final int width, final int height, final double fontSize) throws IOException
189 {
190 // to double the font size, we halve the base dimensions
191 // JFreeChart will the assign more area (relatively) to the fixed actual font size
192 double baseWidth = width / (fontSize / 16);
193 double baseHeight = height / (fontSize / 16);
194 // this code is from ChartUtils.writeScaledChartAsPNG
195 BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
196 Graphics2D g2 = image.createGraphics();
197 // to compensate for the base dimensions which are not w x h, we scale the drawing
198 AffineTransform saved = g2.getTransform();
199 g2.transform(AffineTransform.getScaleInstance(width / baseWidth, height / baseHeight));
200 getChart().draw(g2, new Rectangle2D.Double(0, 0, baseWidth, baseHeight), null, null);
201 g2.setTransform(saved);
202 g2.dispose();
203 return ChartUtils.encodeAsPNG(image);
204 }
205
206 @Override
207 public final DatasetGroup getGroup()
208 {
209 return null; // not used
210 }
211
212 @Override
213 public final void setGroup(final DatasetGroup group)
214 {
215 // not used
216 }
217
218 /**
219 * Overridable; activates auto bounds on domain axis from user input. This class does not force the use of {@link XYPlot}s,
220 * but the auto bounds command comes from the {@code ChartPanel} that shows this plot. In case the used plot is a
221 * {@link XYPlot}, this method is then invoked. Sub classes with auto domain bounds that work with an {@link XYPlot} should
222 * implement this. The method is not abstract as the use of {@code XYPlot} is not obligated.
223 * @param plot plot
224 */
225 public void setAutoBoundDomain(final XYPlot plot)
226 {
227 throw new UnsupportedOperationException("Plot is a XYPlot but does not implement setAutoBoundDomain");
228 }
229
230 /**
231 * Overridable; activates auto bounds on range axis from user input. This class does not force the use of {@link XYPlot}s,
232 * but the auto bounds command comes from the {@code ChartPanel} that shows this plot. In case the used plot is a
233 * {@link XYPlot}, this method is then invoked. Sub classes with auto range bounds that work with an {@link XYPlot} should
234 * implement this. The method is not abstract as the use of {@code XYPlot} is not obligated.
235 * @param plot plot
236 */
237 public void setAutoBoundRange(final XYPlot plot)
238 {
239 throw new UnsupportedOperationException("Plot is a XYPlot but does not implement setAutoBoundRange");
240 }
241
242 /**
243 * Return the graph type for transceiver.
244 * @return the graph type.
245 */
246 public abstract GraphType getGraphType();
247
248 /**
249 * Returns the status label when the mouse is over the given location.
250 * @param domainValue domain value (x-axis)
251 * @param rangeValue range value (y-axis)
252 * @return status label when the mouse is over the given location
253 */
254 public abstract String getStatusLabel(double domainValue, double rangeValue);
255
256 /**
257 * Returns the chart.
258 * @return chart
259 */
260 public JFreeChart getChart()
261 {
262 return this.chart;
263 }
264
265 @Override
266 public String getId()
267 {
268 return this.id;
269 }
270
271 /**
272 * Retrieve the caption.
273 * @return the caption of the plot
274 */
275 public String getCaption()
276 {
277 return this.caption;
278 }
279
280 // ===== Listeners =====
281
282 @Override
283 public void addChangeListener(final DatasetChangeListener listener)
284 {
285 this.listeners.add(listener);
286 }
287
288 @Override
289 public void removeChangeListener(final DatasetChangeListener listener)
290 {
291 this.listeners.remove(listener);
292 }
293
294 /**
295 * Notify all change listeners.
296 */
297 public void notifyPlotChange()
298 {
299 // take a snapshot to avoid concurrent modification during iteration
300 final List<DatasetChangeListener> snapshot;
301 synchronized (this)
302 {
303 snapshot = new ArrayList<>(this.listeners);
304 }
305
306 Runnable r = () ->
307 {
308 DatasetChangeEvent event = new DatasetChangeEvent(this, this);
309 for (DatasetChangeListener dcl : snapshot)
310 {
311 dcl.datasetChanged(event);
312 }
313 };
314
315 // invoke only on Swing EDT
316 if (SwingUtilities.isEventDispatchThread())
317 {
318 r.run();
319 }
320 else
321 {
322 SwingUtilities.invokeLater(r);
323 }
324 }
325
326 // ===== Paint state =====
327
328 /**
329 * Requests a calculation of the paint state. May be invoked by sub-classes whenever a setting was changed that needs a
330 * recalculation.
331 */
332 protected void invalidate()
333 {
334 this.workerQueue.offer(this.scheduler.getTime());
335 }
336
337 /**
338 * Calculates the paint state object and offers it through {@link #offerPaintState}, or delegates this work to a delegate.
339 * This method is invoked by the worker thread and can thus perform heavy calculations outside of the Swing EDT.
340 * Intermediate paint states during long calculations may also be offered. It is up to the implementation to either
341 * calculate a complete paint state, or cumulatively built on the results from previous calls. It is also up to the
342 * implementation to know when the whole time span needs to be recalculated due to property changes.
343 * @param time time until which data in the paint state should be calculated
344 */
345 protected abstract void calculatePaintState(Duration time);
346
347 /**
348 * Offer new paint state. This method can be invoked by any thread, and will make sure the actual setting of the paint state
349 * 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
350 * for data to paint). Listeners are notified on the Swing EDT as soon as the paint state has been set.
351 * @param paintState paint state
352 */
353 @SuppressWarnings("hiddenfield")
354 public void offerPaintState(final S paintState)
355 {
356 Logger.ots().trace("Offering paint state on plot: {}", this.caption);
357 this.pendingPaintState.set(paintState);
358 if (this.adoptionPosted.compareAndSet(false, true))
359 {
360 if (SwingUtilities.isEventDispatchThread())
361 {
362 setPaintState();
363 }
364 else
365 {
366 SwingUtilities.invokeLater(() -> setPaintState());
367 }
368 }
369 }
370
371 /**
372 * Sets the paint state in a thread safe manner and notifies the listeners. This method is always invoked on the Swing EDT.
373 * This method may be overridden to use a newly set paint state (after calling {@code super.setPaintState()}) to set
374 * internal properties. For example, setting the block size of an internal block renderer based on the granularity of the
375 * data.
376 */
377 protected void setPaintState()
378 {
379 Logger.ots().trace("Setting paint state on plot: {}", this.caption);
380 try
381 {
382 S s = this.pendingPaintState.getAndSet(null);
383 if (s != null)
384 {
385 // single point where the visible paint state changes
386 this.paintState = s;
387 // notify on Swing EDT; painting will occur after this completes
388 Logger.ots().trace("Notifying plot changed: {}", this.caption);
389 notifyPlotChange();
390 }
391 }
392 finally
393 {
394 this.adoptionPosted.set(false);
395 }
396 }
397
398 /**
399 * Returns the current paint state that should be used to return paint data (i.e. x-values, etc.)
400 * @return current paint state
401 */
402 protected S getPaintState()
403 {
404 return this.paintState;
405 }
406
407 /**
408 * Returns up to what time data is available for painting.
409 * @return up to what time data is available for painting
410 */
411 public Duration getAvailableTime()
412 {
413 return this.paintState.getAvailableTime();
414 }
415
416 // ===== Update chain =====
417
418 /**
419 * Suggests a new update interval. This does not affect any time granularity, but only when update events occur. This method
420 * will ask the {@link PlotScheduler} to schedule an update now. The next update will set the update interval and schedule
421 * the next regular update aligning with the new interval. If an update is also desired right now {@link #invalidate} needs
422 * to be invoked. This method is typically called on the Swing EDT and will request the scheduler asynchronously. This
423 * method does not block.
424 * @param interval update interval
425 */
426 public void offerUpdateInterval(final Duration interval)
427 {
428 this.suggestedUpdateInterval = interval;
429 // run asynchronous because we do not want the Swing EDT to wait for the simulation thread semaphore
430 CompletableFuture.runAsync(() ->
431 {
432 this.scheduler.scheduleUpdateNow(this); // divert to scheduling thread
433 });
434 }
435
436 /*
437 * Implementation note: A specific problem is prevented by using offerUpdateInterval() to schedule an update, and update()
438 * to then take up the new update interval. When the interval is changed, the next time to schedule an update needs to be
439 * determined. If this is done by offerUpdateInterval() by taking the current simulation time and adding a delta within the
440 * Swing EDT thread, the scheduler thread may progress time beyond the resulting update time before the event is actually
441 * scheduled. Only the scheduler thread should be in control of time and update event scheduling. Furthermore the scheduling
442 * is parallelized to allow the Swing EDT to not wait for a potentially very busy simulator thread.
443 */
444
445 /**
446 * Requests the worker thread to perform calculations up to the current time and (in the Swing EDT thread) update the plots.
447 * The worker thread will take on this request once any current calculations are done. If multiple updates are requested
448 * before the worker thread is done, only the update with latest time is executed. This method should only be invoked by the
449 * thread that governs time (typically by the {@link PlotScheduler}). Otherwise events may be erroneously scheduled in the
450 * past.
451 * <p>
452 * After a new update interval was suggested through {@link #offerUpdateInterval} this method does not do the above, but
453 * instead only schedules the next update aligning with the new interval.
454 */
455 public void update()
456 {
457 if (this.updateInterval != null && this.suggestedUpdateInterval != null
458 && !this.updateInterval.equals(this.suggestedUpdateInterval))
459 {
460 // take up new update interval and reset 'updates' to fall in alignment
461 this.updateInterval = this.suggestedUpdateInterval;
462 this.suggestedUpdateInterval = null;
463 }
464 else
465 {
466 invalidate();
467 }
468 scheduleUpdateEvent();
469 }
470
471 /**
472 * Schedules the next update event.
473 */
474 private void scheduleUpdateEvent()
475 {
476 double t = this.scheduler.getTime().si;
477 int n = (int) (t / this.updateInterval.si) + 1; // robust to accidental duplicate/out-of-tempo updates
478 // events are scheduled slightly later, so all influencing movements have occurred
479 double tNext = this.updateInterval.si * n + this.delay.si;
480 if (tNext <= t)
481 {
482 tNext += this.updateInterval.si;
483 }
484 this.scheduler.scheduleUpdate(Duration.ofSI(tNext), this);
485 }
486
487 /**
488 * Interface for paint state objects.
489 */
490 interface PaintState
491 {
492
493 /**
494 * Returns up to what time data is available for painting.
495 * @return up to what time data is available for painting
496 */
497 Duration getAvailableTime();
498
499 }
500
501 }