001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.mappaint.styleelement.placement;
003
004import java.awt.font.GlyphVector;
005import java.awt.geom.AffineTransform;
006import java.awt.geom.Point2D;
007import java.awt.geom.Rectangle2D;
008import java.util.ArrayList;
009import java.util.Collections;
010import java.util.Comparator;
011import java.util.Iterator;
012import java.util.List;
013import java.util.Optional;
014import java.util.stream.IntStream;
015
016import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
017import org.openstreetmap.josm.gui.draw.MapViewPath;
018import org.openstreetmap.josm.gui.draw.MapViewPath.PathSegmentConsumer;
019import org.openstreetmap.josm.gui.draw.MapViewPositionAndRotation;
020
021/**
022 * Places the label onto the line.
023 *
024 * @author Michael Zangl
025 * @since 11722
026 * @since 11748 moved to own file
027 */
028public class OnLineStrategy implements PositionForAreaStrategy {
029    /**
030     * An instance of this class.
031     */
032    public static final OnLineStrategy INSTANCE = new OnLineStrategy(0);
033
034    private final double yOffset;
035
036    /**
037     * Create a new strategy that places the text on the line.
038     * @param yOffset The offset sidewards to the line.
039     */
040    public OnLineStrategy(double yOffset) {
041        this.yOffset = yOffset;
042    }
043
044    @Override
045    public MapViewPositionAndRotation findLabelPlacement(MapViewPath path, Rectangle2D nb) {
046        return findOptimalWayPosition(nb, path).map(best -> {
047            MapViewPoint center = best.start.interpolate(best.end, .5);
048            double theta = upsideTheta(best);
049            MapViewPoint moved = center.getMapViewState().getForView(
050                    center.getInViewX() - Math.sin(theta) * yOffset,
051                    center.getInViewY() + Math.cos(theta) * yOffset);
052            return new MapViewPositionAndRotation(moved, theta);
053        }).orElse(null);
054    }
055
056    private static double upsideTheta(HalfSegment best) {
057        double theta = theta(best.start, best.end);
058        if (theta < -Math.PI / 2) {
059            return theta + Math.PI;
060        } else if (theta > Math.PI / 2) {
061            return theta - Math.PI;
062        } else {
063            return theta;
064        }
065    }
066
067    @Override
068    public boolean supportsGlyphVector() {
069        return true;
070    }
071
072    @Override
073    public List<GlyphVector> generateGlyphVectors(MapViewPath path, Rectangle2D nb, List<GlyphVector> gvs,
074            boolean isDoubleTranslationBug) {
075        // Find the position on the way the font should be placed.
076        // If none is found, use the middle of the way.
077        double middleOffset = findOptimalWayPosition(nb, path).map(segment -> segment.offset)
078                .orElse(path.getLength() / 2);
079
080        // Check that segment of the way. Compute in which direction the text should be rendered.
081        // It is rendered in a way that ensures that at least 50% of the text are rotated with the right side up.
082        UpsideComputingVisitor upside = new UpsideComputingVisitor(middleOffset - nb.getWidth() / 2,
083                middleOffset + nb.getWidth() / 2);
084        path.visitLine(upside);
085        boolean doRotateText = upside.shouldRotateText();
086
087        // Compute the list of glyphs to draw, along with their offset on the current line.
088        List<OffsetGlyph> offsetGlyphs = computeOffsetGlyphs(gvs,
089                middleOffset + (doRotateText ? 1 : -1) * nb.getWidth() / 2, doRotateText);
090
091        // Order the glyphs along the line to ensure that they are drawn corretly.
092        Collections.sort(offsetGlyphs, Comparator.comparing(OffsetGlyph::getOffset));
093
094        // Now translate all glyphs. This will modify the glyphs stored in gvs.
095        path.visitLine(new GlyphRotatingVisitor(offsetGlyphs, isDoubleTranslationBug));
096        return gvs;
097    }
098
099    /**
100     * Create a list of glyphs with an offset along the way
101     * @param gvs The list of glyphs
102     * @param startOffset The offset in the line
103     * @param rotateText Rotate the text by 180°
104     * @return The list of glyphs.
105     */
106    private static List<OffsetGlyph> computeOffsetGlyphs(List<GlyphVector> gvs, double startOffset, boolean rotateText) {
107        double offset = startOffset;
108        ArrayList<OffsetGlyph> offsetGlyphs = new ArrayList<>();
109        for (GlyphVector gv : gvs) {
110            double gvOffset = offset;
111            IntStream.range(0, gv.getNumGlyphs())
112                    .mapToObj(i -> new OffsetGlyph(gvOffset, rotateText, gv, i))
113                    .forEach(offsetGlyphs::add);
114            offset += (rotateText ? -1 : 1) + gv.getLogicalBounds().getBounds2D().getWidth();
115        }
116        return offsetGlyphs;
117    }
118
119    private static Optional<HalfSegment> findOptimalWayPosition(Rectangle2D rect, MapViewPath path) {
120        // find half segments that are long enough to draw text on (don't draw text over the cross hair in the center of each segment)
121        List<HalfSegment> longHalfSegment = new ArrayList<>();
122        double minSegmentLength = 2 * (rect.getWidth() + 4);
123        double length = path.visitLine((inLineOffset, start, end, startIsOldEnd) -> {
124            double segmentLength = start.distanceToInView(end);
125            if (segmentLength > minSegmentLength) {
126                MapViewPoint center = start.interpolate(end, .5);
127                double q = computeQuality(start, center);
128                // prefer the first one for quality equality.
129                longHalfSegment.add(new HalfSegment(start, center, q + .1, inLineOffset + .25 * segmentLength));
130
131                q = computeQuality(center, end);
132                longHalfSegment.add(new HalfSegment(center, end, q, inLineOffset + .75 * segmentLength));
133            }
134        });
135
136        // find the segment with the best quality. If there are several with best quality, the one close to the center is preferred.
137        return longHalfSegment.stream().max(
138                Comparator.comparingDouble(segment -> segment.quality - 1e-5 * Math.abs(segment.offset - length / 2)));
139    }
140
141    private static double computeQuality(MapViewPoint p1, MapViewPoint p2) {
142        double q = 0;
143        if (p1.isInView()) {
144            q += 1;
145        }
146        if (p2.isInView()) {
147            q += 1;
148        }
149        return q;
150    }
151
152    /**
153     * A half segment that can be used to place text on it. Used in the drawTextOnPath algorithm.
154     * @author Michael Zangl
155     */
156    private static class HalfSegment {
157        /**
158         * start point of half segment
159         */
160        private final MapViewPoint start;
161
162        /**
163         * end point of half segment
164         */
165        private final MapViewPoint end;
166
167        /**
168         * quality factor (off screen / partly on screen / fully on screen)
169         */
170        private final double quality;
171
172        /**
173         * The offset in the path.
174         */
175        private final double offset;
176
177        /**
178         * Create a new half segment
179         * @param start The start along the way
180         * @param end The end of the segment
181         * @param quality A quality factor.
182         * @param offset The offset in the path.
183         */
184        HalfSegment(MapViewPoint start, MapViewPoint end, double quality, double offset) {
185            super();
186            this.start = start;
187            this.end = end;
188            this.quality = quality;
189            this.offset = offset;
190        }
191
192        @Override
193        public String toString() {
194            return "HalfSegment [start=" + start + ", end=" + end + ", quality=" + quality + ']';
195        }
196    }
197
198    /**
199     * A visitor that computes the side of the way that is the upper one for each segment and computes the dominant upper side of the way.
200     * This is used to always place at least 50% of the text correctly.
201     */
202    private static class UpsideComputingVisitor implements PathSegmentConsumer {
203
204        private final double startOffset;
205        private final double endOffset;
206
207        private double upsideUpLines;
208        private double upsideDownLines;
209
210        UpsideComputingVisitor(double startOffset, double endOffset) {
211            super();
212            this.startOffset = startOffset;
213            this.endOffset = endOffset;
214        }
215
216        @Override
217        public void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd) {
218            if (inLineOffset > endOffset) {
219                return;
220            }
221            double length = start.distanceToInView(end);
222            if (inLineOffset + length < startOffset) {
223                return;
224            }
225
226            double segmentStart = Math.max(inLineOffset, startOffset);
227            double segmentEnd = Math.min(inLineOffset + length, endOffset);
228
229            double segmentLength = segmentEnd - segmentStart;
230
231            if (start.getInViewX() < end.getInViewX()) {
232                upsideUpLines += segmentLength;
233            } else {
234                upsideDownLines += segmentLength;
235            }
236        }
237
238        /**
239         * Check if the text should be rotated by 180°
240         * @return if the text should be rotated.
241         */
242        boolean shouldRotateText() {
243            return upsideUpLines < upsideDownLines;
244        }
245    }
246
247    /**
248     * Rotate the glyphs along a path.
249     */
250    private class GlyphRotatingVisitor implements PathSegmentConsumer {
251        private final Iterator<OffsetGlyph> gvs;
252        private final boolean isDoubleTranslationBug;
253        private OffsetGlyph next;
254
255        /**
256         * Create a new {@link GlyphRotatingVisitor}
257         * @param gvs The glyphs to draw. Sorted along the line
258         * @param isDoubleTranslationBug true to fix a double translation bug.
259         */
260        GlyphRotatingVisitor(List<OffsetGlyph> gvs, boolean isDoubleTranslationBug) {
261            this.isDoubleTranslationBug = isDoubleTranslationBug;
262            this.gvs = gvs.iterator();
263            takeNext();
264            while (next != null && next.offset < 0) {
265                // skip them
266                takeNext();
267            }
268        }
269
270        private void takeNext() {
271            if (gvs.hasNext()) {
272                next = gvs.next();
273            } else {
274                next = null;
275            }
276        }
277
278        @Override
279        public void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd) {
280            double segLength = start.distanceToInView(end);
281            double segEnd = inLineOffset + segLength;
282            double theta = theta(start, end);
283            while (next != null && next.offset < segEnd) {
284                Rectangle2D rect = next.getBounds();
285                double centerY = 0;
286                MapViewPoint p = start.interpolate(end, (next.offset - inLineOffset) / segLength);
287
288                AffineTransform trfm = new AffineTransform();
289                trfm.translate(-rect.getCenterX(), -centerY);
290                trfm.translate(p.getInViewX(), p.getInViewY());
291                trfm.rotate(theta + next.preRotate, rect.getWidth() / 2, centerY);
292                trfm.translate(0, next.glyph.getFont().getSize2D() * .25);
293                trfm.translate(0, yOffset);
294                if (isDoubleTranslationBug) {
295                    // scale the translation components by one half
296                    AffineTransform tmp = AffineTransform.getTranslateInstance(-0.5 * trfm.getTranslateX(),
297                            -0.5 * trfm.getTranslateY());
298                    tmp.concatenate(trfm);
299                    trfm = tmp;
300                }
301                next.glyph.setGlyphTransform(next.glyphIndex, trfm);
302                takeNext();
303            }
304        }
305    }
306
307    private static class OffsetGlyph {
308        private final double offset;
309        private final double preRotate;
310        private final GlyphVector glyph;
311        private final int glyphIndex;
312
313        OffsetGlyph(double offset, boolean rotateText, GlyphVector glyph, int glyphIndex) {
314            super();
315            this.preRotate = rotateText ? Math.PI : 0;
316            this.glyph = glyph;
317            this.glyphIndex = glyphIndex;
318            Rectangle2D rect = getBounds();
319            this.offset = offset + (rotateText ? -1 : 1) * (rect.getX() + rect.getWidth() / 2);
320        }
321
322        Rectangle2D getBounds() {
323            return glyph.getGlyphLogicalBounds(glyphIndex).getBounds2D();
324        }
325
326        double getOffset() {
327            return offset;
328        }
329
330        @Override
331        public String toString() {
332            return "OffsetGlyph [offset=" + offset + ", preRotate=" + preRotate + ", glyphIndex=" + glyphIndex + ']';
333        }
334    }
335
336    private static double theta(MapViewPoint start, MapViewPoint end) {
337        return Math.atan2(end.getInViewY() - start.getInViewY(), end.getInViewX() - start.getInViewX());
338    }
339
340    @Override
341    public PositionForAreaStrategy withAddedOffset(Point2D addToOffset) {
342        if (Math.abs(addToOffset.getY()) < 1e-5) {
343            return this;
344        } else {
345            return new OnLineStrategy(this.yOffset - addToOffset.getY());
346        }
347    }
348
349    @Override
350    public String toString() {
351        return "OnLineStrategy [yOffset=" + yOffset + ']';
352    }
353
354    @Override
355    public int hashCode() {
356        final int prime = 31;
357        int result = 1;
358        long temp;
359        temp = Double.doubleToLongBits(yOffset);
360        result = prime * result + (int) (temp ^ (temp >>> 32));
361        return result;
362    }
363
364    @Override
365    public boolean equals(Object obj) {
366        if (this == obj) {
367            return true;
368        }
369        if (obj == null || getClass() != obj.getClass()) {
370            return false;
371        }
372        OnLineStrategy other = (OnLineStrategy) obj;
373        return Double.doubleToLongBits(yOffset) == Double.doubleToLongBits(other.yOffset);
374    }
375}