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.PathIterator;
008
009import org.openstreetmap.josm.data.coor.EastNorth;
010import org.openstreetmap.josm.data.osm.Node;
011import org.openstreetmap.josm.gui.MapView;
012import org.openstreetmap.josm.gui.MapViewState;
013import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
014import org.openstreetmap.josm.gui.MapViewState.MapViewRectangle;
015
016
017/**
018 * 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.
019 * <p>
020 * 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
021 * performance if the zoom level is high and the part outside the view is long. See {@link #computeClippedLine(Stroke)}.
022 * @author Michael Zangl
023 * @since 10875
024 */
025public class MapViewPath extends MapPath2D {
026
027    private final MapViewState state;
028
029    /**
030     * Create a new path
031     * @param mv The map view to use for coordinate conversion.
032     */
033    public MapViewPath(MapView mv) {
034        this(mv.getState());
035    }
036
037    /**
038     * Create a new path
039     * @param state The state to use for coordinate conversion.
040     */
041    public MapViewPath(MapViewState state) {
042        this.state = state;
043    }
044
045    /**
046     * Move the cursor to the given node.
047     * @param n The node
048     * @return this for easy chaining.
049     */
050    public MapViewPath moveTo(Node n) {
051        moveTo(n.getEastNorth());
052        return this;
053    }
054
055    /**
056     * Move the cursor to the given position.
057     * @param eastNorth The position
058     * @return this for easy chaining.
059     */
060    public MapViewPath moveTo(EastNorth eastNorth) {
061        moveTo(state.getPointFor(eastNorth));
062        return this;
063    }
064
065    @Override
066    public MapViewPath moveTo(MapViewPoint p) {
067        super.moveTo(p);
068        return this;
069    }
070
071    /**
072     * Draw a line to the node.
073     * <p>
074     * line clamping to view is done automatically.
075     * @param n The node
076     * @return this for easy chaining.
077     */
078    public MapViewPath lineTo(Node n) {
079        lineTo(n.getEastNorth());
080        return this;
081    }
082
083    /**
084     * Draw a line to the position.
085     * <p>
086     * line clamping to view is done automatically.
087     * @param eastNorth The position
088     * @return this for easy chaining.
089     */
090    public MapViewPath lineTo(EastNorth eastNorth) {
091        lineTo(state.getPointFor(eastNorth));
092        return this;
093    }
094
095    @Override
096    public MapViewPath lineTo(MapViewPoint p) {
097        super.lineTo(p);
098        return this;
099    }
100
101    /**
102     * Add the given shape centered around the current node.
103     * @param p1 The point to draw around
104     * @param symbol The symbol type
105     * @param size The size of the symbol in pixel
106     * @return this for easy chaining.
107     */
108    public MapViewPath shapeAround(Node p1, SymbolShape symbol, double size) {
109        shapeAround(p1.getEastNorth(), symbol, size);
110        return this;
111    }
112
113    /**
114     * Add the given shape centered around the current position.
115     * @param eastNorth The point to draw around
116     * @param symbol The symbol type
117     * @param size The size of the symbol in pixel
118     * @return this for easy chaining.
119     */
120    public MapViewPath shapeAround(EastNorth eastNorth, SymbolShape symbol, double size) {
121        shapeAround(state.getPointFor(eastNorth), symbol, size);
122        return this;
123    }
124
125    @Override
126    public MapViewPath shapeAround(MapViewPoint p, SymbolShape symbol, double size) {
127        super.shapeAround(p, symbol, size);
128        return this;
129    }
130
131    /**
132     * Append a list of nodes
133     * @param nodes The nodes to append
134     * @param connect <code>true</code> if we should use a lineTo as first command.
135     * @return this for easy chaining.
136     */
137    public MapViewPath append(Iterable<Node> nodes, boolean connect) {
138        appendWay(nodes, connect, false);
139        return this;
140    }
141
142    /**
143     * Append a list of nodes as closed way.
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 appendClosed(Iterable<Node> nodes, boolean connect) {
149        appendWay(nodes, connect, true);
150        return this;
151    }
152
153    private void appendWay(Iterable<Node> nodes, boolean connect, boolean close) {
154        boolean useMoveTo = !connect;
155        Node first = null;
156        for (Node n : nodes) {
157            if (useMoveTo) {
158                moveTo(n);
159            } else {
160                lineTo(n);
161            }
162            if (close && first == null) {
163                first = n;
164            }
165            useMoveTo = false;
166        }
167        if (first != null) {
168            lineTo(first);
169        }
170    }
171
172    /**
173     * Compute a line that is similar to the current path expect for that parts outside the screen are skipped using moveTo commands.
174     *
175     * The line is computed in a way that dashes stay in their place when moving the view.
176     *
177     * The resulting line is not intended to fill areas.
178     * @param stroke The stroke to compute the line for.
179     * @return The new line shape.
180     * @since 11147
181     */
182    public Shape computeClippedLine(Stroke stroke) {
183        MapPath2D clamped = new MapPath2D();
184        if (visitClippedLine(stroke, (inLineOffset, start, end, startIsOldEnd) -> {
185            if (!startIsOldEnd) {
186                clamped.moveTo(start);
187            }
188            clamped.lineTo(end);
189        })) {
190            return clamped;
191        } else {
192            // could not clip the path.
193            return this;
194        }
195    }
196
197    /**
198     * Visits all straight segments of this path. The segments are clamped to the view.
199     * If they are clamped, the start points are aligned with the pattern.
200     * @param stroke The stroke to take the dash information from.
201     * @param consumer The consumer to call for each segment
202     * @return false if visiting the path failed because there e.g. were non-straight segments.
203     * @since 11147
204     */
205    public boolean visitClippedLine(Stroke stroke, PathSegmentConsumer consumer) {
206        if (stroke instanceof BasicStroke && ((BasicStroke) stroke).getDashArray() != null) {
207            float length = 0;
208            for (float f : ((BasicStroke) stroke).getDashArray()) {
209                length += f;
210            }
211            return visitClippedLine(((BasicStroke) stroke).getDashPhase(), length, consumer);
212        } else {
213            return visitClippedLine(0, 0, consumer);
214        }
215    }
216
217    /**
218     * Visits all straight segments of this path. The segments are clamped to the view.
219     * If they are clamped, the start points are aligned with the pattern.
220     * @param strokeOffset The initial offset of the pattern
221     * @param strokeLength The dash pattern length. 0 to use no pattern.
222     * @param consumer The consumer to call for each segment
223     * @return false if visiting the path failed because there e.g. were non-straight segments.
224     * @since 11147
225     */
226    public boolean visitClippedLine(double strokeOffset, double strokeLength, PathSegmentConsumer consumer) {
227        return new ClampingPathVisitor(state.getViewClipRectangle(), strokeOffset, strokeLength, consumer)
228            .visit(this);
229    }
230
231
232    /**
233     * This class is used to visit the segments of this path.
234     * @author Michael Zangl
235     * @since 11147
236     */
237    @FunctionalInterface
238    public interface PathSegmentConsumer {
239
240        /**
241         * Add a line segment between two points
242         * @param inLineOffset The offset of start in the line
243         * @param start The start point
244         * @param end The end point
245         * @param startIsOldEnd If the start point equals the last end point.
246         */
247        void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd);
248
249    }
250
251    private class ClampingPathVisitor {
252        private final MapViewRectangle clip;
253        private final PathSegmentConsumer consumer;
254        protected double strokeProgress;
255        private final double strokeLength;
256        private MapViewPoint lastMoveTo;
257
258        private MapViewPoint cursor;
259        private boolean cursorIsActive;
260
261        /**
262         * Create a new {@link ClampingPathVisitor}
263         * @param clip View clip rectangle
264         * @param strokeOffset Initial stroke offset
265         * @param strokeLength Total length of a stroke sequence
266         * @param consumer The consumer to notify of the path segments.
267         */
268        ClampingPathVisitor(MapViewRectangle clip, double strokeOffset, double strokeLength, PathSegmentConsumer consumer) {
269            this.clip = clip;
270            this.strokeProgress = Math.min(strokeLength - strokeOffset, 0);
271            this.strokeLength = strokeLength;
272            this.consumer = consumer;
273        }
274
275        /**
276         * Append a path to this one. The path is clipped to the current view.
277         * @param mapViewPath The iterator
278         * @return true if adding the path was successful.
279         */
280        public boolean visit(MapViewPath mapViewPath) {
281            double[] coords = new double[8];
282            PathIterator it = mapViewPath.getPathIterator(null);
283            while (!it.isDone()) {
284                int type = it.currentSegment(coords);
285                switch (type) {
286                case PathIterator.SEG_CLOSE:
287                    visitClose();
288                    break;
289                case PathIterator.SEG_LINETO:
290                    visitLineTo(coords[0], coords[1]);
291                    break;
292                case PathIterator.SEG_MOVETO:
293                    visitMoveTo(coords[0], coords[1]);
294                    break;
295                default:
296                    // cannot handle this shape - this should be very rare. We let Java2D do the clipping.
297                    return false;
298                }
299                it.next();
300            }
301            return true;
302        }
303
304        void visitClose() {
305            drawLineTo(lastMoveTo);
306        }
307
308        void visitMoveTo(double x, double y) {
309            MapViewPoint point = state.getForView(x, y);
310            lastMoveTo = point;
311            cursor = point;
312            cursorIsActive = false;
313        }
314
315        void visitLineTo(double x, double y) {
316            drawLineTo(state.getForView(x, y));
317        }
318
319        private void drawLineTo(MapViewPoint next) {
320            MapViewPoint entry = clip.getLineEntry(cursor, next);
321            if (entry != null) {
322                MapViewPoint exit = clip.getLineEntry(next, cursor);
323                if (!cursorIsActive || !entry.equals(cursor)) {
324                    entry = alignStrokeOffset(entry, cursor);
325                }
326                consumer.addLineBetween(strokeProgress + cursor.distanceToInView(entry), entry, exit, cursorIsActive);
327                cursorIsActive = exit.equals(next);
328            }
329            strokeProgress += cursor.distanceToInView(next);
330
331            cursor = next;
332        }
333
334        private MapViewPoint alignStrokeOffset(MapViewPoint entry, MapViewPoint originalStart) {
335            double distanceSq = entry.distanceToInViewSq(originalStart);
336            if (distanceSq < 0.01 || strokeLength <= 0.001) {
337                // don't move if there is nothing to move.
338                return entry;
339            }
340
341            double distance = Math.sqrt(distanceSq);
342            double offset = (strokeProgress + distance) % strokeLength;
343            if (offset < 0.01) {
344                return entry;
345            }
346
347            return entry.interpolate(originalStart, offset / distance);
348        }
349    }
350}