// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.gui.mappaint.styleelement.placement;
import java.awt.font.GlyphVector;
import java.awt.geom.AffineTransform;
import java.awt.geom.Rectangle2D;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Optional;
import java.util.stream.IntStream;
import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
import org.openstreetmap.josm.gui.draw.MapViewPath;
import org.openstreetmap.josm.gui.draw.MapViewPath.PathSegmentConsumer;
import org.openstreetmap.josm.gui.draw.MapViewPositionAndRotation;
/**
* Places the label onto the line.
*
* @author Michael Zangl
* @since 11722
* @since 11748 moved to own file
*/
public class OnLineStrategy implements PositionForAreaStrategy {
/**
* An instance of this class.
*/
public static final OnLineStrategy INSTANCE = new OnLineStrategy(0);
private final double yOffset;
/**
* Create a new strategy that places the text on the line.
* @param yOffset The offset sidewards to the line.
*/
public OnLineStrategy(double yOffset) {
this.yOffset = yOffset;
}
@Override
public MapViewPositionAndRotation findLabelPlacement(MapViewPath path, Rectangle2D nb) {
return findOptimalWayPosition(nb, path).map(best -> {
MapViewPoint center = best.start.interpolate(best.end, .5);
return new MapViewPositionAndRotation(center, upsideTheta(best));
}).orElse(null);
}
private static double upsideTheta(HalfSegment best) {
double theta = theta(best.start, best.end);
if (theta < -Math.PI / 2) {
return theta + Math.PI;
} else if (theta > Math.PI / 2) {
return theta - Math.PI;
} else {
return theta;
}
}
@Override
public boolean supportsGlyphVector() {
return true;
}
@Override
public List<GlyphVector> generateGlyphVectors(MapViewPath path, Rectangle2D nb, List<GlyphVector> gvs,
boolean isDoubleTranslationBug) {
// Find the position on the way the font should be placed.
// If none is found, use the middle of the way.
double middleOffset = findOptimalWayPosition(nb, path).map(segment -> segment.offset)
.orElse(path.getLength() / 2);
// Check that segment of the way. Compute in which direction the text should be rendered.
// It is rendered in a way that ensures that at least 50% of the text are rotated with the right side up.
UpsideComputingVisitor upside = new UpsideComputingVisitor(middleOffset - nb.getWidth() / 2,
middleOffset + nb.getWidth() / 2);
path.visitLine(upside);
boolean doRotateText = upside.shouldRotateText();
// Compute the list of glyphs to draw, along with their offset on the current line.
List<OffsetGlyph> offsetGlyphs = computeOffsetGlyphs(gvs,
middleOffset + (doRotateText ? 1 : -1) * nb.getWidth() / 2, doRotateText);
// Order the glyphs along the line to ensure that they are drawn corretly.
Collections.sort(offsetGlyphs, Comparator.comparing(OffsetGlyph::getOffset));
// Now translate all glyphs. This will modify the glyphs stored in gvs.
path.visitLine(new GlyphRotatingVisitor(offsetGlyphs, isDoubleTranslationBug));
return gvs;
}
/**
* Create a list of glyphs with an offset along the way
* @param gvs The list of glyphs
* @param startOffset The offset in the line
* @param rotateText Rotate the text by 180°
* @return The list of glyphs.
*/
private static List<OffsetGlyph> computeOffsetGlyphs(List<GlyphVector> gvs, double startOffset, boolean rotateText) {
double offset = startOffset;
ArrayList<OffsetGlyph> offsetGlyphs = new ArrayList<>();
for (GlyphVector gv : gvs) {
double gvOffset = offset;
IntStream.range(0, gv.getNumGlyphs())
.mapToObj(i -> new OffsetGlyph(gvOffset, rotateText, gv, i))
.forEach(offsetGlyphs::add);
offset += (rotateText ? -1 : 1) + gv.getLogicalBounds().getBounds2D().getWidth();
}
return offsetGlyphs;
}
private static Optional<HalfSegment> findOptimalWayPosition(Rectangle2D rect, MapViewPath path) {
// 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)
List<HalfSegment> longHalfSegment = new ArrayList<>();
double minSegmentLength = 2 * (rect.getWidth() + 4);
double length = path.visitLine((inLineOffset, start, end, startIsOldEnd) -> {
double segmentLength = start.distanceToInView(end);
if (segmentLength > minSegmentLength) {
MapViewPoint center = start.interpolate(end, .5);
double q = computeQuality(start, center);
// prefer the first one for quality equality.
longHalfSegment.add(new HalfSegment(start, center, q + .1, inLineOffset + .25 * segmentLength));
q = computeQuality(center, end);
longHalfSegment.add(new HalfSegment(center, end, q, inLineOffset + .75 * segmentLength));
}
});
// find the segment with the best quality. If there are several with best quality, the one close to the center is prefered.
return longHalfSegment.stream().max(
Comparator.comparingDouble(segment -> segment.quality - 1e-5 * Math.abs(segment.offset - length / 2)));
}
private static double computeQuality(MapViewPoint p1, MapViewPoint p2) {
double q = 0;
if (p1.isInView()) {
q += 1;
}
if (p2.isInView()) {
q += 1;
}
return q;
}
/**
* A half segment that can be used to place text on it. Used in the drawTextOnPath algorithm.
* @author Michael Zangl
*/
private static class HalfSegment {
/**
* start point of half segment
*/
private final MapViewPoint start;
/**
* end point of half segment
*/
private final MapViewPoint end;
/**
* quality factor (off screen / partly on screen / fully on screen)
*/
private final double quality;
/**
* The offset in the path.
*/
private final double offset;
/**
* Create a new half segment
* @param start The start along the way
* @param end The end of the segment
* @param quality A quality factor.
* @param offset The offset in the path.
*/
HalfSegment(MapViewPoint start, MapViewPoint end, double quality, double offset) {
super();
this.start = start;
this.end = end;
this.quality = quality;
this.offset = offset;
}
@Override
public String toString() {
return "HalfSegment [start=" + start + ", end=" + end + ", quality=" + quality + ']';
}
}
/**
* 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.
* This is used to always place at least 50% of the text correctly.
*/
private static class UpsideComputingVisitor implements PathSegmentConsumer {
private final double startOffset;
private final double endOffset;
private double upsideUpLines;
private double upsideDownLines;
UpsideComputingVisitor(double startOffset, double endOffset) {
super();
this.startOffset = startOffset;
this.endOffset = endOffset;
}
@Override
public void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd) {
if (inLineOffset > endOffset) {
return;
}
double length = start.distanceToInView(end);
if (inLineOffset + length < startOffset) {
return;
}
double segmentStart = Math.max(inLineOffset, startOffset);
double segmentEnd = Math.min(inLineOffset + length, endOffset);
double segmentLength = segmentEnd - segmentStart;
if (start.getInViewX() < end.getInViewX()) {
upsideUpLines += segmentLength;
} else {
upsideDownLines += segmentLength;
}
}
/**
* Check if the text should be rotated by 180°
* @return if the text should be rotated.
*/
boolean shouldRotateText() {
return upsideUpLines < upsideDownLines;
}
}
/**
* Rotate the glyphs along a path.
*/
private class GlyphRotatingVisitor implements PathSegmentConsumer {
private final Iterator<OffsetGlyph> gvs;
private final boolean isDoubleTranslationBug;
private OffsetGlyph next;
/**
* Create a new {@link GlyphRotatingVisitor}
* @param gvs The glyphs to draw. Sorted along the line
* @param isDoubleTranslationBug true to fix a double translation bug.
*/
GlyphRotatingVisitor(List<OffsetGlyph> gvs, boolean isDoubleTranslationBug) {
this.isDoubleTranslationBug = isDoubleTranslationBug;
this.gvs = gvs.iterator();
takeNext();
while (next != null && next.offset < 0) {
// skip them
takeNext();
}
}
private void takeNext() {
if (gvs.hasNext()) {
next = gvs.next();
} else {
next = null;
}
}
@Override
public void addLineBetween(double inLineOffset, MapViewPoint start, MapViewPoint end, boolean startIsOldEnd) {
double segLength = start.distanceToInView(end);
double segEnd = inLineOffset + segLength;
double theta = theta(start, end);
while (next != null && next.offset < segEnd) {
Rectangle2D rect = next.getBounds();
double centerY = 0;
MapViewPoint p = start.interpolate(end, (next.offset - inLineOffset) / segLength);
AffineTransform trfm = new AffineTransform();
trfm.translate(-rect.getCenterX(), -centerY);
trfm.translate(p.getInViewX(), p.getInViewY());
trfm.rotate(theta + next.preRotate, rect.getWidth() / 2, centerY);
trfm.translate(0, next.glyph.getFont().getSize2D() * .25);
trfm.translate(0, yOffset);
if (isDoubleTranslationBug) {
// scale the translation components by one half
AffineTransform tmp = AffineTransform.getTranslateInstance(-0.5 * trfm.getTranslateX(),
-0.5 * trfm.getTranslateY());
tmp.concatenate(trfm);
trfm = tmp;
}
next.glyph.setGlyphTransform(next.glyphIndex, trfm);
takeNext();
}
}
}
private static class OffsetGlyph {
private final double offset;
private final double preRotate;
private final GlyphVector glyph;
private final int glyphIndex;
OffsetGlyph(double offset, boolean rotateText, GlyphVector glyph, int glyphIndex) {
super();
this.preRotate = rotateText ? Math.PI : 0;
this.glyph = glyph;
this.glyphIndex = glyphIndex;
Rectangle2D rect = getBounds();
this.offset = offset + (rotateText ? -1 : 1) * (rect.getX() + rect.getWidth() / 2);
}
Rectangle2D getBounds() {
return glyph.getGlyphLogicalBounds(glyphIndex).getBounds2D();
}
double getOffset() {
return offset;
}
@Override
public String toString() {
return "OffsetGlyph [offset=" + offset + ", preRotate=" + preRotate + ", glyphIndex=" + glyphIndex + ']';
}
}
private static double theta(MapViewPoint start, MapViewPoint end) {
return Math.atan2(end.getInViewY() - start.getInViewY(), end.getInViewX() - start.getInViewX());
}
}