View Javadoc
1   package org.opentrafficsim.draw.graphs;
2   
3   import java.awt.BasicStroke;
4   import java.awt.Color;
5   import java.awt.Paint;
6   import java.awt.Shape;
7   import java.awt.Stroke;
8   import java.awt.geom.CubicCurve2D;
9   import java.awt.geom.Line2D;
10  import java.util.ArrayList;
11  import java.util.LinkedHashMap;
12  import java.util.List;
13  import java.util.Map;
14  
15  import org.djunits.value.vdouble.scalar.Duration;
16  import org.djunits.value.vdouble.scalar.Length;
17  import org.djunits.value.vdouble.scalar.Time;
18  import org.djutils.exceptions.Try;
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.data.DomainOrder;
29  import org.jfree.data.xy.XYDataset;
30  import org.opentrafficsim.draw.BoundsPaintScale;
31  import org.opentrafficsim.draw.Colors;
32  import org.opentrafficsim.draw.graphs.GraphPath.Section;
33  import org.opentrafficsim.kpi.interfaces.LaneData;
34  import org.opentrafficsim.kpi.sampling.SamplerData;
35  import org.opentrafficsim.kpi.sampling.Trajectory;
36  import org.opentrafficsim.kpi.sampling.TrajectoryGroup;
37  
38  /**
39   * Plot of trajectories along a path.
40   * <p>
41   * Copyright (c) 2013-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
42   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
43   * </p>
44   * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
45   * @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
46   * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
47   */
48  public class TrajectoryPlot extends AbstractSamplerPlot implements XYDataset
49  {
50      /** Single shape to provide due to non-null requirement, but actually not used. */
51      private static final Shape NO_SHAPE = new Line2D.Float(0, 0, 0, 0);
52  
53      /** Color map. */
54      private static final Color[] COLORMAP;
55  
56      /** Strokes. */
57      private static final BasicStroke[] STROKES;
58  
59      /** Shape for the legend entries to draw the line over. */
60      private static final Shape LEGEND_LINE = new CubicCurve2D.Float(-20, 7, -10, -7, 0, 7, 20, -7);
61  
62      /** Updater for update times. */
63      private final GraphUpdater<Time> graphUpdater;
64  
65      /** Counter of the number of trajectories imported per lane. */
66      private final Map<LaneData<?>, Integer> knownTrajectories = new LinkedHashMap<>();
67  
68      /** Per lane, mapping from series rank number to trajectory. */
69      private List<List<OffsetTrajectory>> curves = new ArrayList<>();
70  
71      /** Stroke per series. */
72      private List<List<Stroke>> strokes = new ArrayList<>();
73  
74      /** Number of curves per lane. This may be less than the length of {@code List<OffsetTrajectory>} due to concurrency. */
75      private List<Integer> curvesPerLane = new ArrayList<>();
76  
77      /** Legend to change text color to indicate visibility. */
78      private LegendItemCollection legend;
79  
80      /** Whether each lane is visible or not. */
81      private final List<Boolean> laneVisible = new ArrayList<>();
82  
83      static
84      {
85          Color[] c = BoundsPaintScale.hue(6);
86          COLORMAP = new Color[] {c[0], c[4], c[2], c[1], c[3], c[5]};
87          float lw = 0.4f;
88          STROKES = new BasicStroke[] {new BasicStroke(lw, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER, 10.0f, null, 0.0f),
89                  new BasicStroke(lw, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER, 1.0f, new float[] {13f, 4f}, 0.0f),
90                  new BasicStroke(lw, BasicStroke.CAP_SQUARE, BasicStroke.JOIN_MITER, 1.0f, new float[] {11f, 3f, 2f, 3f}, 0.0f)};
91      }
92  
93      /**
94       * Constructor.
95       * @param caption caption
96       * @param updateInterval regular update interval (simulation time)
97       * @param scheduler scheduler.
98       * @param samplerData sampler data
99       * @param path path
100      */
101     public TrajectoryPlot(final String caption, final Duration updateInterval, final PlotScheduler scheduler,
102             final SamplerData<?> samplerData, final GraphPath<? extends LaneData<?>> path)
103     {
104         super(caption, updateInterval, scheduler, samplerData, path, Duration.ZERO);
105         for (int i = 0; i < path.getNumberOfSeries(); i++)
106         {
107             this.curves.add(new ArrayList<>());
108             this.strokes.add(new ArrayList<>());
109             this.curvesPerLane.add(0);
110             this.laneVisible.add(true);
111         }
112         setChart(createChart());
113 
114         // setup updater to do the actual work in another thread
115         this.graphUpdater = new GraphUpdater<>("Trajectories worker", Thread.currentThread(), (t) ->
116         {
117             for (Section<? extends LaneData<?>> section : path.getSections())
118             {
119                 Length startDistance = path.getStartDistance(section);
120                 for (int i = 0; i < path.getNumberOfSeries(); i++)
121                 {
122                     LaneData<?> lane = section.getSource(i);
123                     if (lane == null)
124                     {
125                         continue; // lane is not part of this section, e.g. after a lane-drop
126                     }
127                     TrajectoryGroup<?> trajectoryGroup = getSamplerData().getTrajectoryGroup(lane);
128                     int from = this.knownTrajectories.getOrDefault(lane, 0);
129                     int to = trajectoryGroup.size();
130                     double scaleFactor = section.length().si / lane.getLength().si;
131                     for (Trajectory<?> trajectory : trajectoryGroup.getTrajectories().subList(from, to))
132                     {
133                         if (getPath().getNumberOfSeries() > 1)
134                         {
135                             // assign a stroke with random offset, otherwise it will look artificial
136                             BasicStroke stroke = STROKES[i % STROKES.length];
137                             if (stroke.getDashArray() != null)
138                             {
139                                 float dashLength = 0.0f;
140                                 for (float d : stroke.getDashArray())
141                                 {
142                                     dashLength += d;
143                                 }
144                                 stroke = new BasicStroke(stroke.getLineWidth(), stroke.getEndCap(), stroke.getLineJoin(),
145                                         stroke.getMiterLimit(), stroke.getDashArray(), (float) (Math.random() * dashLength));
146                             }
147                             this.strokes.get(i).add(stroke);
148                         }
149                         this.curves.get(i).add(new OffsetTrajectory(trajectory, startDistance, scaleFactor, lane.getLength()));
150                     }
151                     this.knownTrajectories.put(lane, to);
152                 }
153             }
154         });
155     }
156 
157     /**
158      * Create a chart.
159      * @return chart
160      */
161     private JFreeChart createChart()
162     {
163         NumberAxis xAxis = new NumberAxis("Time [s] \u2192");
164         NumberAxis yAxis = new NumberAxis("Distance [m] \u2192");
165         XYLineAndShapeRendererID renderer = new XYLineAndShapeRendererID();
166         XYPlot plot = new XYPlot(this, xAxis, yAxis, renderer);
167         boolean showLegend;
168         if (getPath().getNumberOfSeries() < 2)
169         {
170             plot.setFixedLegendItems(null);
171             showLegend = false;
172         }
173         else
174         {
175             this.legend = new LegendItemCollection();
176             for (int i = 0; i < getPath().getNumberOfSeries(); i++)
177             {
178                 LegendItem li = new LegendItem(getPath().getName(i));
179                 li.setSeriesKey(i); // lane series, not curve series
180                 li.setShape(STROKES[i & STROKES.length].createStrokedShape(LEGEND_LINE));
181                 li.setFillPaint(COLORMAP[i % COLORMAP.length]);
182                 this.legend.add(li);
183             }
184             plot.setFixedLegendItems(this.legend);
185             showLegend = true;
186         }
187         return new JFreeChart(getCaption(), JFreeChart.DEFAULT_TITLE_FONT, plot, showLegend);
188     }
189 
190     @Override
191     public GraphType getGraphType()
192     {
193         return GraphType.TRAJECTORY;
194     }
195 
196     @Override
197     public String getStatusLabel(final double domainValue, final double rangeValue)
198     {
199         return String.format("time %.0fs, distance %.0fm", domainValue, rangeValue);
200     }
201 
202     @Override
203     protected void increaseTime(final Time time)
204     {
205         if (this.graphUpdater != null) // null during construction
206         {
207             this.graphUpdater.offer(time);
208         }
209     }
210 
211     @Override
212     public int getSeriesCount()
213     {
214         int n = 0;
215         for (int i = 0; i < this.curves.size(); i++)
216         {
217             List<OffsetTrajectory> list = this.curves.get(i);
218             int m = list.size();
219             this.curvesPerLane.set(i, m);
220             n += m;
221         }
222         return n;
223     }
224 
225     @Override
226     public Comparable<Integer> getSeriesKey(final int series)
227     {
228         return series;
229     }
230 
231     @SuppressWarnings("rawtypes")
232     @Override
233     public int indexOf(final Comparable seriesKey)
234     {
235         return 0;
236     }
237 
238     @Override
239     public DomainOrder getDomainOrder()
240     {
241         return DomainOrder.ASCENDING;
242     }
243 
244     @Override
245     public int getItemCount(final int series)
246     {
247         OffsetTrajectory trajectory = getTrajectory(series);
248         return trajectory == null ? 0 : trajectory.size();
249     }
250 
251     @Override
252     public Number getX(final int series, final int item)
253     {
254         return getXValue(series, item);
255     }
256 
257     @Override
258     public double getXValue(final int series, final int item)
259     {
260         return getTrajectory(series).getT(item);
261     }
262 
263     @Override
264     public Number getY(final int series, final int item)
265     {
266         return getYValue(series, item);
267     }
268 
269     @Override
270     public double getYValue(final int series, final int item)
271     {
272         return getTrajectory(series).getX(item);
273     }
274 
275     /**
276      * Get the trajectory of the series number.
277      * @param series series
278      * @return trajectory of the series number
279      */
280     private OffsetTrajectory getTrajectory(final int series)
281     {
282         int[] n = getLaneAndSeriesNumber(series);
283         return this.curves.get(n[0]).get(n[1]);
284     }
285 
286     /**
287      * Returns the lane number, and series number within the lane data.
288      * @param series overall series number
289      * @return lane number, and series number within the lane data
290      */
291     private int[] getLaneAndSeriesNumber(final int series)
292     {
293         int n = series;
294         for (int i = 0; i < this.curves.size(); i++)
295         {
296             int m = this.curvesPerLane.get(i);
297             if (n < m)
298             {
299                 return new int[] {i, n};
300             }
301             n -= m;
302         }
303         throw new RuntimeException("Discrepancy between series number and available data.");
304     }
305 
306     /**
307      * Extension of a line renderer to select a color based on GTU ID, and to overrule an unused shape to save memory.
308      * <p>
309      * Copyright (c) 2013-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
310      * <br>
311      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
312      * </p>
313      * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
314      * @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
315      * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
316      */
317     private final class XYLineAndShapeRendererID extends XYLineAndShapeRenderer
318     {
319         /** */
320         private static final long serialVersionUID = 20181014L;
321 
322         /**
323          * Constructor.
324          */
325         XYLineAndShapeRendererID()
326         {
327             super(false, true);
328             setDefaultLinesVisible(true);
329             setDefaultShapesVisible(false);
330             setDrawSeriesLineAsPath(true);
331         }
332 
333         @SuppressWarnings("synthetic-access")
334         @Override
335         public boolean isSeriesVisible(final int series)
336         {
337             int[] n = getLaneAndSeriesNumber(series);
338             return TrajectoryPlot.this.laneVisible.get(n[0]);
339         }
340 
341         @SuppressWarnings("synthetic-access")
342         @Override
343         public Stroke getSeriesStroke(final int series)
344         {
345             if (TrajectoryPlot.this.curves.size() == 1)
346             {
347                 return STROKES[0];
348             }
349             int[] n = getLaneAndSeriesNumber(series);
350             return TrajectoryPlot.this.strokes.get(n[0]).get(n[1]);
351         }
352 
353         @SuppressWarnings("synthetic-access")
354         @Override
355         public Paint getSeriesPaint(final int series)
356         {
357             if (TrajectoryPlot.this.curves.size() == 1)
358             {
359                 String gtuId = getTrajectory(series).getGtuId();
360                 for (int pos = gtuId.length(); --pos >= 0;)
361                 {
362                     Character c = gtuId.charAt(pos);
363                     if (Character.isDigit(c))
364                     {
365                         return Colors.get(c - '0');
366                     }
367                 }
368             }
369             int[] n = getLaneAndSeriesNumber(series);
370             return COLORMAP[n[0] % COLORMAP.length];
371         }
372 
373         /**
374          * {@inheritDoc} Largely based on the super implementation, but returns a dummy shape for markers to save memory and as
375          * markers are not used.
376          */
377         @SuppressWarnings("synthetic-access")
378         @Override
379         protected void addEntity(final EntityCollection entities, final Shape hotspot, final XYDataset dataset,
380                 final int series, final int item, final double entityX, final double entityY)
381         {
382 
383             if (!getItemCreateEntity(series, item))
384             {
385                 return;
386             }
387 
388             // if not hotspot is provided, we create a default based on the
389             // provided data coordinates (which are already in Java2D space)
390             Shape hotspot2 = hotspot == null ? NO_SHAPE : hotspot;
391             String tip = null;
392             XYToolTipGenerator generator = getToolTipGenerator(series, item);
393             if (generator != null)
394             {
395                 tip = generator.generateToolTip(dataset, series, item);
396             }
397             String url = null;
398             if (getURLGenerator() != null)
399             {
400                 url = getURLGenerator().generateURL(dataset, series, item);
401             }
402             XYItemEntity entity = new XYItemEntity(hotspot2, dataset, series, item, tip, url);
403             entities.add(entity);
404         }
405 
406         @Override
407         public String toString()
408         {
409             return "XYLineAndShapeRendererID []";
410         }
411 
412     }
413 
414     /**
415      * Class containing a trajectory with an offset. Takes care of bits that are before and beyond the lane without affecting
416      * the trajectory itself.
417      * <p>
418      * Copyright (c) 2013-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
419      * <br>
420      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
421      * </p>
422      * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
423      * @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
424      * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
425      */
426     private class OffsetTrajectory
427     {
428         /** The trajectory. */
429         private final Trajectory<?> trajectory;
430 
431         /** The offset. */
432         private final double offset;
433 
434         /** Scale factor for space dimension. */
435         private final double scaleFactor;
436 
437         /** First index of the trajectory to include, possibly cutting some measurements before the lane. */
438         private int first;
439 
440         /** Size of the trajectory to consider starting at first, possibly cutting some measurements beyond the lane. */
441         private int size;
442 
443         /** Length of the lane to determine {@code size}. */
444         private final Length laneLength;
445 
446         /**
447          * Construct a new TrajectoryAndLengthOffset object.
448          * @param trajectory the trajectory
449          * @param offset the length from the beginning of the sampled path to the start of the lane to which the trajectory
450          *            belongs
451          * @param scaleFactor scale factor for space dimension
452          * @param laneLength length of the lane
453          */
454         OffsetTrajectory(final Trajectory<?> trajectory, final Length offset, final double scaleFactor, final Length laneLength)
455         {
456             this.trajectory = trajectory;
457             this.offset = offset.si;
458             this.scaleFactor = scaleFactor;
459             this.laneLength = laneLength;
460         }
461 
462         /**
463          * Returns the number of measurements in the trajectory.
464          * @return number of measurements in the trajectory
465          */
466         public final int size()
467         {
468             // as trajectories grow, this calculation needs to be done on each request
469             /*
470              * Note on overlap:
471              * 
472              * Suppose a GTU crosses a lane boundary producing the following events, where distance e->| is the front, and
473              * |->l is the tail, relative to the reference point of the GTU.
474              * @formatter:off
475              * -------------------------------------------  o) regular move event
476              *  o     e   o         o |  l    o         o   e) lane enter event on next lane
477              * -------------------------------------------  l) lane leave event on previous lane
478              *  o         o         o   (l)                 measurements on previous lane
479              *       (e) (o)       (o)        o         o   measurements on next lane
480              * @formatter:on
481              * Trajectories of a particular GTU are not explicitly tied together. Not only would this involve quite some work,
482              * it is also impossible to distinguish a lane change near the start or end of a lane, from moving longitudinally on
483              * to the next lane. The basic idea to minimize overlap is to remove all positions on the previous lane beyond the
484              * lane length, and all negative positions on the next lane, i.e. all between ( ). This would however create a gap
485              * at the lane boundary '|'. Allowing one event beyond the lane length may still result in a gap, l->o in this case.
486              * Allowing one event before the lane would work in this case, but 'e' could also fall between an 'o' and '|'. At
487              * one trajectory it is thus not known whether the other trajectory continues from, or is continued from, the extra
488              * point. Hence we require an extra point before the lane and one beyond the lane to assure there is no gap. The
489              * resulting overlap can be as large as a move, but this is better than occasional gaps.
490              */
491             int f = 0;
492             while (f < this.trajectory.size() - 1 && this.trajectory.getX(f + 1) < 0.0)
493             {
494                 f++;
495             }
496             this.first = f;
497             int s = this.trajectory.size() - 1;
498             while (s > 1 && this.trajectory.getX(s - 1) > this.laneLength.si)
499             {
500                 s--;
501             }
502             this.size = s - f + 1;
503             return this.size;
504         }
505 
506         /**
507          * Returns the location, including offset, of an item.
508          * @param item item (sample) number
509          * @return location, including offset, of an item
510          */
511         public final double getX(final int item)
512         {
513             return Try.assign(() -> this.offset + this.trajectory.getX(this.first + item) * this.scaleFactor,
514                     "Unexpected exception while obtaining location value from trajectory for plotting.");
515         }
516 
517         /**
518          * Returns the time of an item.
519          * @param item item (sample) number
520          * @return time of an item
521          */
522         public final double getT(final int item)
523         {
524             return Try.assign(() -> (double) this.trajectory.getT(this.first + item),
525                     "Unexpected exception while obtaining time value from trajectory for plotting.");
526         }
527 
528         /**
529          * Returns the ID of the GTU of this trajectory.
530          * @return the ID of the GTU of this trajectory
531          */
532         public final String getGtuId()
533         {
534             return this.trajectory.getGtuId();
535         }
536 
537         @Override
538         public final String toString()
539         {
540             return "OffsetTrajectory [trajectory=" + this.trajectory + ", offset=" + this.offset + "]";
541         }
542 
543     }
544 
545     @Override
546     public String toString()
547     {
548         return "TrajectoryPlot [graphUpdater=" + this.graphUpdater + ", knownTrajectories=" + this.knownTrajectories
549                 + ", curves=" + this.curves + ", strokes=" + this.strokes + ", curvesPerLane=" + this.curvesPerLane
550                 + ", legend=" + this.legend + ", laneVisible=" + this.laneVisible + "]";
551     }
552 
553     /**
554      * Retrieve the legend.
555      * @return the legend
556      */
557     public LegendItemCollection getLegend()
558     {
559         return this.legend;
560     }
561 
562     /**
563      * Retrieve the lane visibility flags.
564      * @return the lane visibility flags
565      */
566     public List<Boolean> getLaneVisible()
567     {
568         return this.laneVisible;
569     }
570 
571 }