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 prefered. 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}