View Javadoc
1   package org.opentrafficsim.animation.graphs;
2   
3   import java.awt.BasicStroke;
4   import java.awt.Color;
5   import java.awt.Paint;
6   import java.awt.Rectangle;
7   import java.awt.Shape;
8   import java.awt.Stroke;
9   import java.awt.geom.CubicCurve2D;
10  import java.awt.geom.Line2D;
11  import java.util.ArrayList;
12  import java.util.LinkedHashMap;
13  import java.util.List;
14  import java.util.Map;
15  
16  import org.djunits.value.vdouble.scalar.Duration;
17  import org.djunits.value.vdouble.scalar.Length;
18  import org.djutils.exceptions.Throw;
19  import org.jfree.chart.JFreeChart;
20  import org.jfree.chart.LegendItem;
21  import org.jfree.chart.LegendItemCollection;
22  import org.jfree.chart.axis.NumberAxis;
23  import org.jfree.chart.entity.EntityCollection;
24  import org.jfree.chart.entity.XYItemEntity;
25  import org.jfree.chart.labels.XYToolTipGenerator;
26  import org.jfree.chart.plot.XYPlot;
27  import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
28  import org.jfree.chart.title.PaintScaleLegend;
29  import org.jfree.chart.ui.RectangleEdge;
30  import org.jfree.chart.ui.RectangleInsets;
31  import org.jfree.data.DomainOrder;
32  import org.jfree.data.xy.XYDataset;
33  import org.opentrafficsim.animation.Colors;
34  import org.opentrafficsim.animation.colorer.ColorbarColorer;
35  import org.opentrafficsim.animation.colorer.Colorer;
36  import org.opentrafficsim.animation.colorer.LegendColorer;
37  import org.opentrafficsim.animation.colorer.LegendColorer.LegendEntry;
38  import org.opentrafficsim.animation.colorer.trajectory.TrajectoryColorer;
39  import org.opentrafficsim.animation.graphs.AbstractPlot.PaintState;
40  import org.opentrafficsim.animation.graphs.GraphPath.Section;
41  import org.opentrafficsim.animation.graphs.OffsetTrajectory.TrajectorySection;
42  import org.opentrafficsim.animation.graphs.TrajectoryPlot.TrajectoriesPaintState;
43  import org.opentrafficsim.kpi.interfaces.LaneData;
44  import org.opentrafficsim.kpi.sampling.SamplerData;
45  import org.opentrafficsim.kpi.sampling.Trajectory;
46  import org.opentrafficsim.kpi.sampling.TrajectoryGroup;
47  
48  /**
49   * Plot of trajectories along a path.
50   * <p>
51   * Copyright (c) 2013-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
52   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
53   * </p>
54   * @author Alexander Verbraeck
55   * @author Peter Knoppers
56   * @author Wouter Schakel
57   */
58  public class TrajectoryPlot extends AbstractSpaceTimePlot<TrajectoriesPaintState> implements XYDataset
59  {
60  
61      /** Sampler data. */
62      private final SamplerData<?> samplerData;
63  
64      /** Lanes registered in the sampler. */
65      private final GraphPath<? extends LaneData<?>> path;
66  
67      /** Single shape to provide due to non-null requirement, but actually not used. */
68      private static final Shape NO_SHAPE = new Line2D.Float(0, 0, 0, 0);
69  
70      /** Color map for multiple curves. */
71      private static final Color[] COLORMAP;
72  
73      /** Strokes. */
74      private static final BasicStroke[] STROKES;
75  
76      /** Shape for the legend entries to draw the line over. */
77      private static final Shape LEGEND_LINE = new CubicCurve2D.Float(-20, 7, -10, -7, 0, 7, 20, -7);
78  
79      /** Counter of the number of trajectories imported per lane. */
80      private final Map<LaneData<?>, Integer> knownTrajectories = new LinkedHashMap<>();
81  
82      /** Per lane, mapping from series rank number to trajectory. */
83      private List<List<OffsetTrajectory>> curves = new ArrayList<>();
84  
85      /** Stroke per series. */
86      private List<List<Stroke>> strokes = new ArrayList<>();
87  
88      /** Number of curves per lane. This may be less than the length of {@code List<OffsetTrajectory>} due to concurrency. */
89      private List<Integer> curvesPerLane = new ArrayList<>();
90  
91      /** Legend to change text color to indicate visibility. */
92      private LegendItemCollection legend;
93  
94      /** Whether each lane is visible or not. */
95      private final List<Boolean> laneVisible = new ArrayList<>();
96  
97      /** Colorer. */
98      private Colorer<? super TrajectorySection> colorer;
99  
100     /** Line renderer. */
101     private XYLineAndShapeRendererColor renderer;
102 
103     /** Color bar. */
104     private PaintScaleLegend colorbar;
105 
106     static
107     {
108         Color[] c = Colors.hue(6);
109         COLORMAP = new Color[] {c[0], c[4], c[2].darker().darker(), c[1], c[3], c[5]};
110         float lw = 1.0f;
111         STROKES = new BasicStroke[] {new BasicStroke(lw, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER, 10.0f, null, 0.0f),
112                 new BasicStroke(lw, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER, 1.0f, new float[] {13f, 4f}, 0.0f),
113                 new BasicStroke(lw, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER, 1.0f, new float[] {11f, 3f, 2f, 3f}, 0.0f)};
114     }
115 
116     /**
117      * Constructor.
118      * @param caption caption
119      * @param updateInterval regular update interval (simulation time)
120      * @param scheduler scheduler.
121      * @param samplerData sampler data
122      * @param path path
123      * @throws IllegalArgumentException when the path contains more than 6 lanes
124      */
125     public TrajectoryPlot(final String caption, final Duration updateInterval, final PlotScheduler scheduler,
126             final SamplerData<?> samplerData, final GraphPath<? extends LaneData<?>> path)
127     {
128         super(caption, updateInterval, scheduler, Duration.ZERO, DEFAULT_INITIAL_UPPER_TIME_BOUND);
129         Throw.when(path.getNumberOfSeries() > 6, IllegalArgumentException.class, "The trajectory plot supports up to 6 lanes");
130         this.samplerData = samplerData;
131         this.path = path;
132         for (int i = 0; i < path.getNumberOfSeries(); i++)
133         {
134             this.curves.add(new ArrayList<>());
135             this.strokes.add(new ArrayList<>());
136             this.curvesPerLane.add(0);
137             this.laneVisible.add(true);
138         }
139         setChart(createChart());
140     }
141 
142     /**
143      * Create a chart.
144      * @return chart
145      */
146     private JFreeChart createChart()
147     {
148         NumberAxis xAxis = new NumberAxis("Time [s] \u2192");
149         NumberAxis yAxis = new NumberAxis("Distance [m] \u2192");
150         this.renderer = new XYLineAndShapeRendererColor();
151         XYPlot plot = new XYPlot(this, xAxis, yAxis, this.renderer);
152         if (this.path.getNumberOfSeries() > 1)
153         {
154             this.legend = new LegendItemCollection();
155             for (int i = 0; i < this.path.getNumberOfSeries(); i++)
156             {
157                 LegendItem li = new LegendItem(this.path.getName(i));
158                 li.setSeriesKey(i); // lane series, not curve series
159                 li.setShape(STROKES[i & STROKES.length].createStrokedShape(LEGEND_LINE));
160                 li.setFillPaint(COLORMAP[i % COLORMAP.length]);
161                 this.legend.add(li);
162             }
163             plot.setFixedLegendItems(this.legend);
164         }
165         return new JFreeChart(getCaption(), JFreeChart.DEFAULT_TITLE_FONT, plot, true);
166     }
167 
168     /**
169      * Sets the color renderer for trajectories.
170      * @param colorer color renderer
171      */
172     public void setColorer(final TrajectoryColorer colorer)
173     {
174         this.colorer = colorer;
175         this.renderer.setDrawSeriesLineAsPath(colorer.isSingleColor());
176         if (this.path.getNumberOfSeries() < 2)
177         {
178             if (this.colorbar != null)
179             {
180                 getChart().removeSubtitle(this.colorbar);
181             }
182             LegendItemCollection colorerLegend = new LegendItemCollection();
183             if (colorer instanceof ColorbarColorer<?> colorbarColorer)
184             {
185                 NumberAxis scaleAxis = new NumberAxis("");
186                 scaleAxis.setNumberFormatOverride(colorbarColorer.getNumberFormat());
187                 // increase tick insets from [t=2.0,l=4.0,b=2.0,r=4.0] to let the automatic ticks be less cluttered
188                 scaleAxis.setTickLabelInsets(new RectangleInsets(5.0, 4.0, 5.0, 4.0));
189                 this.colorbar = new PaintScaleLegend(colorbarColorer.getBoundsPaintScale(), scaleAxis);
190                 this.colorbar.setSubdivisionCount(256);
191                 this.colorbar.setPosition(RectangleEdge.RIGHT);
192                 // some padding to make space for last tick number on adjacent axes, and vertically match those axes
193                 this.colorbar.setPadding(10.0, 15.0, 40.0, 10.0);
194                 getChart().addSubtitle(this.colorbar);
195             }
196             else if (colorer instanceof LegendColorer<?> legendColorer)
197             {
198 
199                 for (LegendEntry entry : legendColorer.getLegend())
200                 {
201                     colorerLegend.add(new LegendItem(entry.name(), entry.name(), entry.name(), entry.name(),
202                             new Rectangle(10, 10), entry.color(), new BasicStroke(0.5f), Color.BLACK));
203                 }
204             }
205             ((XYPlot) getChart().getPlot()).setFixedLegendItems(colorerLegend);
206         }
207     }
208 
209     @Override
210     public GraphType getGraphType()
211     {
212         return GraphType.TRAJECTORY;
213     }
214 
215     @Override
216     public String getStatusLabel(final double domainValue, final double rangeValue)
217     {
218         return String.format("time %.0fs, distance %.0fm", domainValue, rangeValue);
219     }
220 
221     @Override
222     public int getSeriesCount()
223     {
224         return getPaintState().getSeriesCount();
225     }
226 
227     /**
228      * Returns the number of lanes.
229      * @return the number of lanes
230      */
231     public int getLaneCount()
232     {
233         return this.curves.size();
234     }
235 
236     @Override
237     public Comparable<Integer> getSeriesKey(final int series)
238     {
239         return series;
240     }
241 
242     @SuppressWarnings("rawtypes")
243     @Override
244     public int indexOf(final Comparable seriesKey)
245     {
246         return 0;
247     }
248 
249     @Override
250     public DomainOrder getDomainOrder()
251     {
252         return DomainOrder.ASCENDING;
253     }
254 
255     @Override
256     public int getItemCount(final int series)
257     {
258         OffsetTrajectory t = getPaintState().series()[series];
259         return t == null ? 0 : t.size();
260     }
261 
262     @Override
263     public Number getX(final int series, final int item)
264     {
265         return getXValue(series, item);
266     }
267 
268     @Override
269     public double getXValue(final int series, final int item)
270     {
271         return getPaintState().series()[series].getT(item); // time (T) in X axis
272     }
273 
274     @Override
275     public Number getY(final int series, final int item)
276     {
277         return getYValue(series, item);
278     }
279 
280     @Override
281     public double getYValue(final int series, final int item)
282     {
283         return getPaintState().series()[series].getX(item); // space (X) on Y axis
284     }
285 
286     /**
287      * Extension of a line renderer to select a color based on GTU ID, and to overrule an unused shape to save memory.
288      */
289     private final class XYLineAndShapeRendererColor extends XYLineAndShapeRenderer
290     {
291 
292         /** Serialization version UID. */
293         private static final long serialVersionUID = 20181014L;
294 
295         /**
296          * Constructor.
297          */
298         XYLineAndShapeRendererColor()
299         {
300             super(false, true);
301             setDefaultLinesVisible(true);
302             setDefaultShapesVisible(false);
303             setDrawSeriesLineAsPath(true);
304             setDefaultCreateEntities(false);
305             setDefaultItemLabelsVisible(false);
306         }
307 
308         @Override
309         public boolean isSeriesVisible(final int series)
310         {
311             int lane = getPaintState().laneOfSeries()[series];
312             return TrajectoryPlot.this.laneVisible == null || lane >= TrajectoryPlot.this.laneVisible.size() ? false
313                     : TrajectoryPlot.this.laneVisible.get(lane);
314         }
315 
316         @Override
317         public Stroke getSeriesStroke(final int series)
318         {
319             if (getPaintState().laneCount() == 1)
320             {
321                 return STROKES[0];
322             }
323             Stroke s = getPaintState().strokeOfSeries()[series];
324             return (s != null ? s : STROKES[0]);
325         }
326 
327         @Override
328         public Paint getSeriesPaint(final int series)
329         {
330             int lane = getPaintState().laneOfSeries()[series];
331             return COLORMAP[lane % COLORMAP.length];
332         }
333 
334         @Override
335         public Paint getItemPaint(final int row, final int column)
336         {
337             if (TrajectoryPlot.this.colorer == null)
338             {
339                 return getSeriesPaint(row);
340             }
341             return TrajectoryPlot.this.colorer.getColor(new TrajectorySection(getPaintState().series()[row], column));
342         }
343 
344         /**
345          * {@inheritDoc} Largely based on the super implementation, but returns a dummy shape for markers to save memory and as
346          * markers are not used.
347          */
348         @Override
349         protected void addEntity(final EntityCollection entities, final Shape hotspot, final XYDataset dataset,
350                 final int series, final int item, final double entityX, final double entityY)
351         {
352 
353             if (!getItemCreateEntity(series, item))
354             {
355                 return;
356             }
357 
358             // if not hotspot is provided, we create a default based on the
359             // provided data coordinates (which are already in Java2D space)
360             Shape hotspot2 = hotspot == null ? NO_SHAPE : hotspot;
361             String tip = null;
362             XYToolTipGenerator generator = getToolTipGenerator(series, item);
363             if (generator != null)
364             {
365                 tip = generator.generateToolTip(dataset, series, item);
366             }
367             String url = null;
368             if (getURLGenerator() != null)
369             {
370                 url = getURLGenerator().generateURL(dataset, series, item);
371             }
372             XYItemEntity entity = new XYItemEntity(hotspot2, dataset, series, item, tip, url);
373             entities.add(entity);
374         }
375 
376         @Override
377         public String toString()
378         {
379             return "XYLineAndShapeRendererID []";
380         }
381 
382     }
383 
384     @Override
385     public String toString()
386     {
387         return "TrajectoryPlot []";
388     }
389 
390     /**
391      * Retrieve the legend.
392      * @return the legend
393      */
394     public LegendItemCollection getLegend()
395     {
396         return this.legend;
397     }
398 
399     /**
400      * Retrieve the lane visibility flags. These can be set externally in the list.
401      * @return the lane visibility flags
402      */
403     public List<Boolean> getLaneVisible()
404     {
405         return this.laneVisible;
406     }
407 
408     @Override
409     protected final Length getEndLocation()
410     {
411         return this.path.getTotalLength();
412     }
413 
414     @Override
415     protected TrajectoriesPaintState emptyPaintState()
416     {
417         return new TrajectoriesPaintState(new OffsetTrajectory[0], new int[0], new Stroke[0], 0, Duration.ZERO);
418     }
419 
420     @Override
421     protected void calculatePaintState(final Duration time)
422     {
423         // Loop sections, lanes in each section, and new trajectories on each lane, and add them to the curves
424         for (Section<? extends LaneData<?>> section : this.path.getSections())
425         {
426             Length startDistance = this.path.getStartDistance(section);
427             for (int i = 0; i < this.path.getNumberOfSeries(); i++)
428             {
429                 LaneData<?> lane = section.getSource(i);
430                 if (lane == null)
431                 {
432                     continue; // lane is not part of this section, e.g. after a lane-drop
433                 }
434                 TrajectoryGroup<?> trajectoryGroup = this.samplerData.getTrajectoryGroup(lane).orElse(null);
435                 if (trajectoryGroup == null)
436                 {
437                     // recording of data not yet started
438                     return;
439                 }
440                 int from = this.knownTrajectories.getOrDefault(lane, 0);
441                 int to = trajectoryGroup.size();
442                 double scaleFactor = section.length().si / lane.getLength().si;
443                 for (Trajectory<?> trajectory : trajectoryGroup.getTrajectories().subList(from, to))
444                 {
445                     if (this.path.getNumberOfSeries() > 1)
446                     {
447                         // assign a stroke with random offset, otherwise it will look artificial
448                         BasicStroke stroke = STROKES[i % STROKES.length];
449                         if (stroke.getDashArray() != null)
450                         {
451                             float dashLength = 0.0f;
452                             for (float d : stroke.getDashArray())
453                             {
454                                 dashLength += d;
455                             }
456                             stroke = new BasicStroke(stroke.getLineWidth(), stroke.getEndCap(), stroke.getLineJoin(),
457                                     stroke.getMiterLimit(), stroke.getDashArray(), (float) (Math.random() * dashLength));
458                         }
459                         this.strokes.get(i).add(stroke);
460                     }
461                     this.curves.get(i).add(new OffsetTrajectory(trajectory, startDistance, scaleFactor));
462                 }
463                 this.knownTrajectories.put(lane, to);
464             }
465         }
466 
467         // Build complete paint state based on all known curves and offer it
468         offerPaintState(buildPaintStateFromLists(time));
469     }
470 
471     /**
472      * Build paint state from collected data.
473      * @param time time until which data in the paint state should be calculated
474      * @return paint state
475      */
476     private TrajectoriesPaintState buildPaintStateFromLists(final Duration time)
477     {
478         // Gather sizes
479         int laneCount = this.curves.size();
480         int totalSeries = 0;
481         for (int i = 0; i < laneCount; i++)
482         {
483             totalSeries += this.curves.get(i).size();
484         }
485 
486         // Allocate arrays
487         OffsetTrajectory[] series = new OffsetTrajectory[totalSeries];
488         int[] laneOfSeries = new int[totalSeries];
489         Stroke[] strokeOfSeries = (laneCount > 1) ? new Stroke[totalSeries] : null;
490 
491         // Fill (contiguously per lane)
492         int k = 0;
493         for (int lane = 0; lane < laneCount; lane++)
494         {
495             List<OffsetTrajectory> laneCurves = this.curves.get(lane);
496             List<Stroke> laneStrokes = (laneCount > 1) ? this.strokes.get(lane) : null;
497             for (int j = 0; j < laneCurves.size(); j++)
498             {
499                 series[k] = laneCurves.get(j);
500                 laneOfSeries[k] = lane;
501                 if (strokeOfSeries != null)
502                 {
503                     strokeOfSeries[k] = laneStrokes.get(j);
504                 }
505                 k++;
506             }
507         }
508 
509         return new TrajectoriesPaintState(series, laneOfSeries, (strokeOfSeries != null ? strokeOfSeries : new Stroke[0]),
510                 laneCount, time);
511     }
512 
513     /**
514      * Paint state for trajectory plot.
515      * @param series trajectories to paint
516      * @param laneOfSeries lane within which the series fall
517      * @param strokeOfSeries stroke to use per series
518      * @param laneCount number of lanes
519      * @param getAvailableTime time until which data is available for painting
520      */
521     public record TrajectoriesPaintState(OffsetTrajectory[] series, int[] laneOfSeries, Stroke[] strokeOfSeries, int laneCount,
522             Duration getAvailableTime) implements PaintState
523     {
524 
525         /**
526          * Returns the number of series.
527          * @return number of series
528          */
529         int getSeriesCount()
530         {
531             return this.series().length;
532         }
533 
534     }
535 
536 }