// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.gui.draw;
import java.awt.BasicStroke;
import java.awt.Shape;
import java.awt.Stroke;
import java.awt.geom.Path2D;
import java.awt.geom.PathIterator;
import org.openstreetmap.josm.data.coor.EastNorth;
import org.openstreetmap.josm.data.osm.Node;
import org.openstreetmap.josm.gui.MapView;
import org.openstreetmap.josm.gui.MapViewState;
import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
import org.openstreetmap.josm.gui.MapViewState.MapViewRectangle;
/**
* This is a version of a java Path2D that allows you to add points to it by simply giving their east/north, lat/lon or node coordinates.
* <p>
* It is possible to clip the part of the path that is outside the view. This is useful when drawing dashed lines. Those lines use up a lot of
* performance if the zoom level is high and the part outside the view is long. See {@link #computeClippedLine(Stroke)}.
* @author Michael Zangl
* @since 10875
*/
public class MapViewPath extends MapPath2D {
private final MapViewState state;
/**
* Create a new path
* @param mv The map view to use for coordinate conversion.
*/
public MapViewPath(MapView mv) {
this(mv.getState());
}
/**
* Create a new path
* @param state The state to use for coordinate conversion.
*/
public MapViewPath(MapViewState state) {
this.state = state;
}
/**
* Gets the map view state this path is used for.
* @return The state.
* @since 11748
*/
public MapViewState getMapViewState() {
return state;
}
/**
* Move the cursor to the given node.
* @param n The node
* @return this for easy chaining.
*/
public MapViewPath moveTo(Node n) {
moveTo(n.getEastNorth());
return this;
}
/**
* Move the cursor to the given position.
* @param eastNorth The position
* @return this for easy chaining.
*/
public MapViewPath moveTo(EastNorth eastNorth) {
moveTo(state.getPointFor(eastNorth));
return this;
}
@Override
public MapViewPath moveTo(MapViewPoint p) {
super.moveTo(p);
return this;
}
/**
* Draw a line to the node.
* <p>
* line clamping to view is done automatically.
* @param n The node
* @return this for easy chaining.
*/
public MapViewPath lineTo(Node n) {
lineTo(n.getEastNorth());
return this;
}
/**
* Draw a line to the position.
* <p>
* line clamping to view is done automatically.
* @param eastNorth The position
* @return this for easy chaining.
*/
public MapViewPath lineTo(EastNorth eastNorth) {
lineTo(state.getPointFor(eastNorth));
return this;
}
@Override
public MapViewPath lineTo(MapViewPoint p) {
super.lineTo(p);
return this;
}
/**
* Add the given shape centered around the current node.
* @param p1 The point to draw around
* @param symbol The symbol type
* @param size The size of the symbol in pixel
* @return this for easy chaining.
*/
public MapViewPath shapeAround(Node p1, SymbolShape symbol, double size) {
shapeAround(p1.getEastNorth(), symbol, size);
return this;
}
/**
* Add the given shape centered around the current position.
* @param eastNorth The point to draw around
* @param symbol The symbol type
* @param size The size of the symbol in pixel
* @return this for easy chaining.
*/
public MapViewPath shapeAround(EastNorth eastNorth, SymbolShape symbol, double size) {
shapeAround(state.getPointFor(eastNorth), symbol, size);
return this;
}
@Override
public MapViewPath shapeAround(MapViewPoint p, SymbolShape symbol, double size) {
super.shapeAround(p, symbol, size);
return this;
}
/**
* Append a list of nodes
* @param nodes The nodes to append
* @param connect <code>true</code> if we should use a lineTo as first command.
* @return this for easy chaining.
*/
public MapViewPath append(Iterable<Node> nodes, boolean connect) {
appendWay(nodes, connect, false);
return this;
}
/**
* Append a list of nodes as closed way.
* @param nodes The nodes to append
* @param connect <code>true</code> if we should use a lineTo as first command.
* @return this for easy chaining.
*/
public MapViewPath appendClosed(Iterable<Node> nodes, boolean connect) {
appendWay(nodes, connect, true);
return this;
}
private void appendWay(Iterable<Node> nodes, boolean connect, boolean close) {
boolean useMoveTo = !connect;
Node first = null;
for (Node n : nodes) {
if (useMoveTo) {
moveTo(n);
} else {
lineTo(n);
}
if (close && first == null) {
first = n;
}
useMoveTo = false;
}
if (first != null) {
lineTo(first);
}
}
/**
* Converts a path in east/north coordinates to view space.
* @param path The path
* @since 11748
*/
public void appendFromEastNorth(Path2D.Double path) {
new PathVisitor() {
@Override
public void visitMoveTo(double x, double y) {
moveTo(new EastNorth(x, y));
}
@Override
public void visitLineTo(double x, double y) {
lineTo(new EastNorth(x, y));
}
@Override
public void visitClose() {
closePath();
}
}.visit(path);
}
/**
* Visits all segments of this path.
* @param consumer The consumer to send path segments to
* @return the total line length
* @since 11748
*/
public double visitLine(PathSegmentConsumer consumer) {
LineVisitor visitor = new LineVisitor(consumer);
visitor.visit(this);
return visitor.inLineOffset;
}
/**
* Compute a line that is similar to the current path expect for that parts outside the screen are skipped using moveTo commands.
*
* The line is computed in a way that dashes stay in their place when moving the view.
*
* The resulting line is not intended to fill areas.
* @param stroke The stroke to compute the line for.
* @return The new line shape.
* @since 11147
*/
public Shape computeClippedLine(Stroke stroke) {
MapPath2D clamped = new MapPath2D();
if (visitClippedLine(stroke, (inLineOffset, start, end, startIsOldEnd) -> {
if (!startIsOldEnd) {
clamped.moveTo(start);
}
clamped.lineTo(end);
})) {
return clamped;
} else {
// could not clip the path.
return this;
}
}
/**
* Visits all straight segments of this path. The segments are clamped to the view.
* If they are clamped, the start points are aligned with the pattern.
* @param stroke The stroke to take the dash information from.
* @param consumer The consumer to call for each segment
* @return false if visiting the path failed because there e.g. were non-straight segments.
* @since 11147
*/
public boolean visitClippedLine(Stroke stroke, PathSegmentConsumer consumer) {
if (stroke instanceof BasicStroke && ((BasicStroke) stroke).getDashArray() != null) {
float length = 0;
for (float f : ((BasicStroke) stroke).getDashArray()) {
length += f;
}
return visitClippedLine(((BasicStroke) stroke).getDashPhase(), length, consumer);
} else {
return visitClippedLine(0, 0, consumer);
}
}
/**
* Visits all straight segments of this path. The segments are clamped to the view.
* If they are clamped, the start points are aligned with the pattern.
* @param strokeOffset The initial offset of the pattern
* @param strokeLength The dash pattern length. 0 to use no pattern.
* @param consumer The consumer to call for each segment
* @return false if visiting the path failed because there e.g. were non-straight segments.
* @since 11147
*/
public boolean visitClippedLine(double strokeOffset, double strokeLength, PathSegmentConsumer consumer) {
return new ClampingPathVisitor(state.getViewClipRectangle(), strokeOffset, strokeLength, consumer)
.visit(this);
}
/**
* Gets the length of the way in visual space.
* @return The length.
* @since 11748
*/
public double getLength() {
return visitLine((inLineOffset, start, end, startIsOldEnd) -> { });
}
/**
* This class is used to visit the segments of this path.
* @author Michael Zangl
* @since 11147
*/
@FunctionalInterface
public interface PathSegmentConsumer {
/**
* Add a line segment between two points
* @param inLineOffset The offset of start in the line
* @param start The start point
* @param end The end point
* @param startIsOldEnd If the start point equals the last end point.
*/
void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd);
}
private interface PathVisitor {
/**
* Append a path to this one. The path is clipped to the current view.
* @param path The iterator
* @return true if adding the path was successful.
*/
default boolean visit(Path2D.Double path) {
double[] coords = new double[8];
PathIterator it = path.getPathIterator(null);
while (!it.isDone()) {
int type = it.currentSegment(coords);
switch (type) {
case PathIterator.SEG_CLOSE:
visitClose();
break;
case PathIterator.SEG_LINETO:
visitLineTo(coords[0], coords[1]);
break;
case PathIterator.SEG_MOVETO:
visitMoveTo(coords[0], coords[1]);
break;
default:
// cannot handle this shape - this should be very rare and not happening in OSM draw code.
return false;
}
it.next();
}
return true;
}
void visitClose();
void visitMoveTo(double x, double y);
void visitLineTo(double x, double y);
}
private abstract class AbstractMapPathVisitor implements PathVisitor {
private MapViewPoint lastMoveTo;
@Override
public void visitMoveTo(double x, double y) {
MapViewPoint move = state.getForView(x, y);
lastMoveTo = move;
visitMoveTo(move);
}
abstract void visitMoveTo(MapViewPoint p);
@Override
public void visitLineTo(double x, double y) {
visitLineTo(state.getForView(x, y));
}
abstract void visitLineTo(MapViewPoint p);
@Override
public void visitClose() {
visitLineTo(lastMoveTo);
}
}
private final class LineVisitor extends AbstractMapPathVisitor {
private final PathSegmentConsumer consumer;
private MapViewPoint last;
private double inLineOffset;
private boolean startIsOldEnd;
LineVisitor(PathSegmentConsumer consumer) {
this.consumer = consumer;
}
@Override
void visitMoveTo(MapViewPoint p) {
last = p;
startIsOldEnd = false;
}
@Override
void visitLineTo(MapViewPoint p) {
consumer.addLineBetween(inLineOffset, last, p, startIsOldEnd);
inLineOffset += last.distanceToInView(p);
last = p;
startIsOldEnd = true;
}
}
private class ClampingPathVisitor extends AbstractMapPathVisitor {
private final MapViewRectangle clip;
private final PathSegmentConsumer consumer;
protected double strokeProgress;
private final double strokeLength;
private MapViewPoint cursor;
private boolean cursorIsActive;
/**
* Create a new {@link ClampingPathVisitor}
* @param clip View clip rectangle
* @param strokeOffset Initial stroke offset
* @param strokeLength Total length of a stroke sequence
* @param consumer The consumer to notify of the path segments.
*/
ClampingPathVisitor(MapViewRectangle clip, double strokeOffset, double strokeLength, PathSegmentConsumer consumer) {
this.clip = clip;
this.strokeProgress = Math.min(strokeLength - strokeOffset, 0);
this.strokeLength = strokeLength;
this.consumer = consumer;
}
@Override
void visitMoveTo(MapViewPoint point) {
cursor = point;
cursorIsActive = false;
}
@Override
void visitLineTo(MapViewPoint next) {
MapViewPoint entry = clip.getLineEntry(cursor, next);
if (entry != null) {
MapViewPoint exit = clip.getLineEntry(next, cursor);
if (!cursorIsActive || !entry.equals(cursor)) {
entry = alignStrokeOffset(entry, cursor);
}
consumer.addLineBetween(strokeProgress + cursor.distanceToInView(entry), entry, exit, cursorIsActive);
cursorIsActive = exit.equals(next);
}
strokeProgress += cursor.distanceToInView(next);
cursor = next;
}
private MapViewPoint alignStrokeOffset(MapViewPoint entry, MapViewPoint originalStart) {
double distanceSq = entry.distanceToInViewSq(originalStart);
if (distanceSq < 0.01 || strokeLength <= 0.001) {
// don't move if there is nothing to move.
return entry;
}
double distance = Math.sqrt(distanceSq);
double offset = (strokeProgress + distance) % strokeLength;
if (offset < 0.01) {
return entry;
}
return entry.interpolate(originalStart, offset / distance);
}
}
}