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}