001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.draw;
003
004import java.awt.BasicStroke;
005import java.awt.Shape;
006import java.awt.Stroke;
007import java.awt.geom.Path2D;
008import java.awt.geom.PathIterator;
009import java.util.ArrayList;
010
011import org.openstreetmap.josm.data.coor.EastNorth;
012import org.openstreetmap.josm.data.coor.ILatLon;
013import org.openstreetmap.josm.data.osm.visitor.paint.OffsetIterator;
014import org.openstreetmap.josm.gui.MapView;
015import org.openstreetmap.josm.gui.MapViewState;
016import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
017import org.openstreetmap.josm.gui.MapViewState.MapViewRectangle;
018
019/**
020 * 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.
021 * <p>
022 * 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
023 * performance if the zoom level is high and the part outside the view is long. See {@link #computeClippedLine(Stroke)}.
024 * @author Michael Zangl
025 * @since 10875
026 */
027public class MapViewPath extends MapPath2D {
028
029    private final MapViewState state;
030
031    /**
032     * Create a new path
033     * @param mv The map view to use for coordinate conversion.
034     */
035    public MapViewPath(MapView mv) {
036        this(mv.getState());
037    }
038
039    /**
040     * Create a new path
041     * @param state The state to use for coordinate conversion.
042     */
043    public MapViewPath(MapViewState state) {
044        this.state = state;
045    }
046
047    /**
048     * Gets the map view state this path is used for.
049     * @return The state.
050     * @since 11748
051     */
052    public MapViewState getMapViewState() {
053        return state;
054    }
055
056    /**
057     * Move the cursor to the given node.
058     * @param n The node
059     * @return this for easy chaining.
060     */
061    public MapViewPath moveTo(ILatLon n) {
062        moveTo(n.getEastNorth(state.getProjecting()));
063        return this;
064    }
065
066    /**
067     * Move the cursor to the given position.
068     * @param eastNorth The position
069     * @return this for easy chaining.
070     */
071    public MapViewPath moveTo(EastNorth eastNorth) {
072        moveTo(state.getPointFor(eastNorth));
073        return this;
074    }
075
076    @Override
077    public MapViewPath moveTo(MapViewPoint p) {
078        super.moveTo(p);
079        return this;
080    }
081
082    /**
083     * Draw a line to the node.
084     * <p>
085     * line clamping to view is done automatically.
086     * @param n The node
087     * @return this for easy chaining.
088     */
089    public MapViewPath lineTo(ILatLon n) {
090        lineTo(n.getEastNorth(state.getProjecting()));
091        return this;
092    }
093
094    /**
095     * Draw a line to the position.
096     * <p>
097     * line clamping to view is done automatically.
098     * @param eastNorth The position
099     * @return this for easy chaining.
100     */
101    public MapViewPath lineTo(EastNorth eastNorth) {
102        lineTo(state.getPointFor(eastNorth));
103        return this;
104    }
105
106    @Override
107    public MapViewPath lineTo(MapViewPoint p) {
108        super.lineTo(p);
109        return this;
110    }
111
112    /**
113     * Add the given shape centered around the current node.
114     * @param p1 The point to draw around
115     * @param symbol The symbol type
116     * @param size The size of the symbol in pixel
117     * @return this for easy chaining.
118     */
119    public MapViewPath shapeAround(ILatLon p1, SymbolShape symbol, double size) {
120        shapeAround(p1.getEastNorth(state.getProjecting()), symbol, size);
121        return this;
122    }
123
124    /**
125     * Add the given shape centered around the current position.
126     * @param eastNorth The point to draw around
127     * @param symbol The symbol type
128     * @param size The size of the symbol in pixel
129     * @return this for easy chaining.
130     */
131    public MapViewPath shapeAround(EastNorth eastNorth, SymbolShape symbol, double size) {
132        shapeAround(state.getPointFor(eastNorth), symbol, size);
133        return this;
134    }
135
136    @Override
137    public MapViewPath shapeAround(MapViewPoint p, SymbolShape symbol, double size) {
138        super.shapeAround(p, symbol, size);
139        return this;
140    }
141
142    /**
143     * Append a list of nodes
144     * @param nodes The nodes to append
145     * @param connect <code>true</code> if we should use a lineTo as first command.
146     * @return this for easy chaining.
147     */
148    public MapViewPath append(Iterable<? extends ILatLon> nodes, boolean connect) {
149        appendWay(nodes, connect, false);
150        return this;
151    }
152
153    /**
154     * Append a list of nodes as closed way.
155     * @param nodes The nodes to append
156     * @param connect <code>true</code> if we should use a lineTo as first command.
157     * @return this for easy chaining.
158     */
159    public MapViewPath appendClosed(Iterable<? extends ILatLon> nodes, boolean connect) {
160        appendWay(nodes, connect, true);
161        return this;
162    }
163
164    private void appendWay(Iterable<? extends ILatLon> nodes, boolean connect, boolean close) {
165        boolean useMoveTo = !connect;
166        ILatLon first = null;
167        for (ILatLon n : nodes) {
168            if (useMoveTo) {
169                moveTo(n);
170            } else {
171                lineTo(n);
172            }
173            if (close && first == null) {
174                first = n;
175            }
176            useMoveTo = false;
177        }
178        if (first != null) {
179            lineTo(first);
180        }
181    }
182
183    /**
184     * Converts a path in east/north coordinates to view space.
185     * @param path The path
186     * @since 11748
187     */
188    public void appendFromEastNorth(Path2D.Double path) {
189        new PathVisitor() {
190            @Override
191            public void visitMoveTo(double x, double y) {
192                moveTo(new EastNorth(x, y));
193            }
194
195            @Override
196            public void visitLineTo(double x, double y) {
197                lineTo(new EastNorth(x, y));
198            }
199
200            @Override
201            public void visitClose() {
202                closePath();
203            }
204        }.visit(path);
205    }
206
207    /**
208     * Visits all segments of this path.
209     * @param consumer The consumer to send path segments to
210     * @return the total line length
211     * @since 11748
212     */
213    public double visitLine(PathSegmentConsumer consumer) {
214        LineVisitor visitor = new LineVisitor(consumer);
215        visitor.visit(this);
216        return visitor.inLineOffset;
217    }
218
219    /**
220     * Compute a line that is similar to the current path expect for that parts outside the screen are skipped using moveTo commands.
221     *
222     * The line is computed in a way that dashes stay in their place when moving the view.
223     *
224     * The resulting line is not intended to fill areas.
225     * @param stroke The stroke to compute the line for.
226     * @return The new line shape.
227     * @since 11147
228     */
229    public Shape computeClippedLine(Stroke stroke) {
230        MapPath2D clamped = new MapPath2D();
231        if (visitClippedLine(stroke, (inLineOffset, start, end, startIsOldEnd) -> {
232            if (!startIsOldEnd) {
233                clamped.moveTo(start);
234            }
235            clamped.lineTo(end);
236        })) {
237            return clamped;
238        } else {
239            // could not clip the path.
240            return this;
241        }
242    }
243
244    /**
245     * Visits all straight segments of this path. The segments are clamped to the view.
246     * If they are clamped, the start points are aligned with the pattern.
247     * @param stroke The stroke to take the dash information from.
248     * @param consumer The consumer to call for each segment
249     * @return false if visiting the path failed because there e.g. were non-straight segments.
250     * @since 11147
251     */
252    public boolean visitClippedLine(Stroke stroke, PathSegmentConsumer consumer) {
253        if (stroke instanceof BasicStroke && ((BasicStroke) stroke).getDashArray() != null) {
254            float length = 0;
255            for (float f : ((BasicStroke) stroke).getDashArray()) {
256                length += f;
257            }
258            return visitClippedLine(length, consumer);
259        } else {
260            return visitClippedLine(0, consumer);
261        }
262    }
263
264    /**
265     * Visits all straight segments of this path. The segments are clamped to the view.
266     * If they are clamped, the start points are aligned with the pattern.
267     * @param strokeLength The dash pattern length. 0 to use no pattern. Only segments of this length will be removed from the line.
268     * @param consumer The consumer to call for each segment
269     * @return false if visiting the path failed because there e.g. were non-straight segments.
270     * @since 11147
271     */
272    public boolean visitClippedLine(double strokeLength, PathSegmentConsumer consumer) {
273        return new ClampingPathVisitor(state.getViewClipRectangle(), strokeLength, consumer)
274            .visit(this);
275    }
276
277    /**
278     * Gets the length of the way in visual space.
279     * @return The length.
280     * @since 11748
281     */
282    public double getLength() {
283        return visitLine((inLineOffset, start, end, startIsOldEnd) -> { });
284    }
285
286    /**
287     * Create a new {@link MapViewPath} that is the same as the current one except that it is offset in the view.
288     * @param viewOffset The offset in view pixels
289     * @return The new path
290     * @since 12505
291     */
292    public MapViewPath offset(double viewOffset) {
293        OffsetPathVisitor visitor = new OffsetPathVisitor(state, viewOffset);
294        visitor.visit(this);
295        return visitor.getPath();
296    }
297
298    /**
299     * This class is used to visit the segments of this path.
300     * @author Michael Zangl
301     * @since 11147
302     */
303    @FunctionalInterface
304    public interface PathSegmentConsumer {
305
306        /**
307         * Add a line segment between two points
308         * @param inLineOffset The offset of start in the line
309         * @param start The start point
310         * @param end The end point
311         * @param startIsOldEnd If the start point equals the last end point.
312         */
313        void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd);
314    }
315
316    private interface PathVisitor {
317        /**
318         * Append a path to this one. The path is clipped to the current view.
319         * @param path The iterator
320         * @return true if adding the path was successful.
321         */
322        default boolean visit(Path2D.Double path) {
323            double[] coords = new double[8];
324            PathIterator it = path.getPathIterator(null);
325            while (!it.isDone()) {
326                int type = it.currentSegment(coords);
327                switch (type) {
328                case PathIterator.SEG_CLOSE:
329                    visitClose();
330                    break;
331                case PathIterator.SEG_LINETO:
332                    visitLineTo(coords[0], coords[1]);
333                    break;
334                case PathIterator.SEG_MOVETO:
335                    visitMoveTo(coords[0], coords[1]);
336                    break;
337                default:
338                    // cannot handle this shape - this should be very rare and not happening in OSM draw code.
339                    return false;
340                }
341                it.next();
342            }
343            return true;
344        }
345
346        void visitClose();
347
348        void visitMoveTo(double x, double y);
349
350        void visitLineTo(double x, double y);
351    }
352
353    private abstract class AbstractMapPathVisitor implements PathVisitor {
354        private MapViewPoint lastMoveTo;
355
356        @Override
357        public void visitMoveTo(double x, double y) {
358            MapViewPoint move = state.getForView(x, y);
359            lastMoveTo = move;
360            visitMoveTo(move);
361        }
362
363        abstract void visitMoveTo(MapViewPoint p);
364
365        @Override
366        public void visitLineTo(double x, double y) {
367            visitLineTo(state.getForView(x, y));
368        }
369
370        abstract void visitLineTo(MapViewPoint p);
371
372        @Override
373        public void visitClose() {
374            visitLineTo(lastMoveTo);
375        }
376    }
377
378    private final class LineVisitor extends AbstractMapPathVisitor {
379        private final PathSegmentConsumer consumer;
380        private MapViewPoint last;
381        private double inLineOffset;
382        private boolean startIsOldEnd;
383
384        LineVisitor(PathSegmentConsumer consumer) {
385            this.consumer = consumer;
386        }
387
388        @Override
389        void visitMoveTo(MapViewPoint p) {
390            last = p;
391            startIsOldEnd = false;
392        }
393
394        @Override
395        void visitLineTo(MapViewPoint p) {
396            consumer.addLineBetween(inLineOffset, last, p, startIsOldEnd);
397            inLineOffset += last.distanceToInView(p);
398            last = p;
399            startIsOldEnd = true;
400        }
401    }
402
403    private class ClampingPathVisitor extends AbstractMapPathVisitor {
404        private final MapViewRectangle clip;
405        private final PathSegmentConsumer consumer;
406        protected double strokeProgress;
407        private final double strokeLength;
408
409        private MapViewPoint cursor;
410        private boolean cursorIsActive;
411
412        /**
413         * Create a new {@link ClampingPathVisitor}
414         * @param clip View clip rectangle
415         * @param strokeLength Total length of a stroke sequence
416         * @param consumer The consumer to notify of the path segments.
417         */
418        ClampingPathVisitor(MapViewRectangle clip, double strokeLength, PathSegmentConsumer consumer) {
419            this.clip = clip;
420            this.strokeLength = strokeLength;
421            this.consumer = consumer;
422        }
423
424        @Override
425        void visitMoveTo(MapViewPoint point) {
426            cursor = point;
427            cursorIsActive = false;
428        }
429
430        @Override
431        void visitLineTo(MapViewPoint next) {
432            MapViewPoint entry = clip.getLineEntry(cursor, next);
433            if (entry != null) {
434                MapViewPoint exit = clip.getLineEntry(next, cursor);
435                if (!cursorIsActive || !entry.equals(cursor)) {
436                    entry = alignStrokeOffset(entry, cursor);
437                }
438                consumer.addLineBetween(strokeProgress + cursor.distanceToInView(entry), entry, exit, cursorIsActive);
439                cursorIsActive = exit.equals(next);
440            }
441            strokeProgress += cursor.distanceToInView(next);
442
443            cursor = next;
444        }
445
446        private MapViewPoint alignStrokeOffset(MapViewPoint entry, MapViewPoint originalStart) {
447            double distanceSq = entry.distanceToInViewSq(originalStart);
448            if (distanceSq < 0.01 || strokeLength <= 0.001) {
449                // don't move if there is nothing to move.
450                return entry;
451            }
452
453            double distance = Math.sqrt(distanceSq);
454            double offset = (strokeProgress + distance) % strokeLength;
455            if (offset < 0.01) {
456                return entry;
457            }
458
459            return entry.interpolate(originalStart, offset / distance);
460        }
461    }
462
463    private class OffsetPathVisitor extends AbstractMapPathVisitor {
464        private final MapViewPath collector;
465        private final ArrayList<MapViewPoint> points = new ArrayList<>();
466        private final double offset;
467
468        OffsetPathVisitor(MapViewState state, double offset) {
469            this.collector = new MapViewPath(state);
470            this.offset = offset;
471        }
472
473        @Override
474        void visitMoveTo(MapViewPoint p) {
475            finishLineSegment();
476            points.add(p);
477        }
478
479        @Override
480        void visitLineTo(MapViewPoint p) {
481            points.add(p);
482        }
483
484        MapViewPath getPath() {
485            finishLineSegment();
486            return collector;
487        }
488
489        private void finishLineSegment() {
490            if (points.size() > 2) {
491                OffsetIterator iterator = new OffsetIterator(points, offset);
492                collector.moveTo(iterator.next());
493                while (iterator.hasNext()) {
494                    collector.lineTo(iterator.next());
495                }
496                points.clear();
497            }
498        }
499    }
500}