/* * GeoTools - The Open Source Java GIS Toolkit * http://geotools.org * * (C) 2002-2016, Open Source Geospatial Foundation (OSGeo) * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; * version 2.1 of the License. * * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. */ package org.geotools.renderer.label; import com.vividsolutions.jts.geom.Coordinate; import com.vividsolutions.jts.geom.GeometryFactory; import com.vividsolutions.jts.geom.LineString; import org.geotools.geometry.jts.LiteShape; import org.geotools.geometry.jts.LiteShape2; import org.geotools.geometry.jts.TransformedShape; import org.geotools.renderer.label.LabelCacheImpl.LabelRenderingMode; import org.geotools.renderer.label.LabelCacheItem.GraphicResize; import org.geotools.renderer.label.LineInfo.LineComponent; import org.geotools.renderer.lite.StyledShapePainter; import org.geotools.renderer.style.GraphicStyle2D; import org.geotools.renderer.style.IconStyle2D; import org.geotools.renderer.style.MarkStyle2D; import org.geotools.renderer.style.Style2D; import org.geotools.renderer.style.TextStyle2D; import javax.swing.*; import java.awt.*; import java.awt.font.GlyphVector; import java.awt.font.LineMetrics; import java.awt.geom.AffineTransform; import java.awt.geom.Line2D; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.awt.image.AffineTransformOp; import java.awt.image.BufferedImage; import java.util.ArrayList; import java.util.List; /** * This class performs the layouting and painting of the single label (leaving * the label cache the task to sort labels and locate the best label points) * * @author Andrea Aime * * * * * @source $URL$ */ public class LabelPainter { /** * Epsilon used for comparisons with 0 */ static final double EPS = 1e-6; /** * Delegate shape painter used to paint the graphics below the text */ StyledShapePainter shapePainter = new StyledShapePainter(); /** * The current label we're tring to draw */ LabelCacheItem labelItem; /** * The lines in which the label has been split (if any) */ List<LineInfo> lines; /** * The graphics object used during painting */ Graphics2D graphics; /** * Whether we draw text using its {@link Shape} outline, or we use a plain * {@link Graphics2D#drawGlyphVector(GlyphVector, float, float)} instead */ LabelRenderingMode labelRenderingMode; /** * Used to build JTS geometries during label painting */ GeometryFactory gf = new GeometryFactory(); /** * The cached label bounds */ Rectangle2D labelBounds; /** * The class in charge of splitting the labels in multiple lines/scripts/fonts */ LabelSplitter splitter = new LabelSplitter(); /** * Builds a new painter * * @param graphics * @param outlineRenderingEnabled */ public LabelPainter(Graphics2D graphics, LabelRenderingMode labelRenderingMode) { this.graphics = graphics; this.labelRenderingMode = labelRenderingMode; } /** * Sets the current label. The label will be laid out according to the label * item settings (curved lines, auto wrapping, curved line usage) and the * painter will be ready to draw it. * * @param labelItem */ public void setLabel(LabelCacheItem labelItem) { this.labelItem = labelItem; TextStyle2D textStyle = labelItem.getTextStyle(); textStyle.setLabel(labelItem.getLabel()); // reset previous caches labelBounds = null; lines = null; // layout the label elements lines = splitter.layout(labelItem, graphics); // compute the max line length double maxWidth = 0; for (LineInfo line : lines) { maxWidth = Math.max(line.getWidth(), maxWidth); } // now that we know how big each line and how big is the longest, // we can layout the items and compute the total bounds double boundsY = 0; double labelY = 0; for (LineInfo info : lines) { Rectangle2D currBounds = info.getBounds(); // the position at which we start to draw, x and y // for x we have to take into consideration alignment as // well since that affects the horizontal size of the // bounds, // for y we don't care right now as we're computing // only the total bounds for a text located in the origin double minX = (maxWidth - currBounds.getWidth()) * textStyle.getAnchorX() - currBounds.getMinX(); info.setMinX(minX); double lineOffset = info.getLineOffset(); if (labelBounds == null) { labelBounds = currBounds; boundsY = currBounds.getMinY() + lineOffset; } else { Rectangle2D translated = new Rectangle2D.Double(minX, boundsY, currBounds.getWidth(), currBounds.getHeight()); boundsY += lineOffset; labelY += lineOffset; labelBounds = labelBounds.createUnion(translated); } info.setY(labelY); } normalizeBounds(labelBounds); } /** * If, for any reason, a font size of 0 is provided to the renderer, resulting bounds * will become empty and this will ruin most geometric computations dealing with spacing * and orientations. Enlarge the envelope a tiny bit * @param bounds */ void normalizeBounds(Rectangle2D bounds) { if(bounds == null) { bounds = new Rectangle2D.Float(-1, -1, 2, 2); } else if(bounds.isEmpty()) { bounds.setRect(bounds.getCenterX() -1 , bounds.getCenterY() -1, 2, 2); } } /** * Returns the current label item * * @return */ public LabelCacheItem getLabel() { return labelItem; } /** * Returns the line height for this label in pixels (for multiline labels, * it's the height of the first line) * * @return */ public double getLineHeight() { return lines.get(0).getLineHeight(); } /** * The full size above the baseline * @return */ public double getAscent() { return lines.get(0).getAscent(); } /** * Returns the width of the label, as painted in straight form ( * * @return */ public int getStraightLabelWidth() { return (int) Math.round(getLabelBounds().getWidth()); } /** * Number of lines for this label (more than 1 if the label has embedded * newlines or if we're auto-wrapping it) * * @return */ public int getLineCount() { return lines.size(); } /** * Get the straight label bounds, taking into account halo, shield and line * wrapping * * @return */ public Rectangle2D getFullLabelBounds() { // base bounds (clone them, we're going to alter the bounds directly) Rectangle2D bounds = (Rectangle2D) getLabelBounds().clone(); // take into account halo int haloRadius = Math.round(labelItem.getTextStyle().getHaloFill() != null ? labelItem .getTextStyle().getHaloRadius() : 0); bounds.add(bounds.getMinX() - haloRadius, bounds.getMinY() - haloRadius); bounds.add(bounds.getMaxX() + haloRadius, bounds.getMaxY() + haloRadius); // if there is a shield, expand the bounds to account for it as well if (labelItem.getTextStyle().getGraphic() != null) { Rectangle2D area = labelItem.getTextStyle().getGraphicDimensions(); // center the graphics on the labels back Rectangle2D shieldBounds; // handle the image resizing and margins int[] margin = labelItem.getGraphicMargin(); GraphicResize mode = labelItem.getGraphicsResize(); if (mode == GraphicResize.STRETCH) { // it's really the label bounds + margin shieldBounds = applyMargins(margin, bounds); } else if (mode == GraphicResize.PROPORTIONAL) { // the shield will be inflated in proportion to its size double factor = 1; if (bounds.getWidth() > bounds.getHeight()) { factor = bounds.getWidth() / area.getWidth(); } else { factor = bounds.getHeight() / area.getHeight(); } double width = area.getWidth() * factor; double height = area.getHeight() * factor; shieldBounds = new Rectangle2D.Double(width / 2 + bounds.getMinX() - bounds.getWidth() / 2, height / 2 + bounds.getMinY() - bounds.getHeight() / 2, width, height); shieldBounds = applyMargins(margin, shieldBounds); } else { // use the shield natural bounds shieldBounds = new Rectangle2D.Double(-area.getWidth() / 2 + bounds.getMinX() - bounds.getWidth() / 2, -area.getHeight() / 2 + bounds.getMinY() - bounds.getHeight() / 2, area.getWidth(), area.getHeight()); } bounds = bounds.createUnion(shieldBounds); } normalizeBounds(bounds); return bounds; } Rectangle2D applyMargins(int[] margin, Rectangle2D bounds) { if(bounds != null) { double xmin = bounds.getMinX() - margin[3]; double ymin = bounds.getMinY() - margin[0]; double width = bounds.getWidth() + margin[1] + margin[3]; double height = bounds.getHeight() + margin[0] + margin[2]; return new Rectangle2D.Double(xmin, ymin, width, height); } else { return bounds; } } /** * Get the straight label bounds, without taking into account halo and * shield * * @return */ public Rectangle2D getLabelBounds() { return labelBounds; } /** * Paints the label as a non curved one. The positioning and rotation are * provided by the transformation * * @param transform * @throws Exception */ public void paintStraightLabel(AffineTransform transform) throws Exception { AffineTransform oldTransform = graphics.getTransform(); try { AffineTransform newTransform = new AffineTransform(oldTransform); newTransform.concatenate(transform); graphics.setTransform(newTransform); // draw the label shield first, underneath the halo Style2D graphic = labelItem.getTextStyle().getGraphic(); if (graphic != null) { // take into account the graphic margins, if any double offsetY = 0; double offsetX = 0; final int[] margin = labelItem.getGraphicMargin(); if(margin != null) { offsetX = margin[1] - margin[3]; offsetY = margin[2] - margin[0]; } LiteShape2 tempShape = new LiteShape2(gf.createPoint(new Coordinate(labelBounds .getWidth() / 2.0 + offsetX, -1.0 * labelBounds.getHeight() / 2.0 + offsetY)), null, null, false, false); // resize graphic and transform it based on the position of the last line graphic = resizeGraphic(graphic); AffineTransform graphicTx = new AffineTransform(transform); LineInfo lastLine = lines.get(lines.size() - 1); graphicTx.translate(lastLine.getComponents().get(0).getX(), lastLine.getY()); graphics.setTransform(graphicTx); shapePainter.paint(graphics, tempShape, graphic, graphic.getMaxScale()); } // 0 is unfortunately an acceptable value if people only want to draw shields // (to leverage conflict resolution, priority when placing symbols) if(labelItem.getTextStyle().getFont().getSize() == 0) return; // draw the label if (lines.size() == 1 && lines.get(0).getComponents().size() == 1) { LineComponent component = lines.get(0).getComponents().get(0); drawGlyphVector(component); } else { // for multiline labels we have to go thru the lines and apply // the proper transformation // to position each row within the label bounds AffineTransform lineTx = new AffineTransform(); for (LineInfo line : lines) { for (LineComponent component : line.getComponents()) { lineTx.setTransform(newTransform); lineTx.translate(component.getX(), line.getY()); graphics.setTransform(lineTx); drawGlyphVector(component); } } } } finally { graphics.setTransform(oldTransform); } } /** * Resizes the graphic according to the resize mode, label size and margins * @param graphic * @return */ private Style2D resizeGraphic(Style2D graphic) { final GraphicResize mode = labelItem.graphicsResize; // if no resize, nothing to do if(mode == GraphicResize.NONE || mode == null) { return graphic; } // compute the new width and height double width = labelBounds.getWidth(); double height = labelBounds.getHeight(); final int[] margin = labelItem.graphicMargin; if(margin != null) { width += margin[1] + margin[3]; height += margin[0] + margin[2]; } width = Math.round(width); height = Math.round(height); // just in case someone specified negative margins for some reason if(width <= 0 || height <= 0) { return null; } if(graphic instanceof MarkStyle2D) { MarkStyle2D mark = (MarkStyle2D) graphic; Shape original = mark.getShape(); Rectangle2D bounds = original.getBounds2D(); MarkStyle2D resized = (MarkStyle2D) mark.clone(); if(mode == GraphicResize.PROPORTIONAL) { if(width > height) { resized.setSize(Math.round(bounds.getHeight() * width / bounds.getWidth())); } else { resized.setSize(height); } } else { TransformedShape tss = new TransformedShape(); tss.shape = original; tss.setTransform(AffineTransform.getScaleInstance(width / bounds.getWidth(), height / bounds.getHeight())); resized.setShape(tss); resized.setSize(height); } return resized; } else if(graphic instanceof IconStyle2D) { IconStyle2D iconStyle = (IconStyle2D) graphic; IconStyle2D resized = (IconStyle2D) iconStyle.clone(); final Icon icon = iconStyle.getIcon(); AffineTransform at; if(mode == GraphicResize.PROPORTIONAL) { double factor; if(width > height) { factor = width / icon.getIconWidth(); } else { factor = height / icon.getIconHeight(); } at = AffineTransform.getScaleInstance(factor, factor); } else { at = AffineTransform.getScaleInstance(width / icon.getIconWidth(), height / icon.getIconHeight()); } resized.setIcon(new TransformedIcon(icon, at)); return resized; } else if(graphic instanceof GraphicStyle2D) { GraphicStyle2D gstyle = (GraphicStyle2D) graphic; GraphicStyle2D resized = (GraphicStyle2D) graphic.clone(); BufferedImage image = gstyle.getImage(); AffineTransform at; if(mode == GraphicResize.PROPORTIONAL) { double factor; if(width > height) { factor = width / image.getWidth(); } else { factor = height / image.getHeight(); } at = AffineTransform.getScaleInstance(factor, factor); } else { at = AffineTransform.getScaleInstance(width / image.getWidth(), height / image.getHeight()); } AffineTransformOp ato = new AffineTransformOp(at, AffineTransformOp.TYPE_BILINEAR); image = ato.filter(image, null); resized.setImage(image); return resized; } else { return graphic; } } /** * Draws the glyph vector respecting the label item options * * @param gv */ private void drawGlyphVector(LineComponent component) { LineMetrics metrics = computeLineMetricsIfNeeded(component); GlyphVector gv = component.getGlyphVector(); java.awt.Shape outline = gv.getOutline(); if (labelItem.getTextStyle().getHaloFill() != null) { configureHalo(); graphics.draw(outline); // draw underline halo if needed drawStraightLabelUnderlineIfNeeded(outline, metrics, true); } configureLabelStyle(); // draw the under line drawStraightLabelUnderlineIfNeeded(outline, metrics, false); if(labelRenderingMode == LabelRenderingMode.STRING) { graphics.drawGlyphVector(gv, 0, 0); } else if(labelRenderingMode == LabelRenderingMode.OUTLINE) { graphics.fill(outline); } else { AffineTransform tx = graphics.getTransform(); if (Math.abs(tx.getShearX()) >= EPS || Math.abs(tx.getShearY()) > EPS) { graphics.fill(outline); } else { graphics.drawGlyphVector(gv, 0, 0); } } } /** * Computes a line component metrics only if the current label is underlined. */ private LineMetrics computeLineMetricsIfNeeded(LineComponent component) { if (labelItem.isTextUnderlined()) { return component.computeLineMetrics(graphics.getFontRenderContext()); } return null; } /** * Draws a line under the text with the same color of the text and with the same width * using the provided thickness and offset. */ private void drawStraightLabelUnderlineIfNeeded(java.awt.Shape outline, LineMetrics metrics, boolean drawingHalo) { Rectangle2D bounds = outline.getBounds2D().getBounds(); double minX = bounds.getMinX(); double maxX = bounds.getMaxX(); drawStraightLabelUnderlineIfNeeded(minX, maxX, metrics, drawingHalo); } /** * Draws a line under the text with the same color of the text and with the same width * using the provided thickness and offset. */ private void drawStraightLabelUnderlineIfNeeded(double minX, double maxX, LineMetrics metrics, boolean drawingHalo) { // let's see if text underline is enabled for this label or we have something to draw if (!labelItem.isTextUnderlined() || (Math.abs(maxX - minX) < 0.0000001)) { // text underline not enabled or nothing to draw return; } // get needed metrics values float underlineThickness = metrics.getUnderlineThickness(); float underlineOffset = metrics.getUnderlineOffset(); // let's se if we are drawing the halo around the underline line if (drawingHalo) { // when drawing the halo we assume that the correct halo configuration has been set graphics.draw(new Line2D.Double(minX, underlineOffset * 2, maxX, underlineOffset * 2)); } else { // storing the current stroke and setting the stroke according to underline thickness Stroke currentStroke = graphics.getStroke(); graphics.setStroke(new BasicStroke(underlineThickness)); // we draw a line with the same color of the text and a stroke of 2 graphics.draw(new Line2D.Double(minX, underlineOffset * 2, maxX, underlineOffset * 2)); // we need to restore the previous stroke graphics.setStroke(currentStroke); } } /** * Configures the graphic to do the halo drawing */ private void configureHalo() { graphics.setPaint(labelItem.getTextStyle().getHaloFill()); graphics.setComposite(labelItem.getTextStyle().getHaloComposite()); float haloRadius = labelItem.getTextStyle().getHaloFill() != null ? labelItem.getTextStyle().getHaloRadius() : 0; graphics.setStroke(new BasicStroke(2 * haloRadius, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); } /** * Configures the graphic to do the text drawing */ private void configureLabelStyle() { // DJB: added this because several people were using // "font-color" instead of fill // It legal to have a label w/o fill (which means dont // render it) // This causes people no end of trouble. // If they dont want to colour it, then they should use a // filter // DEFAULT (no <Fill>) --> BLACK // NOTE: re-reading the spec says this is the correct // assumption. Paint fill = labelItem.getTextStyle().getFill(); Composite comp = labelItem.getTextStyle().getComposite(); if (fill == null) { fill = Color.BLACK; comp = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f); // 100% // opaque } graphics.setPaint(fill); graphics.setComposite(comp); } /** * Paints a label that follows the line, centered in the current cursor * position * * @param cursor */ public void paintCurvedLabel(LineStringCursor cursor) { // 0 is unfortunately an acceptable value if people only want to draw shields if(labelItem.getTextStyle().getFont().getSize() == 0) return; AffineTransform oldTransform = graphics.getTransform(); LineInfo line = lines.get(0); // first off, check if we are walking the line so that the label is // looking up, if not, reverse the line if (!isLabelUpwards(cursor) && labelItem.isForceLeftToRightEnabled()) { LineStringCursor reverse = cursor.reverse(); reverse.moveTo(cursor.getLineStringLength() - cursor.getCurrentOrdinate()); cursor = reverse; } // find out the true centering position double anchorY = getLinePlacementYAnchor(); // init, move to the starting position double mid = cursor.getCurrentOrdinate(); Coordinate c = new Coordinate(); c = cursor.getCurrentPosition(c); graphics.setPaint(Color.BLACK); double startOrdinate = mid - getStraightLabelWidth() / 2; if (startOrdinate < 0) startOrdinate = 0; cursor.moveTo(startOrdinate); // store the computed outlines an transformations List<Shape[]> allOutlines = new ArrayList<>(); List<AffineTransform[]> allTransforms = new ArrayList<>(); try { for (LineComponent component : line.getComponents()) { GlyphVector glyphVector = component.getGlyphVector(); final int numGlyphs = glyphVector.getNumGlyphs(); float nextAdvance = glyphVector.getGlyphMetrics(0).getAdvance() * 0.5f; double start = cursor.getCurrentOrdinate(); Shape[] outlines = new Shape[numGlyphs]; AffineTransform[] transforms = new AffineTransform[numGlyphs]; for (int i = 0; i < numGlyphs; i++) { outlines[i] = glyphVector.getGlyphOutline(i); Point2D p = glyphVector.getGlyphPosition(i); float advance = nextAdvance; nextAdvance = i < numGlyphs - 1 ? glyphVector.getGlyphMetrics(i + 1).getAdvance() * 0.5f : 0; c = cursor.getCurrentPosition(c); AffineTransform t = new AffineTransform(graphics.getTransform()); t.translate(c.x, c.y); t.rotate(cursor.getCurrentAngle()); t.translate(-p.getX() - advance, -p.getY() + getLineHeight() * anchorY); transforms[i] = t; cursor.moveTo(cursor.getCurrentOrdinate() + advance + nextAdvance); } allOutlines.add(outlines); allTransforms.add(transforms); // take into account eventual spaces at the end of the glyph cursor.moveTo(start + glyphVector.getGlyphPosition(numGlyphs).getX()); } // draw halo and label if (labelItem.getTextStyle().getHaloFill() != null) { configureHalo(); if (labelItem.isTextUnderlined()) { // we need to draw the underline halo drawCurvedUnderline(line, cursor, startOrdinate, true); } drawOrFillOutlines(allOutlines, allTransforms, false); } graphics.setTransform(oldTransform); configureLabelStyle(); if (labelItem.isTextUnderlined()) { // we need to draw the underline drawCurvedUnderline(line, cursor, startOrdinate, false); } drawOrFillOutlines(allOutlines, allTransforms, true); } finally { graphics.setTransform(oldTransform); } } /** * Helper method that will draw the underline of a curved label using the context of the cursor. */ private void drawCurvedUnderline(LineInfo line, LineStringCursor cursor, double startOrdinate, boolean drawingHalo) { // extracting label first line component and compute is metrics LineComponent component = line.getComponents().get(0); LineMetrics metrics = computeLineMetricsIfNeeded(component); // the cursor is in the last char of the label double endOrdinate = cursor.getCurrentOrdinate(); // compute the advance based on the first char of the label GlyphVector glyphVector = line.getComponents().get(0).getGlyphVector(); double advance = glyphVector.getGlyphMetrics(0).getAdvance() * 0.5f; // extract from the linestring the portion associated with the layer LineString labelLineString = cursor.getSubLineString(startOrdinate - advance, endOrdinate - advance); // compute the underline linestring LiteShape underlineLineString = computeCurvedUnderline(labelLineString, metrics); if (drawingHalo) { // when drawing the halo we assume that the correct halo configuration has been set graphics.draw(underlineLineString); } else { // string the current stroke to restore it back Stroke oldStroke = graphics.getStroke(); try { // if we are not drawing the halo we need to set the proper stroke graphics.setStroke(new BasicStroke(metrics.getUnderlineThickness())); // draw the underline graphics.draw(underlineLineString); } finally { graphics.setStroke(oldStroke); } } } /** * Helper method that go through all the outlines and transformations a draw or fill them. */ private void drawOrFillOutlines(List<Shape[]> allOutlines, List<AffineTransform[]> allTransforms, boolean fill) { for(int i = 0; i < allOutlines.size(); i++) { Shape[] outlines = allOutlines.get(i); AffineTransform[] transforms = allTransforms.get(i); int numGlyphs = outlines.length; for (int j = 0; j < numGlyphs; j++) { graphics.setTransform(transforms[j]); if (fill) { graphics.fill(outlines[j]); } else { graphics.draw(outlines[j]); } } } } /** * Given the portion of the linestring associated with the label and label metrics, * this method will compute a proper underline. */ private LiteShape computeCurvedUnderline(LineString labelLineString, LineMetrics metrics) { Coordinate[] coordinates = labelLineString.getCoordinates(); Coordinate[] parallelCoordinates = new Coordinate[coordinates.length]; double anchorOffset = getLinePlacementYAnchor() * getLineHeight(); for (int i = 0; i < coordinates.length - 1; i++) { // let's compute some basic info for the current segment Coordinate coordinateA = coordinates[i]; Coordinate coordinateB = coordinates[i + 1]; double dx = coordinateB.x - coordinateA.x; double dy = coordinateB.y - coordinateA.y; double length = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); double offset = -(anchorOffset + metrics.getUnderlineOffset() * 2); // compute the parallel coordinates double x1 = coordinateA.x + offset * (coordinateB.y - coordinateA.y) / length; double x2 = coordinateB.x + offset * (coordinateB.y - coordinateA.y) / length; double y1 = coordinateA.y + offset * (coordinateA.x - coordinateB.x) / length; double y2 = coordinateB.y + offset * (coordinateA.x - coordinateB.x) / length; parallelCoordinates[i] = new Coordinate(x1, y1); parallelCoordinates[i + 1] = new Coordinate(x2, y2); } // build the parallel linestring and wrap it in a lite shape LineString lineString = labelLineString.getFactory().createLineString(parallelCoordinates); return new LiteShape(lineString, null, true); } /** * Vertical centering is not trivial, because visually we want centering on * characters such as a,m,e, and not centering on d,g whose center is * affected by the full ascent or the full descent. This method tries to * computes the y anchor taking into account those. */ public double getLinePlacementYAnchor() { TextStyle2D textStyle = getLabel().getTextStyle(); LineMetrics lm = textStyle.getFont().getLineMetrics(textStyle.getLabel(), graphics.getFontRenderContext()); // gracefully handle font size = 0 if (lm.getHeight() > 0) { return (Math.abs(lm.getStrikethroughOffset()) + lm.getDescent() + lm.getLeading()) / lm.getHeight(); } else { return 0; } } /** * Returns true if a label placed in the current cursor position would look * upwards or not, defining upwards a label whose bottom to top direction is * greater than zero, and less or equal to 180 degrees. * * @param cursor * @return */ boolean isLabelUpwards(LineStringCursor cursor) { // label angle is orthogonal to the line direction double labelAngle = cursor.getCurrentAngle() + Math.PI / 2; // normalize the angle so that it's comprised between 0 and 360° labelAngle = labelAngle % (Math.PI * 2); return labelAngle >= 0 && labelAngle < Math.PI; } }