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    public interface PathSegmentConsumer {
238
239        /**
240         * Add a line segment between two points
241         * @param inLineOffset The offset of start in the line
242         * @param start The start point
243         * @param end The end point
244         * @param startIsOldEnd If the start point equals the last end point.
245         */
246        void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd);
247
248    }
249
250    private class ClampingPathVisitor {
251        private final MapViewRectangle clip;
252        private final PathSegmentConsumer consumer;
253        protected double strokeProgress;
254        private final double strokeLength;
255        private MapViewPoint lastMoveTo;
256
257        private MapViewPoint cursor;
258        private boolean cursorIsActive = false;
259
260        /**
261         * Create a new {@link ClampingPathVisitor}
262         * @param clip View clip rectangle
263         * @param strokeOffset Initial stroke offset
264         * @param strokeLength Total length of a stroke sequence
265         * @param consumer The consumer to notify of the path segments.
266         */
267        ClampingPathVisitor(MapViewRectangle clip, double strokeOffset, double strokeLength, PathSegmentConsumer consumer) {
268            this.clip = clip;
269            this.strokeProgress = Math.min(strokeLength - strokeOffset, 0);
270            this.strokeLength = strokeLength;
271            this.consumer = consumer;
272        }
273
274        /**
275         * Append a path to this one. The path is clipped to the current view.
276         * @param mapViewPath The iterator
277         * @return true if adding the path was successful.
278         */
279        public boolean visit(MapViewPath mapViewPath) {
280            double[] coords = new double[8];
281            PathIterator it = mapViewPath.getPathIterator(null);
282            while (!it.isDone()) {
283                int type = it.currentSegment(coords);
284                switch (type) {
285                case PathIterator.SEG_CLOSE:
286                    visitClose();
287                    break;
288                case PathIterator.SEG_LINETO:
289                    visitLineTo(coords[0], coords[1]);
290                    break;
291                case PathIterator.SEG_MOVETO:
292                    visitMoveTo(coords[0], coords[1]);
293                    break;
294                default:
295                    // cannot handle this shape - this should be very rare. We let Java2D do the clipping.
296                    return false;
297                }
298                it.next();
299            }
300            return true;
301        }
302
303        void visitClose() {
304            drawLineTo(lastMoveTo);
305        }
306
307        void visitMoveTo(double x, double y) {
308            MapViewPoint point = state.getForView(x, y);
309            lastMoveTo = point;
310            cursor = point;
311            cursorIsActive = false;
312        }
313
314        void visitLineTo(double x, double y) {
315            drawLineTo(state.getForView(x, y));
316        }
317
318        private void drawLineTo(MapViewPoint next) {
319            MapViewPoint entry = clip.getLineEntry(cursor, next);
320            if (entry != null) {
321                MapViewPoint exit = clip.getLineEntry(next, cursor);
322                if (!cursorIsActive || !entry.equals(cursor)) {
323                    entry = alignStrokeOffset(entry, cursor);
324                }
325                consumer.addLineBetween(strokeProgress + cursor.distanceToInView(entry), entry, exit, cursorIsActive);
326                cursorIsActive = exit.equals(next);
327            }
328            strokeProgress += cursor.distanceToInView(next);
329
330            cursor = next;
331        }
332
333        private MapViewPoint alignStrokeOffset(MapViewPoint entry, MapViewPoint originalStart) {
334            double distanceSq = entry.distanceToInViewSq(originalStart);
335            if (distanceSq < 0.01 || strokeLength <= 0.001) {
336                // don't move if there is nothing to move.
337                return entry;
338            }
339
340            double distance = Math.sqrt(distanceSq);
341            double offset = ((strokeProgress + distance)) % strokeLength;
342            if (offset < 0.01) {
343                return entry;
344            }
345
346            return entry.interpolate(originalStart, offset / distance);
347        }
348    }
349}