View Javadoc
1   package org.opentrafficsim.editor.extensions.map;
2   
3   import java.awt.Color;
4   import java.util.ArrayList;
5   import java.util.Collection;
6   import java.util.Collections;
7   import java.util.Comparator;
8   import java.util.Iterator;
9   import java.util.LinkedHashMap;
10  import java.util.LinkedHashSet;
11  import java.util.List;
12  import java.util.Map;
13  import java.util.Map.Entry;
14  import java.util.NavigableMap;
15  import java.util.Set;
16  import java.util.TreeMap;
17  
18  import javax.swing.SwingUtilities;
19  
20  import org.djunits.value.vdouble.scalar.Angle;
21  import org.djunits.value.vdouble.scalar.Direction;
22  import org.djunits.value.vdouble.scalar.Length;
23  import org.djunits.value.vdouble.scalar.LinearDensity;
24  import org.djunits.value.vdouble.vector.LengthVector;
25  import org.djutils.draw.curve.Arc2d;
26  import org.djutils.draw.curve.BezierCubic2d;
27  import org.djutils.draw.curve.Clothoid2d;
28  import org.djutils.draw.curve.OffsetCurve2d;
29  import org.djutils.draw.curve.Straight2d;
30  import org.djutils.draw.function.ContinuousPiecewiseLinearFunction;
31  import org.djutils.draw.line.PolyLine2d;
32  import org.djutils.draw.line.Polygon2d;
33  import org.djutils.draw.line.Ray2d;
34  import org.djutils.draw.point.DirectedPoint2d;
35  import org.djutils.draw.point.Point2d;
36  import org.djutils.event.Event;
37  import org.djutils.event.EventListener;
38  import org.djutils.event.EventListenerMap;
39  import org.djutils.event.EventProducer;
40  import org.djutils.event.EventType;
41  import org.djutils.event.reference.ReferenceType;
42  import org.djutils.math.AngleUtil;
43  import org.djutils.metadata.MetaData;
44  import org.djutils.metadata.ObjectDescriptor;
45  import org.opentrafficsim.animation.network.LinkAnimation.LinkData;
46  import org.opentrafficsim.animation.road.CrossSectionElementAnimation;
47  import org.opentrafficsim.animation.road.LaneAnimation;
48  import org.opentrafficsim.animation.road.PriorityAnimation;
49  import org.opentrafficsim.animation.road.StripeAnimation;
50  import org.opentrafficsim.base.OtsRuntimeException;
51  import org.opentrafficsim.base.StripeElement;
52  import org.opentrafficsim.base.StripeElement.StripeLateralSync;
53  import org.opentrafficsim.base.geometry.OtsGeometryUtil;
54  import org.opentrafficsim.base.geometry.OtsShape;
55  import org.opentrafficsim.base.logger.Logger;
56  import org.opentrafficsim.core.geometry.CurveFlattener;
57  import org.opentrafficsim.core.geometry.PolyLineCurve2d;
58  import org.opentrafficsim.editor.ChildNodeFinder;
59  import org.opentrafficsim.editor.OtsEditor;
60  import org.opentrafficsim.editor.XsdPaths;
61  import org.opentrafficsim.editor.XsdTreeNode;
62  import org.opentrafficsim.editor.extensions.Adapters;
63  import org.opentrafficsim.road.network.CrossSectionGeometry;
64  import org.opentrafficsim.road.network.StripeData.StripePhaseSync;
65  import org.opentrafficsim.road.network.factory.xml.utils.RoadLayoutOffsets.CseData;
66  import org.opentrafficsim.road.network.factory.xml.utils.StripeSynchronization;
67  import org.opentrafficsim.xml.bindings.ExpressionAdapter;
68  import org.opentrafficsim.xml.bindings.types.ArcDirectionType.ArcDirection;
69  import org.opentrafficsim.xml.generated.Link.Arc;
70  import org.opentrafficsim.xml.generated.Link.Bezier;
71  import org.opentrafficsim.xml.generated.Link.Clothoid;
72  import org.opentrafficsim.xml.generated.Link.Polyline;
73  
74  import nl.tudelft.simulation.dsol.animation.d2.Renderable2d;
75  
76  /**
77   * LinkData for the editor Map. This class will also listen to any changes that may affect the link shape, maintain the drawn
78   * layout, and maintain the priority animation.
79   * <p>
80   * Copyright (c) 2023-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
81   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
82   * </p>
83   * @author Wouter Schakel
84   */
85  public class MapLinkData extends MapData implements LinkData, EventListener, EventProducer
86  {
87  
88      /** Event when layout is rebuilt. */
89      public static final EventType LAYOUT_REBUILT = new EventType("LAYOUTREBUILT", new MetaData("LAYOUT", "Layout is rebuilt.",
90              new ObjectDescriptor("LinkData", "Map link data object.", MapLinkData.class)));
91  
92      /** Event listeners. */
93      private final EventListenerMap eventListenerMap = new EventListenerMap();
94  
95      /** Listener to changes in shape. */
96      private final ShapeListener shapeListener = new ShapeListener();
97  
98      /** String attribute. */
99      private String id = "";
100 
101     /** Tree node of start. */
102     private XsdTreeNode nodeStart;
103 
104     /** Tree node of end. */
105     private XsdTreeNode nodeEnd;
106 
107     /** Start direction. */
108     private Direction directionStart = Direction.ZERO;
109 
110     /** End direction. */
111     private Direction directionEnd = Direction.ZERO;
112 
113     /** Start offset. */
114     private Length offsetStart;
115 
116     /** End offset. */
117     private Length offsetEnd;
118 
119     /** From point. */
120     private Point2d from;
121 
122     /** To point. */
123     private Point2d to;
124 
125     /** Continuous design line. */
126     private OffsetCurve2d designLine = null;
127 
128     /** Flattened design line. */
129     private PolyLine2d flattenedDesignLine = null;
130 
131     /** Location. */
132     private DirectedPoint2d location;
133 
134     /** Absolute contour. */
135     private Polygon2d absoluteContour;
136 
137     /** Relative contour. */
138     private Polygon2d relativeContour;
139 
140     /** Node describing the road layout. */
141     private XsdTreeNode roadLayoutNode;
142 
143     /** Node linking defined road layout id. */
144     private XsdTreeNode definedRoadLayoutNode;
145 
146     /** Listener to road layout, if locally defined. */
147     private RoadLayoutListener roadLayoutListener;
148 
149     /** Listener to flattener, if locally defined. */
150     private FlattenerListener flattenerListener;
151 
152     /** Set of drawable cross-section elements. */
153     private Set<Renderable2d<?>> crossSectionElements = new LinkedHashSet<>();
154 
155     /** Lane data. */
156     private java.util.Map<String, MapLaneData> laneData = new LinkedHashMap<>();
157 
158     /** Stripe data. */
159     private java.util.Map<String, MapStripeData> stripeData = new LinkedHashMap<>();
160 
161     /** Priority animation. */
162     private PriorityAnimation priorityAnimation;
163 
164     /** Whether this is a connector. */
165     private final boolean isConnector;
166 
167     /** Centroid node of this connector, if it is a connector. */
168     private XsdTreeNode centroid;
169 
170     /** Regular node of this connector, if it is a connector. */
171     private XsdTreeNode connectorNode;
172 
173     /** Whether link geometry is dirty. */
174     private boolean dirtyGeometry = true;
175 
176     /** Whether layout is dirty. */
177     private boolean dirtyLayout = true;
178 
179     /**
180      * Constructor.
181      * @param map map.
182      * @param linkNode node Ots.Network.Link.
183      * @param editor editor.
184      */
185     public MapLinkData(final EditorMap map, final XsdTreeNode linkNode, final OtsEditor editor)
186     {
187         super(map, linkNode, editor);
188         // linkNode.getRoot().addListener(this, XsdTreeNodeRoot.NODE_CREATED, ReferenceType.WEAK);
189         linkNode.addListener(this, XsdTreeNode.ATTRIBUTE_CHANGED, ReferenceType.WEAK);
190         this.isConnector = "Connector".equals(linkNode.getNodeName());
191         if (this.isConnector)
192         {
193             SwingUtilities.invokeLater(() ->
194             {
195                 // this is for 1) when delete is undone, as some children are recovered later, and 2) when the centroid or node
196                 // is later in a loaded xml
197                 notify(new Event(XsdTreeNode.ATTRIBUTE_CHANGED, new Object[] {getNode(), "Id", null}));
198                 this.centroid = replaceNode(this.centroid, linkNode.getCoupledNodeAttribute("Centroid").orElse(null));
199                 this.connectorNode = replaceNode(this.connectorNode, linkNode.getCoupledNodeAttribute("Node").orElse(null));
200                 setDirtyEndCheckValidity();
201             });
202             return;
203         }
204         if (Set.of("Straight", "PolyLine", "Bezier", "Clothoid", "Arc").contains(linkNode.getChild(0).getNodeName()))
205         {
206             linkNode.getChild(0).addListener(this.shapeListener, XsdTreeNode.OPTION_CHANGED, ReferenceType.WEAK);
207             this.shapeListener.shapeNode = linkNode.getChild(0);
208         }
209 
210         // as RoadLayout is the default, a setOption() never triggers this (for DefinedRoadLayout this is not required)
211         SwingUtilities.invokeLater(() ->
212         {
213             XsdTreeNode layout = linkNode.getChild(1);
214             layout.addListener(this, XsdTreeNode.OPTION_CHANGED, ReferenceType.WEAK);
215             if (layout.getOption().equals(layout))
216             {
217                 notify(new Event(XsdTreeNode.OPTION_CHANGED, new Object[] {layout, layout, layout}));
218             }
219         });
220 
221         // for when node is duplicated, set data immediately if (getNode().isActive())
222         if (getNode().isActive())
223         {
224             SwingUtilities.invokeLater(() ->
225             {
226                 // this is for 1) when delete is undone, as some children are recovered later, including the shape node, and 2)
227                 // when the start or end node are later in a loaded xml
228                 this.shapeListener.shapeNode = linkNode.getChild(0);
229                 linkNode.getChild(0).addListener(this.shapeListener, XsdTreeNode.OPTION_CHANGED, ReferenceType.WEAK);
230 
231                 notify(new Event(XsdTreeNode.ATTRIBUTE_CHANGED, new Object[] {getNode(), "Id", null}));
232                 this.nodeStart = replaceNode(this.nodeStart, linkNode.getCoupledNodeAttribute("NodeStart").orElse(null));
233                 this.nodeEnd = replaceNode(this.nodeEnd, linkNode.getCoupledNodeAttribute("NodeEnd").orElse(null));
234                 notify(new Event(XsdTreeNode.ATTRIBUTE_CHANGED, new Object[] {getNode(), "OffsetStart", null}));
235                 notify(new Event(XsdTreeNode.ATTRIBUTE_CHANGED, new Object[] {getNode(), "OffsetEnd", null}));
236                 XsdTreeNode shape = linkNode.getChild(0);
237                 this.shapeListener.notify(new Event(XsdTreeNode.OPTION_CHANGED, new Object[] {shape, shape, shape}));
238 
239                 setDirtyEndCheckValidity();
240             });
241         }
242     }
243 
244     @Override
245     public void destroy()
246     {
247         super.destroy();
248         for (Renderable2d<?> renderable : this.crossSectionElements)
249         {
250             getMap().removeAnimation(renderable);
251         }
252         this.crossSectionElements.clear();
253         if (this.roadLayoutListener != null)
254         {
255             this.roadLayoutListener.destroy();
256             this.roadLayoutListener = null;
257         }
258         if (this.definedRoadLayoutNode != null)
259         {
260             this.definedRoadLayoutNode.removeListener(this, XsdTreeNode.VALUE_CHANGED);
261             this.definedRoadLayoutNode = null;
262         }
263         if (this.flattenerListener != null)
264         {
265             this.flattenerListener.destroy();
266             this.flattenerListener = null;
267         }
268         if (this.priorityAnimation != null)
269         {
270             getMap().removeAnimation(this.priorityAnimation);
271         }
272         if (this.nodeStart != null)
273         {
274             this.nodeStart.removeListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
275         }
276         if (this.nodeEnd != null)
277         {
278             this.nodeEnd.removeListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
279         }
280         this.roadLayoutNode = null;
281     }
282 
283     /**
284      * Check for dirty geometry, and restore it if its dirty.
285      */
286     private void checkDirtyGeometry()
287     {
288         if (this.dirtyGeometry)
289         {
290             buildDesignLine();
291         }
292         else if (this.dirtyLayout)
293         {
294             buildLayout();
295         }
296     }
297 
298     @Override
299     public DirectedPoint2d getLocation()
300     {
301         checkDirtyGeometry();
302         return this.location;
303     }
304 
305     @Override
306     public Polygon2d getAbsoluteContour()
307     {
308         checkDirtyGeometry();
309         return this.absoluteContour;
310     }
311 
312     @Override
313     public Polygon2d getRelativeContour()
314     {
315         checkDirtyGeometry();
316         return this.relativeContour;
317     }
318 
319     @Override
320     public String getId()
321     {
322         return this.id;
323     }
324 
325     @Override
326     public boolean isConnector()
327     {
328         return this.isConnector;
329     }
330 
331     @Override
332     public PolyLine2d getCenterLine()
333     {
334         checkDirtyGeometry();
335         return this.flattenedDesignLine;
336     }
337 
338     @Override
339     public PolyLine2d getLine()
340     {
341         return OtsShape.transformLine(getCenterLine(), getLocation());
342     }
343 
344     @Override
345     public void notify(final Event event)
346     {
347         if (event.getType().equals(XsdTreeNode.OPTION_CHANGED))
348         {
349             Object[] content = (Object[]) event.getContent();
350             XsdTreeNode selected = (XsdTreeNode) content[1];
351             if (selected.getNodeName().equals("RoadLayout") || (selected.getNodeName().equals("xsd:sequence")
352                     && selected.getChildCount() > 0 && selected.getChild(0).getNodeName().equals("DefinedLayout")))
353             {
354                 // road layout
355                 if (this.roadLayoutListener != null)
356                 {
357                     this.roadLayoutListener.destroy();
358                 }
359                 else if (this.roadLayoutNode != null)
360                 {
361                     getMap().getRoadLayoutListener(this.roadLayoutNode).removeListener(this, ChangeListener.CHANGE_EVENT);
362                 }
363                 if (this.definedRoadLayoutNode != null)
364                 {
365                     this.definedRoadLayoutNode.removeListener(this, XsdTreeNode.VALUE_CHANGED);
366                 }
367                 if (selected.getNodeName().equals("RoadLayout"))
368                 {
369                     this.roadLayoutNode = selected;
370                     this.definedRoadLayoutNode = null;
371                     this.roadLayoutListener = new RoadLayoutListener(selected, this::getEval);
372                     this.roadLayoutListener.addListener(this, ChangeListener.CHANGE_EVENT, ReferenceType.WEAK);
373                 }
374                 else
375                 {
376                     this.definedRoadLayoutNode = selected.getChild(0);
377                     this.definedRoadLayoutNode.addListener(this, XsdTreeNode.VALUE_CHANGED, ReferenceType.WEAK);
378                     this.roadLayoutNode = this.definedRoadLayoutNode.getCoupledNodeValue().orElse(null);
379                     if (this.roadLayoutNode != null)
380                     {
381                         getMap().getRoadLayoutListener(this.roadLayoutNode).addListener(this, ChangeListener.CHANGE_EVENT,
382                                 ReferenceType.WEAK);
383                     }
384                     this.roadLayoutListener = null;
385                 }
386             }
387             else
388             {
389                 // flattener
390                 if (this.flattenerListener != null)
391                 {
392                     this.flattenerListener.destroy();
393                 }
394                 this.flattenerListener = new FlattenerListener(selected, this::getEval);
395             }
396             this.dirtyLayout = true;
397             return;
398         }
399         else if (event.getType().equals(XsdTreeNode.VALUE_CHANGED))
400         {
401             // defined road layout value changed
402             if (this.roadLayoutNode != null)
403             {
404                 getMap().getRoadLayoutListener(this.roadLayoutNode).removeListener(this, ChangeListener.CHANGE_EVENT);
405             }
406             this.roadLayoutNode = this.definedRoadLayoutNode.getCoupledNodeValue().orElse(null);
407             if (this.roadLayoutNode != null)
408             {
409                 getMap().getRoadLayoutListener(this.roadLayoutNode).addListener(this, ChangeListener.CHANGE_EVENT,
410                         ReferenceType.WEAK);
411             }
412             this.dirtyLayout = true;
413             return;
414         }
415         else if (event.getType().equals(ChangeListener.CHANGE_EVENT))
416         {
417             XsdTreeNode node = (XsdTreeNode) event.getContent();
418             if (node.getNodeName().equals("RoadLayout") || node.getNodeName().equals("DefinedLayout"))
419             {
420                 if (node.isIdentifiable() && this.definedRoadLayoutNode != null)
421                 {
422                     // for if the id in the road layout has changed
423                     this.roadLayoutNode = this.definedRoadLayoutNode.getCoupledNodeValue().orElse(null);
424                 }
425                 if (node.equals(this.roadLayoutNode) && node.reportInvalidId().isEmpty())
426                 {
427                     this.dirtyLayout = true;
428                 }
429             }
430             else
431             {
432                 // change in flattener
433                 setDirtyEndCheckValidity();
434             }
435             return;
436         }
437 
438         // any attribute of the link node, or of either of the connected nodes
439         Object[] content = (Object[]) event.getContent();
440         XsdTreeNode node = (XsdTreeNode) content[0];
441         String attribute = (String) content[1];
442         String value = node.getAttributeValueOrDefault(attribute);
443 
444         if ("Id".equals(attribute))
445         {
446             this.id = value == null ? "" : value;
447             return;
448         }
449         else if ("NodeStart".equals(attribute))
450         {
451             this.nodeStart = replaceNode(this.nodeStart, getNode().getCoupledNodeAttribute("NodeStart").orElse(null));
452         }
453         else if ("NodeEnd".equals(attribute))
454         {
455             this.nodeEnd = replaceNode(this.nodeEnd, getNode().getCoupledNodeAttribute("NodeEnd").orElse(null));
456         }
457         else if ("Centroid".equals(attribute))
458         {
459             this.centroid = replaceNode(this.centroid, getNode().getCoupledNodeAttribute("Centroid").orElse(null));
460         }
461         else if ("Node".equals(attribute))
462         {
463             this.connectorNode = replaceNode(this.connectorNode, getNode().getCoupledNodeAttribute("Node").orElse(null));
464         }
465         else if ("OffsetStart".equals(attribute))
466         {
467             setValue((v) -> this.offsetStart = v, Adapters.get(Length.class), getNode(), attribute);
468         }
469         else if ("OffsetEnd".equals(attribute))
470         {
471             setValue((v) -> this.offsetEnd = v, Adapters.get(Length.class), getNode(), attribute);
472         }
473         else if (!"Coordinate".equals(attribute) && !"Direction".equals(attribute))
474         {
475             // Coordinate and Direction pertain to either of the nodes, to which this class also listens
476             // Otherwise: other attribute, not important, do not build design line
477             return;
478         }
479         setDirtyEndCheckValidity();
480     }
481 
482     /**
483      * Replaces the old node with the new node, adding and removing this as a listener as required. If a node refers to a
484      * default input parameter node, the node that the input parameter id refers to is found instead.
485      * @param oldNode former node.
486      * @param newNode new node.
487      * @return the actual new node (Ots.Network.Node).
488      */
489     private XsdTreeNode replaceNode(final XsdTreeNode oldNode, final XsdTreeNode newNode)
490     {
491         if (oldNode != null)
492         {
493             oldNode.removeListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
494             if (oldNode.getPathString().equals(XsdPaths.DEFAULT_INPUT_PARAMETER_STRING))
495             {
496                 XsdTreeNode node = getInputNode(newNode);
497                 if (node != null)
498                 {
499                     node.removeListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
500                 }
501             }
502         }
503         if (newNode != null)
504         {
505             newNode.addListener(this, XsdTreeNode.ATTRIBUTE_CHANGED, ReferenceType.WEAK);
506             if (newNode.getPathString().equals(XsdPaths.DEFAULT_INPUT_PARAMETER_STRING))
507             {
508                 XsdTreeNode node = getInputNode(newNode);
509                 if (node != null)
510                 {
511                     node.addListener(this, XsdTreeNode.ATTRIBUTE_CHANGED, ReferenceType.WEAK);
512                 }
513                 return node;
514             }
515         }
516         return newNode;
517     }
518 
519     /**
520      * Finds the network node that the value of an input parameter node refers to.
521      * @param inputParameter input parameter node (default).
522      * @return the actual node (Ots.Network.Node).
523      */
524     private XsdTreeNode getInputNode(final XsdTreeNode inputParameter)
525     {
526         String inputId = inputParameter.getId();
527         String nodeId;
528         try
529         {
530             nodeId = (String) getEval().evaluate(inputId.substring(1, inputId.length() - 1));
531         }
532         catch (IllegalArgumentException ex)
533         {
534             // expression could not be evaluated, possibly a missing parameter
535             return null;
536         }
537         XsdTreeNode ots = inputParameter.getRoot();
538         for (XsdTreeNode child : ots.getChildren())
539         {
540             if (child.getPathString().equals(XsdPaths.NETWORK))
541             {
542                 for (XsdTreeNode networkElement : child.getChildren())
543                 {
544                     if (networkElement.isType("Node") && nodeId != null && nodeId.equals(networkElement.getId()))
545                     {
546                         return networkElement;
547                     }
548                 }
549             }
550         }
551         return null;
552     }
553 
554     /**
555      * The map was notified a new coordinate node was added. The node may or may not be part of this link.
556      * @param node added coordinate node.
557      */
558     public void addCoordinate(final XsdTreeNode node)
559     {
560         if (this.shapeListener.shapeNode != null && this.shapeListener.shapeNode.equals(node.getParent()))
561         {
562             this.shapeListener.coordinates.put(node, orNull(node.getValueOrDefault(), Adapters.get(Point2d.class)));
563             setDirtyEndCheckValidity();
564             node.addListener(this.shapeListener, XsdTreeNode.VALUE_CHANGED, ReferenceType.WEAK);
565             node.addListener(this.shapeListener, XsdTreeNode.MOVED, ReferenceType.WEAK);
566         }
567     }
568 
569     /**
570      * The map was notified a coordinate node was removed. The node may or may not be part of this link.
571      * @param node removed coordinate node.
572      */
573     public void removeCoordinate(final XsdTreeNode node)
574     {
575         // this.shapeListener.shapeNode.equals(node.getParent()) does not work as the parent is null after being removed
576         // this.shapeListener.coordinates.containsKey() id. as the ordering is incomplete as the node is removed from the parent
577         Iterator<XsdTreeNode> it = this.shapeListener.coordinates.keySet().iterator();
578         while (it.hasNext())
579         {
580             XsdTreeNode key = it.next();
581             if (node.equals(key))
582             {
583                 it.remove();
584                 setDirtyEndCheckValidity();
585                 node.removeListener(this.shapeListener, XsdTreeNode.VALUE_CHANGED);
586                 node.removeListener(this.shapeListener, XsdTreeNode.MOVED);
587                 return;
588             }
589         }
590     }
591 
592     /**
593      * Sets the link as dirty. Validity is checked such that the visualization may start to request the center line. This will
594      * then be calculated when dirty.
595      */
596     private void setDirtyEndCheckValidity()
597     {
598         this.dirtyGeometry = true;
599 
600         // Connector
601         if (this.isConnector)
602         {
603             if (this.centroid == null || this.connectorNode == null)
604             {
605                 setInvalid();
606                 return;
607             }
608             setValid();
609             return;
610         }
611 
612         // Node
613         if (this.nodeStart == null || this.nodeEnd == null || this.nodeStart.equals(this.nodeEnd)
614                 || this.shapeListener.shapeNode == null)
615         {
616             setInvalid();
617             return;
618         }
619         setValue((v) -> this.from = v, Adapters.get(Point2d.class), this.nodeStart, "Coordinate");
620         setValue((v) -> this.to = v, Adapters.get(Point2d.class), this.nodeEnd, "Coordinate");
621         if (this.from == null || this.to == null)
622         {
623             setInvalid();
624             return;
625         }
626 
627         setValid();
628     }
629 
630     /**
631      * Builds the design line.
632      */
633     private void buildDesignLine()
634     {
635         // TODO: move this to a parallel process?
636 
637         // Connector
638         if (this.isConnector)
639         {
640             setValue((v) -> this.from = v, Adapters.get(Point2d.class), this.centroid, "Coordinate");
641             setValue((v) -> this.to = v, Adapters.get(Point2d.class), this.connectorNode, "Coordinate");
642             DirectedPoint2d fromPoint = new DirectedPoint2d(this.from, this.from.directionTo(this.to));
643             this.designLine = new Straight2d(fromPoint, this.from.distance(this.to));
644             setGeometry();
645             return;
646         }
647 
648         // Node
649         setValue((v) -> this.directionStart = v, Adapters.get(Direction.class), this.nodeStart, "Direction");
650         double dirStart = this.directionStart == null ? 0.0 : this.directionStart.si;
651         DirectedPoint2d fromPoint = new DirectedPoint2d(this.from, dirStart);
652         setValue((v) -> this.directionEnd = v, Adapters.get(Direction.class), this.nodeEnd, "Direction");
653         double dirEnd = this.directionEnd == null ? 0.0 : this.directionEnd.si;
654         DirectedPoint2d toPoint = new DirectedPoint2d(this.to, dirEnd);
655         if (this.offsetStart != null)
656         {
657             fromPoint = OtsGeometryUtil.offsetPoint(fromPoint, this.offsetStart.si);
658         }
659         if (this.offsetEnd != null)
660         {
661             toPoint = OtsGeometryUtil.offsetPoint(toPoint, this.offsetEnd.si);
662         }
663 
664         // Geometry
665         this.designLine = this.shapeListener.getContiuousLine(fromPoint, toPoint);
666         if (this.designLine == null)
667         {
668             this.designLine = new Straight2d(fromPoint, fromPoint.distance(toPoint));
669         }
670         setGeometry();
671         if (this.priorityAnimation != null)
672         {
673             getMap().removeAnimation(this.priorityAnimation);
674         }
675         this.priorityAnimation = new PriorityAnimation(new MapPriorityData(this), getMap().getContextualized());
676         buildLayout();
677     }
678 
679     /**
680      * Based on the design line, sets the flattened line, location, absolute contour and relative contour.
681      */
682     private void setGeometry()
683     {
684         this.flattenedDesignLine = this.designLine.toPolyLine(getFlattener());
685         DirectedPoint2d point = this.flattenedDesignLine.getLocationFractionExtended(0.5);
686         this.location = new DirectedPoint2d(point.x, point.y, point.dirZ);
687         this.absoluteContour =
688                 new Polygon2d(PolyLine2d.concatenate(this.flattenedDesignLine, this.flattenedDesignLine.reverse()).iterator());
689         this.relativeContour =
690                 new Polygon2d(0.0, OtsShape.toRelativeTransform(this.location).transform(this.absoluteContour.iterator()));
691         this.dirtyGeometry = false;
692     }
693 
694     /**
695      * Returns the flattener to use, which is either a flattener defined at link level, or at network level.
696      * @return Flattener, flattener to use.
697      */
698     private CurveFlattener getFlattener()
699     {
700         if (this.flattenerListener != null)
701         {
702             CurveFlattener flattener = this.flattenerListener.getData();
703             if (flattener != null)
704             {
705                 return flattener; // otherwise not valid, return network level flattener
706             }
707         }
708         return getMap().getNetworkFlattener();
709     }
710 
711     /**
712      * Builds all animation objects for stripes, lanes, shoulders, and their center lines and id's.
713      */
714     private void buildLayout()
715     {
716         if (this.designLine == null)
717         {
718             return;
719         }
720         for (Renderable2d<?> renderable : this.crossSectionElements)
721         {
722             getMap().removeAnimation(renderable);
723         }
724         for (Entry<String, MapStripeData> entry : this.stripeData.entrySet())
725         {
726             getMap().getSynchronizableStripes().remove(entry.getValue());
727         }
728         this.crossSectionElements.clear();
729         this.laneData.clear();
730         this.stripeData.clear();
731         if (this.roadLayoutNode != null)
732         {
733             java.util.Map<XsdTreeNode, CseData> cseDataMap = this.roadLayoutListener != null ? this.roadLayoutListener.getData()
734                     : getMap().getRoadLayoutListener(this.roadLayoutNode).getData();
735             MiddleOffset middleOffset = new MiddleOffset();
736             Map<String, XsdTreeNode> laneOverrides = new LinkedHashMap<>();
737             Map<String, XsdTreeNode> stripeOverrides = new LinkedHashMap<>();
738             if (new ChildNodeFinder(getNode()).hasActiveChild("DefinedLayout"))
739             {
740                 for (XsdTreeNode child : getNode().getChildren())
741                 {
742                     if (child.getNodeName().equals("xsd:sequence"))
743                     {
744                         for (XsdTreeNode override : child.getChildren())
745                         {
746                             if (override.getNodeName().equals("LaneOverride") && override.isActive())
747                             {
748                                 laneOverrides.put(override.getAttributeValueOrDefault("Lane"), override);
749                             }
750                             else if (override.getNodeName().equals("StripeOverride") && override.isActive())
751                             {
752                                 stripeOverrides.put(override.getAttributeValueOrDefault("Stripe"), override);
753                             }
754                         }
755                     }
756                 }
757             }
758 
759             for (Entry<XsdTreeNode, CseData> entry : cseDataMap.entrySet())
760             {
761                 XsdTreeNode node = entry.getKey();
762                 CseData cseData = entry.getValue();
763                 ContinuousPiecewiseLinearFunction offsetFunc = ContinuousPiecewiseLinearFunction.of(0.0,
764                         cseData.centerOffsetStart.si, 1.0, cseData.centerOffsetEnd.si);
765                 if (node.getNodeName().equals("Stripe"))
766                 {
767                     StripeAnimation stripe = createStripe(node, offsetFunc, middleOffset, stripeOverrides);
768                     if (stripe == null)
769                     {
770                         continue;
771                     }
772                     this.crossSectionElements.add(stripe);
773                 }
774                 else
775                 {
776                     ContinuousPiecewiseLinearFunction widthFunc =
777                             ContinuousPiecewiseLinearFunction.of(0.0, cseData.widthStart.si, 1.0, cseData.widthEnd.si);
778                     CrossSectionGeometry geometry =
779                             CrossSectionGeometry.of(this.designLine, getFlattener(), offsetFunc, widthFunc);
780                     if (node.getNodeName().equals("Lane"))
781                     {
782                         MapLaneData mapLaneData = new MapLaneData(node.getId(), getNode(), geometry);
783                         LaneAnimation lane =
784                                 new LaneAnimation(mapLaneData, getMap().getContextualized(), Color.GRAY.brighter());
785                         this.crossSectionElements.add(lane);
786                         this.laneData.put(node.getId(), mapLaneData);
787                     }
788                     else if (node.getNodeName().equals("Shoulder"))
789                     {
790                         CrossSectionElementAnimation<?> shoulder = new CrossSectionElementAnimation<>(
791                                 new MapShoulderData(getNode(), geometry), getMap().getContextualized(), Color.DARK_GRAY);
792                         this.crossSectionElements.add(shoulder);
793                     }
794                     else if (node.getNodeName().equals("NoTrafficLane"))
795                     {
796                         CrossSectionElementAnimation<?> noTrafficLane = new CrossSectionElementAnimation<>(
797                                 new MapCrossSectionData(getNode(), geometry), getMap().getContextualized(), Color.DARK_GRAY);
798                         this.crossSectionElements.add(noTrafficLane);
799                     }
800                     else
801                     {
802                         throw new OtsRuntimeException(
803                                 "Element " + node.getNodeName() + " is not a supported cross-section element.");
804                     }
805                 }
806             }
807         }
808         StripeSynchronization.synchronize(new LinkedHashMap<>(getMap().getSynchronizableStripes()));
809         this.dirtyLayout = false;
810         fireEvent(LAYOUT_REBUILT, this);
811     }
812 
813     /**
814      * Creates stripe animation from stripe node.
815      * @param node node Stripe within road layout
816      * @param offsetFunc offset function
817      * @param middleOffset middle offset to add stripe offsets to
818      * @param stripeOverrides stripe overrides
819      * @return stripe animation, {@code null} if something was not valid
820      */
821     private StripeAnimation createStripe(final XsdTreeNode node, final ContinuousPiecewiseLinearFunction offsetFunc,
822             final MiddleOffset middleOffset, final Map<String, XsdTreeNode> stripeOverrides)
823     {
824         StripePhaseSync phaseSync = StripePhaseSync.NONE;
825         Length dashOffset = Length.ZERO;
826         StripeLateralSync lateralSync = StripeLateralSync.NONE;
827         List<StripeElement> elements = new ArrayList<>();
828         XsdTreeNode stripeNode;
829         ChildNodeFinder stripeRefFinder = new ChildNodeFinder(node);
830         if (stripeRefFinder.hasActiveChild("DefinedStripe"))
831         {
832             stripeNode = stripeRefFinder.get().getCoupledNodeValue().orElse(null);
833         }
834         else if (stripeRefFinder.hasActiveChild("Custom"))
835         {
836             stripeNode = stripeRefFinder.get();
837         }
838         else
839         {
840             return null;
841         }
842         ChildNodeFinder stripeFinder = new ChildNodeFinder(stripeNode);
843         ChildNodeFinder overrideFinder =
844                 stripeOverrides.containsKey(node.getId()) ? new ChildNodeFinder(stripeOverrides.get(node.getId())) : null;
845         if ((overrideFinder != null && overrideFinder.hasActiveChild("DashOffset"))
846                 || stripeFinder.hasActiveChild("DashOffset"))
847         {
848             try
849             {
850                 XsdTreeNode dashOffsetNode = overrideFinder != null && overrideFinder.hasActiveChild("DashOffset")
851                         ? overrideFinder.get().getChild(0) : stripeFinder.get().getChild(0);
852 
853                 switch (dashOffsetNode.getNodeName())
854                 {
855                     case "SyncUpstream":
856                     {
857                         phaseSync = StripePhaseSync.UPSTREAM;
858                         break;
859                     }
860                     case "SyncDownstream":
861                     {
862                         phaseSync = StripePhaseSync.DOWNSTREAM;
863                         break;
864                     }
865                     case "Fixed":
866                     {
867                         phaseSync = StripePhaseSync.NONE;
868                         dashOffset = Adapters.get(Length.class).unmarshal(dashOffsetNode.getAttributeValueOrDefault("Offset"))
869                                 .get(getEval());
870                         break;
871                     }
872                     default:
873                     {
874                         Logger.ots().warn("Dash synchronization " + dashOffsetNode.getNodeName() + " is unknown.");
875                     }
876                 }
877             }
878             catch (Exception e)
879             {
880                 // ignore
881             }
882         }
883         if ((overrideFinder != null && overrideFinder.hasActiveChild("LateralSync"))
884                 || stripeFinder.hasActiveChild("LateralSync"))
885         {
886             String latSyncName = overrideFinder != null && overrideFinder.hasActiveChild("DashOffset")
887                     ? overrideFinder.get().getValueOrDefault() : stripeFinder.get().getValueOrDefault();
888             lateralSync = latSyncName == null ? StripeLateralSync.NONE
889                     : Adapters.get(StripeLateralSync.class).unmarshal(latSyncName).get(getEval());
890         }
891         Length width = Length.ZERO;
892         List<XsdTreeNode> elementNodes;
893         if (overrideFinder != null && overrideFinder.hasActiveChild("Elements"))
894         {
895             elementNodes = overrideFinder.get().getChildren();
896         }
897         else if (stripeFinder.hasActiveChild("Elements"))
898         {
899             elementNodes = stripeFinder.get().getChildren();
900         }
901         else
902         {
903             elementNodes = Collections.emptyList();
904         }
905         for (XsdTreeNode elementNode : elementNodes)
906         {
907             if (elementNode.isValid())
908             {
909                 Length w = Adapters.get(Length.class).unmarshal(elementNode.getAttributeValueOrDefault("Width")).get(getEval());
910                 width = width.plus(w);
911                 if (elementNode.getNodeName().equals("Line"))
912                 {
913                     String colorName = elementNode.getAttributeValueOrDefault("Color");
914                     Color color = Adapters.get(Color.class).unmarshal(colorName).get(getEval());
915                     if (elementNode.getChild(0).getNodeName().equals("Continuous"))
916                     {
917                         elements.add(StripeElement.continuous(w, color));
918                     }
919                     else
920                     {
921                         List<Double> gapsAndDashes = new ArrayList<>();
922                         for (XsdTreeNode gapDash : elementNode.getChild(0).getChildren())
923                         {
924                             gapsAndDashes.add(Adapters.get(Length.class).unmarshal(gapDash.getChild(0).getValueOrDefault())
925                                     .get(getEval()).si);
926                             gapsAndDashes.add(Adapters.get(Length.class).unmarshal(gapDash.getChild(1).getValueOrDefault())
927                                     .get(getEval()).si);
928                         }
929                         elements.add(StripeElement.dashed(w, color,
930                                 new LengthVector(gapsAndDashes.stream().mapToDouble(v -> v).toArray())));
931                     }
932                 }
933                 else
934                 {
935                     elements.add(StripeElement.gap(w));
936                 }
937             }
938         }
939         ContinuousPiecewiseLinearFunction widthFunc = ContinuousPiecewiseLinearFunction.of(0.0, width.si, 1.0, width.si);
940         CrossSectionGeometry geometry = CrossSectionGeometry.of(this.designLine, getFlattener(), offsetFunc, widthFunc);
941         middleOffset.addStartOffset(geometry.offset().get(0.0));
942         middleOffset.addEndOffset(geometry.offset().get(1.0));
943         MapStripeData data = new MapStripeData(dashOffset, getNode(), geometry, elements, lateralSync, this.flattenedDesignLine,
944                 middleOffset, this.directionStart, this.directionEnd);
945         getMap().getSynchronizableStripes().put(data, new SynchronizableMapStripe(this, data, phaseSync));
946         this.stripeData.put(node.getId(), data);
947         return new StripeAnimation(data, getMap().getContextualized());
948     }
949 
950     /**
951      * Returns the stripes.
952      * @return stripes
953      */
954     protected Collection<MapStripeData> getStripeData()
955     {
956         return this.stripeData.values();
957     }
958 
959     @Override
960     public EventListenerMap getEventListenerMap()
961     {
962         return this.eventListenerMap;
963     }
964 
965     @Override
966     public void evalChanged()
967     {
968         if (getNode().isActive())
969         {
970             this.id = getNode().getId() == null ? "" : getNode().getId();
971             this.nodeStart = replaceNode(this.nodeStart, getNode().getCoupledNodeAttribute("NodeStart").orElse(null));
972             this.nodeEnd = replaceNode(this.nodeEnd, getNode().getCoupledNodeAttribute("NodeEnd").orElse(null));
973             XsdTreeNode node = getNode();
974             if (node != null && node.getPathString().equals(XsdPaths.LINK)) // Connectors have no offsets
975             {
976                 setValue((v) -> this.offsetStart = v, Adapters.get(Length.class), node, "OffsetStart");
977                 setValue((v) -> this.offsetEnd = v, Adapters.get(Length.class), node, "OffsetEnd");
978             }
979             this.shapeListener.updateShape();
980             setDirtyEndCheckValidity();
981         }
982     }
983 
984     /**
985      * Notification from the Map that a node (Ots.Network.Node) id was changed.
986      */
987     public void notifyNodeIdChanged()
988     {
989         this.nodeStart = replaceNode(this.nodeStart, getNode().getCoupledNodeAttribute("NodeStart").orElse(null));
990         this.nodeEnd = replaceNode(this.nodeEnd, getNode().getCoupledNodeAttribute("NodeEnd").orElse(null));
991         setDirtyEndCheckValidity();
992     }
993 
994     /**
995      * Notification from the Map that a node (Ots.Network.Node) is removed.
996      * @param node node of node
997      */
998     public void notifyNodeRemoved(final XsdTreeNode node)
999     {
1000         if (node.equals(this.nodeStart))
1001         {
1002             this.nodeStart = null;
1003             this.directionStart = null;
1004             setDirtyEndCheckValidity();
1005         }
1006         else if (node.equals(this.nodeEnd))
1007         {
1008             this.nodeEnd = null;
1009             this.directionEnd = null;
1010             setDirtyEndCheckValidity();
1011         }
1012     }
1013 
1014     /**
1015      * Returns the value with appropriate adapter, or {@code null} if the value is {@code null}.
1016      * @param <T> type of the value after unmarshaling.
1017      * @param value value.
1018      * @param adapter adapter for values of type T.
1019      * @return unmarshaled value.
1020      */
1021     private <T> T orNull(final String value, final ExpressionAdapter<T, ?> adapter)
1022     {
1023         try
1024         {
1025             return value == null ? null : adapter.unmarshal(value).get(getEval());
1026         }
1027         catch (IllegalArgumentException ex)
1028         {
1029             // illegal or missing value for adapter
1030             return null;
1031         }
1032     }
1033 
1034     /**
1035      * Returns the editor lane data for the lane of given id.
1036      * @param laneId id.
1037      * @return editor lane data for the lane of given id.
1038      */
1039     public MapLaneData getLaneData(final String laneId)
1040     {
1041         return this.laneData.get(laneId);
1042     }
1043 
1044     /**
1045      * Remembers minimum and maximum start and end offset, to return the mean of both.
1046      */
1047     public class MiddleOffset
1048     {
1049         /** Minimum start offset. */
1050         private double startOffsetMin = Double.POSITIVE_INFINITY;
1051 
1052         /** Minimum start offset. */
1053         private double startOffsetMax = Double.NEGATIVE_INFINITY;
1054 
1055         /** Minimum end offset. */
1056         private double endOffsetMin = Double.POSITIVE_INFINITY;
1057 
1058         /** Maximum end offset. */
1059         private double endOffsetMax = Double.NEGATIVE_INFINITY;
1060 
1061         /**
1062          * Constructor.
1063          */
1064         public MiddleOffset()
1065         {
1066             //
1067         }
1068 
1069         /**
1070          * Get start offset.
1071          * @return start offset
1072          */
1073         public double getStartOffset()
1074         {
1075             return .5 * (this.startOffsetMin + this.startOffsetMax);
1076         }
1077 
1078         /**
1079          * Add start offset.
1080          * @param startOffset start offset.
1081          */
1082         public void addStartOffset(final double startOffset)
1083         {
1084             this.startOffsetMin = Math.min(this.startOffsetMin, startOffset);
1085             this.startOffsetMax = Math.max(this.startOffsetMax, startOffset);
1086         }
1087 
1088         /**
1089          * Get end offset.
1090          * @return end offset
1091          */
1092         public double getEndOffset()
1093         {
1094             return .5 * (this.endOffsetMin + this.endOffsetMax);
1095         }
1096 
1097         /**
1098          * Add end offset.
1099          * @param endOffset end offset
1100          */
1101         public void addEndOffset(final double endOffset)
1102         {
1103             this.endOffsetMin = Math.min(this.endOffsetMin, endOffset);
1104             this.endOffsetMax = Math.max(this.endOffsetMax, endOffset);
1105         }
1106     }
1107 
1108     /**
1109      * Listener to events that affect the shape. This class can also deliver the resulting line.
1110      * <p>
1111      * Copyright (c) 2023-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
1112      * <br>
1113      * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
1114      * </p>
1115      * @author Wouter Schakel
1116      */
1117     private final class ShapeListener implements EventListener
1118     {
1119         /** Node of the shape. */
1120         private XsdTreeNode shapeNode;
1121 
1122         /** Bezier shape. */
1123         private Double shape;
1124 
1125         /** Bezier weighted or not. */
1126         private Boolean weighted;
1127 
1128         /** Clothoid start curvature. */
1129         private LinearDensity startCurvature;
1130 
1131         /** Clothoid end curvature. */
1132         private LinearDensity endCurvature;
1133 
1134         /** Clothoid length. */
1135         private Length length;
1136 
1137         /** Clothoid a-value. */
1138         private Length a;
1139 
1140         /** Arc radius. */
1141         private Length radius;
1142 
1143         /** Arc direction. */
1144         private ArcDirection direction;
1145 
1146         /** Polyline coordinates. */
1147         private NavigableMap<XsdTreeNode, Point2d> coordinates = new TreeMap<>(new Comparator<>()
1148         {
1149             @Override
1150             public int compare(final XsdTreeNode o1, final XsdTreeNode o2)
1151             {
1152                 List<XsdTreeNode> list = ShapeListener.this.shapeNode.getChildren();
1153                 return Integer.compare(list.indexOf(o1), list.indexOf(o2));
1154             }
1155         });
1156 
1157         @Override
1158         public void notify(final Event event)
1159         {
1160             if (event.getType().equals(XsdTreeNode.OPTION_CHANGED))
1161             {
1162                 XsdTreeNode node = (XsdTreeNode) ((Object[]) event.getContent())[1];
1163                 if (node.getParent().equals(this.shapeNode))
1164                 {
1165                     // clothoid option changed
1166                     for (XsdTreeNode option : node.getChildren())
1167                     {
1168                         option.addListener(this, XsdTreeNode.VALUE_CHANGED, ReferenceType.WEAK);
1169                     }
1170                     // later as values may not be loaded yet during loading
1171                     SwingUtilities.invokeLater(() -> updateShape());
1172                 }
1173                 else
1174                 {
1175                     // shape node changed
1176                     if (this.shapeNode != null)
1177                     {
1178                         if (this.shapeNode.getChildCount() > 0)
1179                         {
1180                             this.shapeNode.getChild(0).removeListener(this, XsdTreeNode.OPTION_CHANGED);
1181                         }
1182                         this.shapeNode.removeListener(this, XsdTreeNode.ATTRIBUTE_CHANGED);
1183                     }
1184                     this.shapeNode = node;
1185                     this.shapeNode.addListener(this, XsdTreeNode.ATTRIBUTE_CHANGED, ReferenceType.WEAK);
1186                     if (this.shapeNode.getNodeName().equals(Polyline.class.getSimpleName()))
1187                     {
1188                         for (XsdTreeNode option : this.shapeNode.getChildren())
1189                         {
1190                             option.addListener(this, XsdTreeNode.VALUE_CHANGED, ReferenceType.WEAK);
1191                             option.addListener(this, XsdTreeNode.MOVED, ReferenceType.WEAK);
1192                         }
1193                     }
1194                     else if (this.shapeNode.getNodeName().equals(Clothoid.class.getSimpleName()))
1195                     {
1196                         // later as flattener may not be loaded yet during loading
1197                         SwingUtilities.invokeLater(() ->
1198                         {
1199                             this.shapeNode.getChild(0).addListener(this, XsdTreeNode.OPTION_CHANGED, ReferenceType.WEAK);
1200                             for (XsdTreeNode option : this.shapeNode.getChild(0).getChildren())
1201                             {
1202                                 option.addListener(this, XsdTreeNode.VALUE_CHANGED, ReferenceType.WEAK);
1203                             }
1204                         });
1205                     }
1206                     if (this.shapeNode.getNodeName().equals(Clothoid.class.getSimpleName())
1207                             || this.shapeNode.getNodeName().equals(Arc.class.getSimpleName())
1208                             || this.shapeNode.getNodeName().equals(Bezier.class.getSimpleName()))
1209                     {
1210                         // later as flattener may not be loaded yet during loading
1211                         SwingUtilities.invokeLater(() -> setFlattenerListener());
1212                     }
1213                     // later as values/coordinates may not be loaded yet during loading
1214                     SwingUtilities.invokeLater(() -> updateShape());
1215                 }
1216                 setDirtyEndCheckValidity();
1217             }
1218             else if (event.getType().equals(XsdTreeNode.ATTRIBUTE_CHANGED))
1219             {
1220                 setAttribute((String) ((Object[]) event.getContent())[1]);
1221                 setDirtyEndCheckValidity();
1222             }
1223             else if (event.getType().equals(XsdTreeNode.VALUE_CHANGED))
1224             {
1225                 // clothoid option specification or polyline coordinate
1226                 Object[] content = (Object[]) event.getContent();
1227                 XsdTreeNode node = (XsdTreeNode) content[0];
1228                 try
1229                 {
1230                     switch (node.getNodeName())
1231                     {
1232                         case "Coordinate":
1233                             this.coordinates.put(node, orNull(node.getValueOrDefault(), Adapters.get(Point2d.class)));
1234                             break;
1235                         case "StartCurvature":
1236                             this.startCurvature = orNull(node.getValueOrDefault(), Adapters.get(LinearDensity.class));
1237                             break;
1238                         case "EndCurvature":
1239                             this.endCurvature = orNull(node.getValueOrDefault(), Adapters.get(LinearDensity.class));
1240                             break;
1241                         case "Length":
1242                             this.length = orNull(node.getValueOrDefault(), Adapters.get(Length.class));
1243                             break;
1244                         case "A":
1245                             this.a = orNull(node.getValueOrDefault(), Adapters.get(Length.class));
1246                             break;
1247                         default:
1248                             Logger.ots().warn(node.getNodeName()
1249                                     + " is unknown node name of which the value is assumed to affect link center line.");
1250                     }
1251                 }
1252                 catch (Exception ex)
1253                 {
1254                     // leave line as is, new value is not a valid value
1255                     return;
1256                 }
1257                 setDirtyEndCheckValidity();
1258             }
1259             else if (event.getType().equals(XsdTreeNode.MOVED))
1260             {
1261                 // order of coordinates changed
1262                 updateShape();
1263                 setDirtyEndCheckValidity();
1264             }
1265             else if (event.getType().equals(XsdTreeNode.ACTIVATION_CHANGED))
1266             {
1267                 // flattener node
1268                 boolean activated = (boolean) ((Object[]) event.getContent())[1];
1269                 if (activated)
1270                 {
1271                     setFlattenerListener();
1272                 }
1273                 else
1274                 {
1275                     if (MapLinkData.this.flattenerListener != null)
1276                     {
1277                         MapLinkData.this.flattenerListener.destroy();
1278                     }
1279                     MapLinkData.this.flattenerListener = null;
1280                 }
1281                 setDirtyEndCheckValidity();
1282             }
1283         }
1284 
1285         /**
1286          * Sets the flattener listener in the link.
1287          */
1288         private void setFlattenerListener()
1289         {
1290             if (MapLinkData.this.flattenerListener != null)
1291             {
1292                 MapLinkData.this.flattenerListener.destroy();
1293             }
1294             MapLinkData.this.flattenerListener = new FlattenerListener(this.shapeNode.getChild(0), () -> getEval());
1295             MapLinkData.this.flattenerListener.addListener(MapLinkData.this, ChangeListener.CHANGE_EVENT, ReferenceType.WEAK);
1296             this.shapeNode.getChild(0).addListener(this, XsdTreeNode.ACTIVATION_CHANGED);
1297         }
1298 
1299         /**
1300          * Update the line, clearing all fields, and setting any already available attributes (as the shape node was previously
1301          * selected and edited).
1302          */
1303         private void updateShape()
1304         {
1305             this.shape = null;
1306             this.weighted = null;
1307             this.startCurvature = null;
1308             this.endCurvature = null;
1309             this.length = null;
1310             this.a = null;
1311             this.radius = null;
1312             this.direction = null;
1313             this.coordinates.clear();
1314             if (this.shapeNode == null)
1315             {
1316                 return;
1317             }
1318             switch (this.shapeNode.getNodeName())
1319             {
1320                 case "Straight":
1321                     setDirtyEndCheckValidity();
1322                     break;
1323                 case "Polyline":
1324                     for (XsdTreeNode child : this.shapeNode.getChildren())
1325                     {
1326                         try
1327                         {
1328                             this.coordinates.put(child, orNull(child.getValueOrDefault(), Adapters.get(Point2d.class)));
1329                         }
1330                         catch (Exception ex)
1331                         {
1332                             orNull(child.getValueOrDefault(), Adapters.get(Point2d.class));
1333                             throw new OtsRuntimeException(
1334                                     "Expression adapter could not unmarshal value for polyline coordinate.");
1335                         }
1336                     }
1337                     setDirtyEndCheckValidity();
1338                     break;
1339                 case "Bezier":
1340                     setAttribute("Shape");
1341                     setAttribute("Weighted");
1342                     setDirtyEndCheckValidity();
1343                     setFlattenerListener();
1344                     break;
1345                 case "Clothoid":
1346                     if (this.shapeNode.getChildCount() > 0 && this.shapeNode.getChild(0).getChildCount() > 0)
1347                     {
1348                         // child is an xsd:sequence, take children from that
1349                         for (int childIndex = 0; childIndex < this.shapeNode.getChild(0).getChildCount(); childIndex++)
1350                         {
1351                             XsdTreeNode child = this.shapeNode.getChild(0).getChild(childIndex);
1352                             try
1353                             {
1354                                 switch (child.getNodeName())
1355                                 {
1356                                     case "StartCurvature":
1357                                         this.startCurvature =
1358                                                 orNull(child.getValueOrDefault(), Adapters.get(LinearDensity.class));
1359                                         break;
1360                                     case "EndCurvature":
1361                                         this.endCurvature =
1362                                                 orNull(child.getValueOrDefault(), Adapters.get(LinearDensity.class));
1363                                         break;
1364                                     case "Length":
1365                                         this.length = orNull(child.getValueOrDefault(), Adapters.get(Length.class));
1366                                         break;
1367                                     case "A":
1368                                         this.a = orNull(child.getValueOrDefault(), Adapters.get(Length.class));
1369                                         break;
1370                                     default:
1371                                         throw new OtsRuntimeException(
1372                                                 "Clothoid child " + child.getNodeName() + " not supported.");
1373                                 }
1374                             }
1375                             catch (Exception ex)
1376                             {
1377                                 throw new OtsRuntimeException("Expression adapter could not unmarshal value for Clothoid child "
1378                                         + child.getNodeName());
1379                             }
1380                         }
1381                     }
1382                     setDirtyEndCheckValidity();
1383                     setFlattenerListener();
1384                     break;
1385                 case "Arc":
1386                     setAttribute("Radius");
1387                     setAttribute("Direction");
1388                     setDirtyEndCheckValidity();
1389                     setFlattenerListener();
1390                     break;
1391                 case "xsd:choice":
1392                     // inactive node, will be invalid
1393                     setDirtyEndCheckValidity();
1394                     break;
1395                 default:
1396                     throw new OtsRuntimeException(
1397                             "Drawing of shape node " + this.shapeNode.getNodeName() + " is not supported.");
1398             }
1399         }
1400 
1401         /**
1402          * Set the given attribute from the shape node.
1403          * @param attribute attribute name.
1404          */
1405         private void setAttribute(final String attribute)
1406         {
1407             if (!this.shapeNode.reportInvalidAttributeValue(this.shapeNode.getAttributeIndexByName(attribute)).isEmpty())
1408             {
1409                 // invalid value, do nothing
1410                 return;
1411             }
1412             switch (attribute)
1413             {
1414                 case "Shape":
1415                     this.shape = getOrNull(attribute, Adapters.DOUBLE_POSITIVE_ADAPTER);
1416                     break;
1417                 case "Weighted":
1418                     this.weighted = getOrNull(attribute, Adapters.get(Boolean.class));
1419                     break;
1420                 case "Length":
1421                     this.length = getOrNull(attribute, Adapters.get(Length.class));
1422                     break;
1423                 case "Radius":
1424                     this.radius = getOrNull(attribute, Adapters.get(Length.class));
1425                     break;
1426                 case "Direction":
1427                     this.direction = getOrNull(attribute, Adapters.get(ArcDirection.class));
1428                     break;
1429                 default:
1430                     // an attribute was changed that does not change the shape
1431             }
1432         }
1433 
1434         /**
1435          * Returns the attribute value with appropriate adapter, or {@code null} if the attribute is not given.
1436          * @param <T> type of the attribute value after unmarshaling.
1437          * @param attribute attribute.
1438          * @param adapter adapter for values of type T.
1439          * @return unmarshaled value.
1440          */
1441         private <T> T getOrNull(final String attribute, final ExpressionAdapter<T, ?> adapter)
1442         {
1443             String value = this.shapeNode.getAttributeValueOrDefault(attribute);
1444             return orNull(value, adapter);
1445         }
1446 
1447         /**
1448          * Returns the continuous line.
1449          * @param fromPoint possibly offset start point.
1450          * @param toPoint possibly offset end point.
1451          * @return line from the shape and attributes.
1452          */
1453         public OffsetCurve2d getContiuousLine(final DirectedPoint2d fromPoint, final DirectedPoint2d toPoint)
1454         {
1455             switch (this.shapeNode.getNodeName())
1456             {
1457                 case "Straight":
1458                     return new Straight2d(fromPoint, fromPoint.distance(toPoint));
1459                 case "Polyline":
1460                     List<Point2d> list = new ArrayList<>();
1461                     list.add(fromPoint);
1462                     for (Entry<XsdTreeNode, Point2d> entry : this.coordinates.entrySet())
1463                     {
1464                         list.add(entry.getValue());
1465                     }
1466                     list.add(toPoint);
1467                     if (list.contains(null))
1468                     {
1469                         if (list.size() == 3)
1470                         {
1471                             // one coordinate tree node with empty or invalid coordinate value, allow map to show 1 draggable
1472                             list.remove(1);
1473                         }
1474                         else
1475                         {
1476                             return null;
1477                         }
1478                     }
1479                     return new PolyLineCurve2d(new PolyLine2d(0.0, list), fromPoint.dirZ, toPoint.dirZ);
1480                 case "Bezier":
1481                     return new BezierCubic2d(new Ray2d(fromPoint), new Ray2d(toPoint), this.shape == null ? 1.0 : this.shape,
1482                             this.weighted == null ? false : this.weighted);
1483                 case "Clothoid":
1484                     if (this.shapeNode.getChildCount() == 0 || this.shapeNode.getChild(0).getChildCount() == 0
1485                             || this.shapeNode.getChild(0).getChild(0).getNodeName().equals("Interpolated"))
1486                     {
1487                         return new Clothoid2d(fromPoint, toPoint);
1488                     }
1489                     else if (this.shapeNode.getChild(0).getChild(0).getNodeName().equals("Length"))
1490                     {
1491                         if (this.length == null || this.startCurvature == null || this.endCurvature == null)
1492                         {
1493                             return null;
1494                         }
1495                         return Clothoid2d.withLength(fromPoint, this.length.si, this.startCurvature.si, this.endCurvature.si);
1496                     }
1497                     else
1498                     {
1499                         if (this.a == null || this.startCurvature == null || this.endCurvature == null)
1500                         {
1501                             return null;
1502                         }
1503                         return new Clothoid2d(fromPoint, this.a.si, this.startCurvature.si, this.endCurvature.si);
1504                     }
1505                 case "Arc":
1506                     if (this.direction == null || this.radius == null)
1507                     {
1508                         return null;
1509                     }
1510                     boolean left = this.direction.equals(ArcDirection.LEFT);
1511                     double endHeading = toPoint.dirZ;
1512                     while (left && endHeading < fromPoint.dirZ)
1513                     {
1514                         endHeading += 2.0 * Math.PI;
1515                     }
1516                     while (!left && endHeading > fromPoint.dirZ)
1517                     {
1518                         endHeading -= 2.0 * Math.PI;
1519                     }
1520                     Angle angle = Angle.ofSI(
1521                             AngleUtil.normalizeAroundPi(left ? endHeading - fromPoint.dirZ : fromPoint.dirZ - endHeading));
1522                     return new Arc2d(fromPoint, this.radius.si, left, angle.si);
1523                 default:
1524                     throw new OtsRuntimeException(
1525                             "Drawing of shape node " + this.shapeNode.getNodeName() + " is not supported.");
1526             }
1527         }
1528     }
1529 
1530     /**
1531      * Returns the design line.
1532      * @return design line
1533      */
1534     public OffsetCurve2d getDesignLine()
1535     {
1536         checkDirtyGeometry();
1537         return this.designLine;
1538     }
1539 
1540     // Bezier methods
1541 
1542     /**
1543      * Returns whether the link has a Bezier shape.
1544      * @return whether the link has a Bezier shape
1545      */
1546     public boolean isBezier()
1547     {
1548         return isShape(Bezier.class);
1549     }
1550 
1551     /**
1552      * Returns the shape value of the Bezier.
1553      * @return the shape value of the Bezier
1554      */
1555     public double getBezierShape()
1556     {
1557         return this.shapeListener.shape == null ? 1.0 : this.shapeListener.shape;
1558     }
1559 
1560     /**
1561      * Returns whether the Bezier is weighted.
1562      * @return whether the Bezier is weighted
1563      */
1564     public boolean isWeightedBezier()
1565     {
1566         return this.shapeListener.weighted == null ? false : this.shapeListener.weighted;
1567     }
1568 
1569     // Polyline methods
1570 
1571     /**
1572      * Returns whether the link has a polyline shape.
1573      * @return whether the link has a polyline shape
1574      */
1575     public boolean isPolyline()
1576     {
1577         return isShape(Polyline.class);
1578     }
1579 
1580     /**
1581      * Returns a safe copy of the polyline coordinates, mapped from XSD node to point.
1582      * @return a safe copy of the polyline coordinates
1583      */
1584     public NavigableMap<XsdTreeNode, Point2d> getPolylineCoordinates()
1585     {
1586         return new TreeMap<>(this.shapeListener.coordinates);
1587     }
1588 
1589     // Clothoid methods
1590 
1591     /**
1592      * Returns whether the link has a clothoid shape.
1593      * @return whether the link has a clothoid shape
1594      */
1595     public boolean isClothoid()
1596     {
1597         return isShape(Clothoid.class);
1598     }
1599 
1600     /**
1601      * Returns the start curvature from the clothoid.
1602      * @return start curvature from the clothoid.
1603      */
1604     public LinearDensity getClothoidStartCurvature()
1605     {
1606         if (this.designLine != null && this.designLine instanceof Clothoid2d)
1607         {
1608             return LinearDensity.ofSI(((Clothoid2d) this.designLine).getStartCurvature());
1609         }
1610         return null;
1611     }
1612 
1613     /**
1614      * Returns the end curvature from the clothoid.
1615      * @return end curvature from the clothoid.
1616      */
1617     public LinearDensity getClothoidEndCurvature()
1618     {
1619         if (this.designLine != null && this.designLine instanceof Clothoid2d)
1620         {
1621             return LinearDensity.ofSI(((Clothoid2d) this.designLine).getEndCurvature());
1622         }
1623         return null;
1624     }
1625 
1626     /**
1627      * Returns the length from the clothoid.
1628      * @return length from the clothoid.
1629      */
1630     public Length getClothoidLength()
1631     {
1632         if (this.designLine != null && this.designLine instanceof Clothoid2d)
1633         {
1634             return Length.ofSI(((Clothoid2d) this.designLine).getLength());
1635         }
1636         return null;
1637     }
1638 
1639     /**
1640      * Returns the A value from the clothoid.
1641      * @return A value from the clothoid.
1642      */
1643     public Length getClothoidA()
1644     {
1645         if (this.designLine != null && this.designLine instanceof Clothoid2d)
1646         {
1647             return Length.ofSI(((Clothoid2d) this.designLine).getA());
1648         }
1649         return null;
1650     }
1651 
1652     /**
1653      * Returns whether the shape was applied as a Clothoid, an Arc, or as a Straight, depending on start and end position and
1654      * direction.
1655      * @return "Clothoid", "Arc" or "Straight".
1656      */
1657     public String getClothoidAppliedShape()
1658     {
1659         if (this.designLine != null && this.designLine instanceof Clothoid2d)
1660         {
1661             return ((Clothoid2d) this.designLine).getAppliedShape();
1662         }
1663         return null;
1664     }
1665 
1666     /**
1667      * Returns whether the link has the given shape.
1668      * @param clazz class of generated class of XML tag
1669      * @return whether the link has the given shape
1670      */
1671     private boolean isShape(final Class<?> clazz)
1672     {
1673         return this.shapeListener.shapeNode.getNodeName().equals(clazz.getSimpleName());
1674     }
1675 
1676     @Override
1677     public String toString()
1678     {
1679         return (this.isConnector ? "Connector" : "Link ") + this.id;
1680     }
1681 
1682 }