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        for (ILatLon n : nodes) {
167            if (useMoveTo) {
168                moveTo(n);
169            } else {
170                lineTo(n);
171            }
172            useMoveTo = false;
173        }
174        if (close) {
175            closePath();
176        }
177    }
178
179    /**
180     * Converts a path in east/north coordinates to view space.
181     * @param path The path
182     * @since 11748
183     */
184    public void appendFromEastNorth(Path2D.Double path) {
185        new PathVisitor() {
186            @Override
187            public void visitMoveTo(double x, double y) {
188                moveTo(new EastNorth(x, y));
189            }
190
191            @Override
192            public void visitLineTo(double x, double y) {
193                lineTo(new EastNorth(x, y));
194            }
195
196            @Override
197            public void visitClose() {
198                closePath();
199            }
200        }.visit(path);
201    }
202
203    /**
204     * Visits all segments of this path.
205     * @param consumer The consumer to send path segments to
206     * @return the total line length
207     * @since 11748
208     */
209    public double visitLine(PathSegmentConsumer consumer) {
210        LineVisitor visitor = new LineVisitor(consumer);
211        visitor.visit(this);
212        return visitor.inLineOffset;
213    }
214
215    /**
216     * Compute a line that is similar to the current path expect for that parts outside the screen are skipped using moveTo commands.
217     *
218     * The line is computed in a way that dashes stay in their place when moving the view.
219     *
220     * The resulting line is not intended to fill areas.
221     * @param stroke The stroke to compute the line for.
222     * @return The new line shape.
223     * @since 11147
224     */
225    public Shape computeClippedLine(Stroke stroke) {
226        MapPath2D clamped = new MapPath2D();
227        if (visitClippedLine(stroke, (inLineOffset, start, end, startIsOldEnd) -> {
228            if (!startIsOldEnd) {
229                clamped.moveTo(start);
230            }
231            clamped.lineTo(end);
232        })) {
233            return clamped;
234        } else {
235            // could not clip the path.
236            return this;
237        }
238    }
239
240    /**
241     * Visits all straight segments of this path. The segments are clamped to the view.
242     * If they are clamped, the start points are aligned with the pattern.
243     * @param stroke The stroke to take the dash information from.
244     * @param consumer The consumer to call for each segment
245     * @return false if visiting the path failed because there e.g. were non-straight segments.
246     * @since 11147
247     */
248    public boolean visitClippedLine(Stroke stroke, PathSegmentConsumer consumer) {
249        if (stroke instanceof BasicStroke && ((BasicStroke) stroke).getDashArray() != null) {
250            float length = 0;
251            for (float f : ((BasicStroke) stroke).getDashArray()) {
252                length += f;
253            }
254            return visitClippedLine(length, consumer);
255        } else {
256            return visitClippedLine(0, consumer);
257        }
258    }
259
260    /**
261     * Visits all straight segments of this path. The segments are clamped to the view.
262     * If they are clamped, the start points are aligned with the pattern.
263     * @param strokeLength The dash pattern length. 0 to use no pattern. Only segments of this length will be removed from the line.
264     * @param consumer The consumer to call for each segment
265     * @return false if visiting the path failed because there e.g. were non-straight segments.
266     * @since 11147
267     */
268    public boolean visitClippedLine(double strokeLength, PathSegmentConsumer consumer) {
269        return new ClampingPathVisitor(state.getViewClipRectangle(), strokeLength, consumer)
270            .visit(this);
271    }
272
273    /**
274     * Gets the length of the way in visual space.
275     * @return The length.
276     * @since 11748
277     */
278    public double getLength() {
279        return visitLine((inLineOffset, start, end, startIsOldEnd) -> { });
280    }
281
282    /**
283     * Create a new {@link MapViewPath} that is the same as the current one except that it is offset in the view.
284     * @param viewOffset The offset in view pixels
285     * @return The new path
286     * @since 12505
287     */
288    public MapViewPath offset(double viewOffset) {
289        OffsetPathVisitor visitor = new OffsetPathVisitor(state, viewOffset);
290        visitor.visit(this);
291        return visitor.getPath();
292    }
293
294    /**
295     * This class is used to visit the segments of this path.
296     * @author Michael Zangl
297     * @since 11147
298     */
299    @FunctionalInterface
300    public interface PathSegmentConsumer {
301
302        /**
303         * Add a line segment between two points
304         * @param inLineOffset The offset of start in the line
305         * @param start The start point
306         * @param end The end point
307         * @param startIsOldEnd If the start point equals the last end point.
308         */
309        void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd);
310    }
311
312    private interface PathVisitor {
313        /**
314         * Append a path to this one. The path is clipped to the current view.
315         * @param path The iterator
316         * @return true if adding the path was successful.
317         */
318        default boolean visit(Path2D.Double path) {
319            double[] coords = new double[8];
320            PathIterator it = path.getPathIterator(null);
321            while (!it.isDone()) {
322                int type = it.currentSegment(coords);
323                switch (type) {
324                case PathIterator.SEG_CLOSE:
325                    visitClose();
326                    break;
327                case PathIterator.SEG_LINETO:
328                    visitLineTo(coords[0], coords[1]);
329                    break;
330                case PathIterator.SEG_MOVETO:
331                    visitMoveTo(coords[0], coords[1]);
332                    break;
333                default:
334                    // cannot handle this shape - this should be very rare and not happening in OSM draw code.
335                    return false;
336                }
337                it.next();
338            }
339            return true;
340        }
341
342        void visitClose();
343
344        void visitMoveTo(double x, double y);
345
346        void visitLineTo(double x, double y);
347    }
348
349    private abstract class AbstractMapPathVisitor implements PathVisitor {
350        private MapViewPoint lastMoveTo;
351
352        @Override
353        public void visitMoveTo(double x, double y) {
354            MapViewPoint move = state.getForView(x, y);
355            lastMoveTo = move;
356            visitMoveTo(move);
357        }
358
359        abstract void visitMoveTo(MapViewPoint p);
360
361        @Override
362        public void visitLineTo(double x, double y) {
363            visitLineTo(state.getForView(x, y));
364        }
365
366        abstract void visitLineTo(MapViewPoint p);
367
368        @Override
369        public void visitClose() {
370            visitLineTo(lastMoveTo);
371        }
372    }
373
374    private final class LineVisitor extends AbstractMapPathVisitor {
375        private final PathSegmentConsumer consumer;
376        private MapViewPoint last;
377        private double inLineOffset;
378        private boolean startIsOldEnd;
379
380        LineVisitor(PathSegmentConsumer consumer) {
381            this.consumer = consumer;
382        }
383
384        @Override
385        void visitMoveTo(MapViewPoint p) {
386            last = p;
387            startIsOldEnd = false;
388        }
389
390        @Override
391        void visitLineTo(MapViewPoint p) {
392            consumer.addLineBetween(inLineOffset, last, p, startIsOldEnd);
393            inLineOffset += last.distanceToInView(p);
394            last = p;
395            startIsOldEnd = true;
396        }
397    }
398
399    private class ClampingPathVisitor extends AbstractMapPathVisitor {
400        private final MapViewRectangle clip;
401        private final PathSegmentConsumer consumer;
402        protected double strokeProgress;
403        private final double strokeLength;
404
405        private MapViewPoint cursor;
406        private boolean cursorIsActive;
407
408        /**
409         * Create a new {@link ClampingPathVisitor}
410         * @param clip View clip rectangle
411         * @param strokeLength Total length of a stroke sequence
412         * @param consumer The consumer to notify of the path segments.
413         */
414        ClampingPathVisitor(MapViewRectangle clip, double strokeLength, PathSegmentConsumer consumer) {
415            this.clip = clip;
416            this.strokeLength = strokeLength;
417            this.consumer = consumer;
418        }
419
420        @Override
421        void visitMoveTo(MapViewPoint point) {
422            cursor = point;
423            cursorIsActive = false;
424        }
425
426        @Override
427        void visitLineTo(MapViewPoint next) {
428            MapViewPoint entry = clip.getLineEntry(cursor, next);
429            if (entry != null) {
430                MapViewPoint exit = clip.getLineEntry(next, cursor);
431                if (!cursorIsActive || !entry.equals(cursor)) {
432                    entry = alignStrokeOffset(entry, cursor);
433                }
434                consumer.addLineBetween(strokeProgress + cursor.distanceToInView(entry), entry, exit, cursorIsActive);
435                cursorIsActive = exit.equals(next);
436            }
437            strokeProgress += cursor.distanceToInView(next);
438
439            cursor = next;
440        }
441
442        private MapViewPoint alignStrokeOffset(MapViewPoint entry, MapViewPoint originalStart) {
443            double distanceSq = entry.distanceToInViewSq(originalStart);
444            if (distanceSq < 0.01 || strokeLength <= 0.001) {
445                // don't move if there is nothing to move.
446                return entry;
447            }
448
449            double distance = Math.sqrt(distanceSq);
450            double offset = (strokeProgress + distance) % strokeLength;
451            if (offset < 0.01) {
452                return entry;
453            }
454
455            return entry.interpolate(originalStart, offset / distance);
456        }
457    }
458
459    private class OffsetPathVisitor extends AbstractMapPathVisitor {
460        private final MapViewPath collector;
461        private final ArrayList<MapViewPoint> points = new ArrayList<>();
462        private final double offset;
463
464        OffsetPathVisitor(MapViewState state, double offset) {
465            this.collector = new MapViewPath(state);
466            this.offset = offset;
467        }
468
469        @Override
470        void visitMoveTo(MapViewPoint p) {
471            finishLineSegment();
472            points.add(p);
473        }
474
475        @Override
476        void visitLineTo(MapViewPoint p) {
477            points.add(p);
478        }
479
480        MapViewPath getPath() {
481            finishLineSegment();
482            return collector;
483        }
484
485        private void finishLineSegment() {
486            if (points.size() > 2) {
487                OffsetIterator iterator = new OffsetIterator(points, offset);
488                collector.moveTo(iterator.next());
489                while (iterator.hasNext()) {
490                    collector.lineTo(iterator.next());
491                }
492                points.clear();
493            }
494        }
495    }
496}