FundamentalDiagram.java
package org.opentrafficsim.animation.graphs;
import java.awt.Color;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import org.djunits.value.vdouble.scalar.Duration;
import org.djutils.event.EventListener;
import org.djutils.event.EventType;
import org.djutils.exceptions.Throw;
import org.djutils.immutablecollections.ImmutableList;
import org.jfree.chart.JFreeChart;
import org.jfree.chart.LegendItem;
import org.jfree.chart.LegendItemCollection;
import org.jfree.chart.axis.NumberAxis;
import org.jfree.chart.plot.XYPlot;
import org.jfree.chart.renderer.xy.XYLineAndShapeRenderer;
import org.jfree.data.DomainOrder;
import org.jfree.data.xy.XYDataset;
import org.opentrafficsim.animation.graphs.AbstractPlot.PaintState;
import org.opentrafficsim.animation.graphs.FundamentalDiagram.FdPaintState;
/**
* Fundamental diagram from various sources.
* <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
*/
public class FundamentalDiagram extends AbstractBoundedPlot<FdPaintState> implements XYDataset
{
/** Source providing the data. */
private final FdDataSource source;
/** Fundamental diagram line. */
private final FdLine fdLine;
/** Quantity on domain axis. */
private Quantity domainQuantity;
/** Quantity on range axis. */
private Quantity rangeQuantity;
/** The other, 3rd quantity. */
private Quantity otherQuantity;
/** Labels of series. */
private final List<String> seriesLabels = new ArrayList<>();
/** Property for chart listener to provide time info for status label. */
private String timeInfo = "";
/** Legend to change text color to indicate visibility. */
private LegendItemCollection legend;
/** Whether each lane is visible or not. */
private final List<Boolean> laneVisible = new ArrayList<>();
/**
* Constructor.
* @param caption caption
* @param domainQuantity initial quantity on the domain axis
* @param rangeQuantity initial quantity on the range axis
* @param source source providing the data
* @param fdLine fundamental diagram line, may be {@code null}
*/
public FundamentalDiagram(final String caption, final Quantity domainQuantity, final Quantity rangeQuantity,
final FdDataSource source, final FdLine fdLine)
{
super(source.getPlotScheduler(), caption, source.getAggregationPeriod(), source.getDelay());
Throw.when(domainQuantity.equals(rangeQuantity), IllegalArgumentException.class,
"Domain and range quantity should not be equal.");
this.fdLine = fdLine;
this.setDomainQuantity(domainQuantity);
this.setRangeQuantity(rangeQuantity);
Set<Quantity> quantities = EnumSet.allOf(Quantity.class);
quantities.remove(domainQuantity);
quantities.remove(rangeQuantity);
this.setOtherQuantity(quantities.iterator().next());
this.source = source;
int d = 0;
if (fdLine != null)
{
d = 1;
this.seriesLabels.add(fdLine.getName());
this.laneVisible.add(true);
}
for (int series = 0; series < source.getNumberOfSeries(); series++)
{
this.seriesLabels.add(series + d, source.getName(series));
this.laneVisible.add(true);
}
setChart(createChart());
setLowerDomainBound(0.0);
setLowerRangeBound(0.0);
// let this diagram be notified by the source
source.addPlot(this);
}
/**
* Create a chart.
* @return chart
*/
private JFreeChart createChart()
{
NumberAxis xAxis = new NumberAxis(this.getDomainQuantity().label());
NumberAxis yAxis = new NumberAxis(this.getRangeQuantity().label());
XYLineAndShapeRenderer renderer = new XYLineAndShapeRenderer()
{
/** Serialization version UID. */
private static final long serialVersionUID = 20181022L;
@Override
public boolean isSeriesVisible(final int series)
{
return FundamentalDiagram.this.laneVisible.get(series);
}
}; // XYDotRenderer doesn't support different markers
renderer.setDefaultLinesVisible(false);
if (hasLineFD())
{
int series = getNumberOfSeries();
renderer.setSeriesLinesVisible(series, true);
renderer.setSeriesPaint(series, Color.BLACK);
renderer.setSeriesShapesVisible(series, false);
}
XYPlot plot = new XYPlot(this, xAxis, yAxis, renderer);
boolean showLegend = true;
if (!hasLineFD() && getNumberOfSeries() < 2)
{
plot.setFixedLegendItems(null);
showLegend = false;
}
else
{
this.legend = new LegendItemCollection();
for (int i = 0; i < getNumberOfSeries(); i++)
{
LegendItem li = new LegendItem(this.source.getName(i));
li.setSeriesKey(i); // lane series, not curve series
li.setShape(renderer.lookupLegendShape(i));
li.setFillPaint(renderer.lookupSeriesPaint(i));
this.legend.add(li);
}
if (hasLineFD())
{
LegendItem li = new LegendItem(this.fdLine.getName());
li.setSeriesKey(-1);
this.legend.add(li);
}
plot.setFixedLegendItems(this.legend);
showLegend = true;
}
return new JFreeChart(getCaption(), JFreeChart.DEFAULT_TITLE_FONT, plot, showLegend);
}
/**
* Returns the possible updates per period values.
* @return possible update per period values
*/
public ImmutableList<Integer> getPossibleUpdatesPerPeriod()
{
return this.source.getUpdatesPerPeriodSetting().values();
}
/**
* Returns the default updates per period.
* @return default updates per period
*/
public int getDefaultUpdatesPerPeriod()
{
return this.source.getUpdatesPerPeriodSetting().getDefaultValue();
}
/**
* Returns the possible aggregation period values.
* @return possible aggregation period values
*/
public ImmutableList<Duration> getPossibleAggregationPeriods()
{
return this.source.getAggregationPeriodSetting().values();
}
/**
* Returns the default aggregation period.
* @return default aggregation period
*/
public Duration getDefaultAggregationPeriod()
{
return this.source.getAggregationPeriodSetting().getDefaultValue();
}
/**
* Add listener to synchronize UI element when another UI element changes a setting, or the setting is changed
* programmatically.
* @param listener listener
* @param eventType event type (i.e. {@code UPDATES_PER_PERIOD} or {@code AGGREGATION_PERIOD})
*/
public void addListener(final EventListener listener, final EventType eventType)
{
this.source.addListener(listener, eventType);
}
/**
* Returns the number of series.
* @return number of series
*/
public int getNumberOfSeries()
{
return this.source.getNumberOfSeries();
}
/**
* Returns the update interval.
* @return update interval
*/
public Duration getUpdateInterval()
{
return this.source.getUpdateInterval();
}
/**
* Sets the number of updates per period.
* @param n number of updates per period
*/
public void setUpdatesPerPeriod(final int n)
{
this.source.setUpdatesPerPeriod(n);
invalidate();
}
/**
* Sets the aggregation period.
* @param period aggregation period
*/
public void setAggregationPeriod(final Duration period)
{
this.source.setAggregationPeriod(period);
invalidate();
}
@Override
protected FdPaintState emptyPaintState()
{
return new FdPaintState(new FdSeries[0], Duration.ZERO);
}
@Override
protected void calculatePaintState(final Duration time)
{
this.source.calculatePaintStateSafe(time);
}
@Override
protected void setPaintState()
{
super.setPaintState();
getChart().getXYPlot().zoomDomainAxes(0, null, null);
getChart().getXYPlot().zoomRangeAxes(0, null, null);
}
@Override
public int getSeriesCount()
{
return getPaintState().getSeriesCount() + (hasLineFD() ? 1 : 0);
}
@Override
public Comparable<String> getSeriesKey(final int series)
{
return this.seriesLabels.get(series);
}
@SuppressWarnings("rawtypes")
@Override
public int indexOf(final Comparable seriesKey)
{
int index = this.seriesLabels.indexOf(seriesKey);
return index < 0 ? 0 : index;
}
@Override
public DomainOrder getDomainOrder()
{
return DomainOrder.NONE;
}
@Override
public int getItemCount(final int series)
{
if (hasLineFD() && series == getSeriesCount() - 1)
{
return this.fdLine.getValues(this.domainQuantity).length;
}
return getPaintState().getItemCount(series);
}
@Override
public Number getX(final int series, final int item)
{
return getXValue(series, item);
}
@Override
public double getXValue(final int series, final int item)
{
if (hasLineFD() && series == getSeriesCount() - 1)
{
return this.fdLine.getValues(this.domainQuantity)[item];
}
return getPaintState().getValue(getDomainQuantity(), series, item);
}
@Override
public Number getY(final int series, final int item)
{
return getYValue(series, item);
}
@Override
public double getYValue(final int series, final int item)
{
if (hasLineFD() && series == getSeriesCount() - 1)
{
return this.fdLine.getValues(this.rangeQuantity)[item];
}
return getPaintState().getValue(getRangeQuantity(), series, item);
}
@Override
public GraphType getGraphType()
{
return GraphType.FUNDAMENTAL_DIAGRAM;
}
@Override
public String getStatusLabel(final double domainValue, final double rangeValue)
{
return getDomainQuantity().format(domainValue) + ", " + getRangeQuantity().format(rangeValue) + ", "
+ getOtherQuantity().format(getDomainQuantity().computeOther(getRangeQuantity(), domainValue, rangeValue))
+ getTimeInfo();
}
/**
* Retrieve the legend of this FundamentalDiagram.
* @return the legend
*/
public LegendItemCollection getLegend()
{
return this.legend;
}
/**
* Return the list of lane visibility flags.
* @return the list of lane visibility flags
*/
public List<Boolean> getLaneVisible()
{
return this.laneVisible;
}
/**
* Return the domain quantity.
* @return the domain quantity
*/
public Quantity getDomainQuantity()
{
return this.domainQuantity;
}
/**
* Set the domain quantity.
* @param domainQuantity the new domain quantity
*/
public void setDomainQuantity(final Quantity domainQuantity)
{
this.domainQuantity = domainQuantity;
}
/**
* Get the other (non domain; vertical axis) quantity.
* @return the quantity for the vertical axis
*/
public Quantity getOtherQuantity()
{
return this.otherQuantity;
}
/**
* Set the other (non domain; vertical axis) quantity.
* @param otherQuantity the quantity for the vertical axis
*/
public void setOtherQuantity(final Quantity otherQuantity)
{
this.otherQuantity = otherQuantity;
}
/**
* Get the range quantity.
* @return the range quantity
*/
public Quantity getRangeQuantity()
{
return this.rangeQuantity;
}
/**
* Set the range quantity.
* @param rangeQuantity the new range quantity
*/
public void setRangeQuantity(final Quantity rangeQuantity)
{
this.rangeQuantity = rangeQuantity;
}
/**
* Retrieve the time info.
* @return the time info
*/
public String getTimeInfo()
{
return this.timeInfo;
}
/**
* Set the time info.
* @param timeInfo the new time info
*/
public void setTimeInfo(final String timeInfo)
{
this.timeInfo = timeInfo;
}
/**
* Return whether the plot has a fundamental diagram line.
* @return whether the plot has a fundamental diagram line
*/
public boolean hasLineFD()
{
return this.fdLine != null;
}
@Override
public String toString()
{
return "FundamentalDiagram [domainQuantity=" + this.getDomainQuantity() + ", rangeQuantity=" + this.getRangeQuantity()
+ "]";
}
// ===== Helper classes =====
/**
* Quantity enum defining density, flow and speed.
*/
public enum Quantity
{
/** Density. */
DENSITY
{
@Override
public String label()
{
return "Density [veh/km] \u2192";
}
@Override
public String format(final double value)
{
return String.format("%.0f veh/km", value);
}
@Override
public double computeOther(final Quantity pairing, final double thisValue, final double pairedValue)
{
// .......................... speed = flow / density .. flow = density * speed
return pairing.equals(FLOW) ? pairedValue / thisValue : thisValue * pairedValue;
}
},
/** Flow. */
FLOW
{
@Override
public String label()
{
return "Flow [veh/h] \u2192";
}
@Override
public String format(final double value)
{
return String.format("%.0f veh/h", value);
}
@Override
public double computeOther(final Quantity pairing, final double thisValue, final double pairedValue)
{
// speed = flow * density ... density = flow / speed
return pairing.equals(DENSITY) ? thisValue * pairedValue : thisValue / pairedValue;
}
},
/** Speed. */
SPEED
{
@Override
public String label()
{
return "Speed [km/h] \u2192";
}
@Override
public String format(final double value)
{
return String.format("%.1f km/h", value);
}
@Override
public double computeOther(final Quantity pairing, final double thisValue, final double pairedValue)
{
// ............................. flow = speed * density .. density = flow / speed
return pairing.equals(DENSITY) ? thisValue * pairedValue : pairedValue / thisValue;
}
};
/**
* Returns an axis label of the quantity.
* @return axis label of the quantity
*/
public abstract String label();
/**
* Formats a value for status display.
* @param value value
* @return formatted string including quantity
*/
public abstract String format(double value);
/**
* Compute the value of the 3rd quantity.
* @param pairing quantity on other axis
* @param thisValue value of this quantity
* @param pairedValue value of the paired quantity on the other axis
* @return value of the 3rd quantity
*/
public abstract double computeOther(Quantity pairing, double thisValue, double pairedValue);
}
/**
* Defines a line plot for a fundamental diagram.
*/
public interface FdLine
{
/**
* Return the values for the given quantity. For two quantities, this should result in a 2D fundamental diagram line.
* @param quantity quantity to return value for.
* @return values for quantity
*/
double[] getValues(Quantity quantity);
/**
* Returns the name of the line, as shown in the legend.
* @return name of the line, as shown in the legend
*/
String getName();
}
/**
* One data series within a fundamental diagram paint state.
* @param q flow
* @param v speed
* @param k density
*/
public record FdSeries(float[] q, float[] v, float[] k)
{
/**
* Returns one value for a quantity.
* @param quantity quantity
* @param item item in series
* @return one value for a quantity
*/
public double getValue(final Quantity quantity, final int item)
{
switch (quantity)
{
case DENSITY:
return k()[item];
case FLOW:
return q()[item];
case SPEED:
return v()[item];
default:
throw new IllegalArgumentException("Unknown quantity " + quantity);
}
}
}
/**
* Paint state for fundamental diagram.
* @param fdSeries data series
* @param getAvailableTime available time
*/
public record FdPaintState(FdSeries[] fdSeries, Duration getAvailableTime) implements PaintState
{
/**
* Returns the number of series.
* @return number of series
*/
public int getSeriesCount()
{
return this.fdSeries().length;
}
/**
* Get number of items for series.
* @param series series
* @return number of items for series
*/
private int getItemCount(final int series)
{
return fdSeries()[series].k().length;
}
/**
* Get value for quantity.
* @param quantity quantity
* @param series series
* @param item item in series
* @return value for quantity
*/
private double getValue(final Quantity quantity, final int series, final int item)
{
return fdSeries()[series].getValue(quantity, item);
}
}
}