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