/*******************************************************************************
* Copyright (c) 2014 Open Door Logistics (www.opendoorlogistics.com)
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Lesser Public License 3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/lgpl.html
*
******************************************************************************/
package com.opendoorlogistics.core.gis.map;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.font.FontRenderContext;
import java.awt.font.TextLayout;
import java.awt.geom.AffineTransform;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.util.List;
import com.opendoorlogistics.api.components.PredefinedTags;
import com.opendoorlogistics.api.geometry.LatLongToScreen;
import com.opendoorlogistics.core.cache.ApplicationCache;
import com.opendoorlogistics.core.cache.RecentlyUsedCache;
import com.opendoorlogistics.core.geometry.ODLGeomImpl;
import com.opendoorlogistics.core.gis.map.data.DrawableObject;
import com.opendoorlogistics.core.gis.map.data.UserRenderFlags;
import com.opendoorlogistics.core.utils.strings.StandardisedStringTreeMap;
import com.opendoorlogistics.core.utils.strings.Strings;
import com.vividsolutions.jts.geom.Envelope;
import com.vividsolutions.jts.index.quadtree.Quadtree;
class LowLevelTextRenderer {
static final private int NO_DRAW_BUFFER_AROUND_TEXT = 5;
static final private Dimension MIN_FREE_SPACE_TO_ATTEMPT_TEXT_DRAW = new Dimension(30, 20);
static final private double STRING_OFFSET_FRACTION = 0.4;
static final private BasicStroke SMALL_INNER_TEXT_STROKE = new BasicStroke(4, BasicStroke.CAP_ROUND, BasicStroke.CAP_ROUND, 0, null, 0);
static final private BasicStroke MEDIUM_INNER_TEXT_STROKE = new BasicStroke(6, BasicStroke.CAP_ROUND, BasicStroke.CAP_ROUND, 0, null, 0);
static final private BasicStroke LARGE_INNER_TEXT_STROKE = new BasicStroke(8, BasicStroke.CAP_ROUND, BasicStroke.CAP_ROUND, 0, null, 0);
static final private Font LABEL_FONT = new Font(Font.SANS_SERIF, Font.BOLD, 12);
static final Color LABEL_FONT_COLOUR = new Color(0, 0, 100);
static final StandardisedStringTreeMap<LabelPositionOption> labelPosOptionMap = new StandardisedStringTreeMap<LabelPositionOption>(false);
static final LabelPositionOption [] AUTOMATIC_POSITIONS = new LabelPositionOption[]{LabelPositionOption.TOP, LabelPositionOption.BOTTOM, LabelPositionOption.RIGHT, LabelPositionOption.LEFT,
LabelPositionOption.TOP_LEFT, LabelPositionOption.TOP_RIGHT, LabelPositionOption.BOTTOM_LEFT, LabelPositionOption.BOTTOM_RIGHT};
static{
for(LabelPositionOption lpo:LabelPositionOption.values()){
for(String k:lpo.getKeywords()){
labelPosOptionMap.put(k, lpo);
}
}
}
private static LabelPositionOption getLabelPositionOption(String l){
if(l!=null){
LabelPositionOption ret = labelPosOptionMap.get(l);
if(ret!=null){
return ret;
}
}
// default (the original option)
// return LabelPositionOption.RIGHT;
return null;
}
public enum LabelPositionOption{
RIGHT(PredefinedTags.RIGHT, "r"),
LEFT(PredefinedTags.LEFT, "l"),
TOP(PredefinedTags.TOP, "t"),
BOTTOM(PredefinedTags.BOTTOM , "b"),
CENTRE(PredefinedTags.CENTRE),
NO_LABEL(PredefinedTags.NO_LABEL),
TOP_LEFT(PredefinedTags.TOP_LEFT, "tl"),
TOP_RIGHT(PredefinedTags.TOP_RIGHT, "tr"),
BOTTOM_LEFT(PredefinedTags.BOTTOM_LEFT, "bl"),
BOTTOM_RIGHT(PredefinedTags.BOTTOM_RIGHT, "br"),
;
private LabelPositionOption(String ...keywords) {
this.keywords = keywords;
}
private final String []keywords;
public String []getKeywords(){
return keywords;
}
}
private Point2D getScreenPos(LatLongToScreen converter, DrawableObject pnt, Font font, Point2D.Double textSize, LabelPositionOption positionOption) {
Point2D screenPos=null;
ODLGeomImpl geom = pnt.getGeometry();
if (geom == null) {
// point rendering
screenPos = converter.getOnScreenPixelPosition(pnt);
int offset = (int) Math.round(STRING_OFFSET_FRACTION * font.getSize());
int halfWidth = (int)Math.round(textSize.x/ 2);
//int halfHeight = (int)Math.round(textSize.y/ 2);
//int heightSep = pnt.getPixelWidth()
offset = Math.max(offset, (int) Math.ceil(0.5 * pnt.getPixelWidth()) + 1);
double centreX = screenPos.getX() - halfWidth;
double rightX = screenPos.getX() + offset;
double leftX = screenPos.getX() - offset - textSize.x;
double topY = screenPos.getY() - 0.5*pnt.getPixelWidth() - 0.6* textSize.y;
double bottomY = screenPos.getY() + (0.5*pnt.getPixelWidth()+2) + 0.9*textSize.y;
double centreY = screenPos.getY();
switch(positionOption){
case TOP:
screenPos = new Point2D.Double(centreX, topY);
break;
case TOP_LEFT:
screenPos = new Point2D.Double(leftX, topY);
break;
case TOP_RIGHT:
screenPos = new Point2D.Double(rightX, topY);
break;
case BOTTOM:
screenPos = new Point2D.Double(centreX, bottomY);
break;
case BOTTOM_LEFT:
screenPos = new Point2D.Double(leftX, bottomY);
break;
case BOTTOM_RIGHT:
screenPos = new Point2D.Double(rightX, bottomY);
break;
case CENTRE:
screenPos = new Point2D.Double(centreX,centreY );
break;
case RIGHT:
// get text screen positioning, offsetting by a fraction of the font size and at least the point's pixel half-width
screenPos = new Point2D.Double(rightX, centreY);
break;
case LEFT:
// get text screen positioning, offsetting by a fraction of the font size and at least the point's pixel half-width
screenPos = new Point2D.Double(leftX, centreY);
break;
default:
throw new UnsupportedOperationException();
}
} else {
// get the world bitmap position
Point2D wbPos=null;
if(geom.isLineString()){
// draw text in the centre of the object, adjusting for text size
OnscreenGeometry cachedGeometry = DatastoreRenderer.getCachedGeometry(pnt.getGeometry(), converter, true);
if (cachedGeometry == null) {
return null;
}
wbPos = cachedGeometry.getLineStringMidPoint();
}
if(wbPos==null){
wbPos = geom.getWorldBitmapCentroid(converter);
}
if(wbPos!=null){
Rectangle2D view = converter.getViewportWorldBitmapScreenPosition();
screenPos = new Point2D.Double(wbPos.getX() - view.getMinX() - textSize.getX() / 2,
wbPos.getY() - view.getMinY() - textSize.getY() / 2);
}
}
return screenPos;
}
private TextLayout createTextLayout(String text, Font font, FontRenderContext frc){
RecentlyUsedCache cache = ApplicationCache.singleton().get(ApplicationCache.TEXT_LAYOUT_CACHE);
class TLKey{
final String text;
final Font font;
final FontRenderContext frc;
TLKey(String text, Font font, FontRenderContext frc) {
super();
this.text = text;
this.font = font;
this.frc = frc;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((font == null) ? 0 : font.hashCode());
result = prime * result + ((frc == null) ? 0 : frc.hashCode());
result = prime * result + ((text == null) ? 0 : text.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
TLKey other = (TLKey) obj;
if (font == null) {
if (other.font != null)
return false;
} else if (!font.equals(other.font))
return false;
if (frc == null) {
if (other.frc != null)
return false;
} else if (!frc.equals(other.frc))
return false;
if (text == null) {
if (other.text != null)
return false;
} else if (!text.equals(other.text))
return false;
return true;
}
}
TLKey key = new TLKey(text, font, frc);
TextLayout ret = (TextLayout)cache.get(key);
if(ret==null){
ret = new TextLayout(text, font, frc);
// no idea how large this object is! take a guess!
int assumedSize =5* 1024;
cache.put(key, ret, assumedSize);
}
return ret;
}
/**
* Do a quick and dirty check to see if space around the text is free.
* This check is inaccurate but quick...
* @param converter
* @param obj
* @param font
* @param textQuadtree
* @return
*/
private boolean approxQuadtreeCheck( LatLongToScreen converter,DrawableObject obj, Font font ,Quadtree textQuadtree, LabelPositionOption option){
Envelope envelope = getApproxQuadtreeCheckEnvelope(converter, obj, font, option);
if(envelope==null){
return false;
}
return isPositionFree(envelope, textQuadtree);
}
/**
* Get the envelope used for our approximate quadtree check
* @param converter
* @param obj
* @param font
* @param option
* @return
*/
private Envelope getApproxQuadtreeCheckEnvelope(LatLongToScreen converter, DrawableObject obj, Font font, LabelPositionOption option) {
// get rough size...
int approxWidth = font.getSize() * obj.getLabel().length();
int approxHeight = font.getSize();
approxWidth += NO_DRAW_BUFFER_AROUND_TEXT;
approxHeight += NO_DRAW_BUFFER_AROUND_TEXT;
approxWidth = Math.max(approxWidth, MIN_FREE_SPACE_TO_ATTEMPT_TEXT_DRAW.width);
approxHeight = Math.max(approxHeight, MIN_FREE_SPACE_TO_ATTEMPT_TEXT_DRAW.height);
Point2D screenPos = getScreenPos(converter, obj, font, new Point2D.Double(approxWidth,approxHeight),option);
if(screenPos==null){
return null;
}
Envelope envelope = new Envelope(screenPos.getX(),screenPos.getX() + approxWidth, screenPos.getY(),screenPos.getY() + approxHeight);
return envelope;
}
boolean renderDrawableText(Graphics2D g, LatLongToScreen converter, DrawableObject pnt, Quadtree textQuadtree) {
if (Strings.isEmpty(pnt.getLabel()) || pnt.getFontSize() < 0) {
return false;
}
// do an initial rough quadtree check with an assumed bounds as calculating proper bounds is slow
Font font = getFont((int)pnt.getFontSize());
LabelPositionOption option = getLabelPositionOption(pnt.getLabelPositioningOption());
if(option == LabelPositionOption.NO_LABEL){
return false;
}
boolean forceShowLabel = (pnt.getFlags() & UserRenderFlags.ALWAYS_SHOW_LABEL) == UserRenderFlags.ALWAYS_SHOW_LABEL;
// choose the option automatically
if(option == null){
double best = Double.POSITIVE_INFINITY;
for(LabelPositionOption test:AUTOMATIC_POSITIONS){
if(forceShowLabel){
// get the one with least overlap
Envelope envelope = getApproxQuadtreeCheckEnvelope(converter, pnt, font, test);
if(envelope!=null){
double measure = measureOverlap(envelope, textQuadtree);
if(measure < best){
option = test;
best = measure;
}
}
}else{
// get the first with no overlap
if(approxQuadtreeCheck(converter,pnt,font,textQuadtree, test)){
option = test;
break;
}
}
}
}
else{
// check position ok
if(!forceShowLabel){
if(!approxQuadtreeCheck(converter,pnt,font,textQuadtree, option)){
return false;
}
}
}
if(option==null){
return false;
}
// get initial bounds (not in correct screen position)
TextLayout textLayout = createTextLayout(pnt.getLabel(), font, g.getFontRenderContext());
Point2D.Double textSize = getSize(textLayout);
// get screen position from the point or geometry
Point2D screenPos = getScreenPos(converter, pnt, font, textSize, option);
if (screenPos == null) {
return false;
}
return drawTextLayout(font, screenPos, textLayout, textSize,pnt.getLabelColour(), g,forceShowLabel, textQuadtree);
}
/**
* Renders text in the bottom right corner; used for showing OSM copyright
* @param text
* @param fontSize
* @param g
* @param textQuadtree
*/
void renderInBottomCorner(String text, int fontSize,Graphics2D g,Quadtree textQuadtree, boolean rightCorner){
// Measure text
Font font = getFont(fontSize);
TextLayout textLayout =createTextLayout(text, font, g.getFontRenderContext());
Point2D.Double size = getSize(textLayout);
// Get bounds of the screen
Rectangle bounds = g.getClipBounds();
Point2D screenPos = new Point2D.Double(rightCorner? bounds.getWidth() - size.x - 2:0, bounds.getHeight() - size.y - 0);
drawTextLayout(font, screenPos, textLayout, size, null,g, true, textQuadtree);
}
private boolean isPositionFree(Envelope text,Quadtree textQuadtree){
@SuppressWarnings("unchecked")
List<Envelope> found = textQuadtree.query(text);
if (found != null) {
for (Envelope other : found) {
if (other.intersects(text)) {
return false;
}
}
}
return true;
}
private double measureOverlap(Envelope text,Quadtree textQuadtree){
double ret = 0;
@SuppressWarnings("unchecked")
List<Envelope> found = textQuadtree.query(text);
if (found != null) {
for (Envelope other : found) {
if (other.intersects(text)) {
Envelope overlap = other.intersection(text);
ret +=Math.max(0, overlap.getArea());
}
}
}
return ret;
}
/**
* @param font
* @param screenPos
* @param textLayout
* @param size
* @param g
* @param textQuadtree
* @return
*/
private boolean drawTextLayout(Font font, Point2D screenPos, TextLayout textLayout, Point2D.Double size,Color col, Graphics2D g,boolean skipQuadtreeCheck, Quadtree textQuadtree) {
// get bounds by getting the shape and translating to screen position
AffineTransform affineTransform = new AffineTransform();
affineTransform.translate((int) screenPos.getX(), (int) screenPos.getY() + size.getY() / 2);
Shape shape = textLayout.getOutline(affineTransform);
Rectangle2D onscreenBounds = shape.getBounds2D();
// save current hints and change to high quality rendering
Object oldAAHintVal = g.getRenderingHint(RenderingHints.KEY_ANTIALIASING);
Object oldRHintVal = g.getRenderingHint(RenderingHints.KEY_RENDERING);
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// test quadtree to see if we can draw here
Envelope envelope = new Envelope(onscreenBounds.getMinX(), onscreenBounds.getMaxX(), onscreenBounds.getMinY(), onscreenBounds.getMaxY());
envelope.expandBy(NO_DRAW_BUFFER_AROUND_TEXT, NO_DRAW_BUFFER_AROUND_TEXT);
boolean drawText =skipQuadtreeCheck? true: isPositionFree(envelope,textQuadtree);
if (drawText) {
// draw outline
if (font.getSize() < 20) {
g.setStroke(SMALL_INNER_TEXT_STROKE);
} else if (font.getSize() < 30) {
g.setStroke(MEDIUM_INNER_TEXT_STROKE);
} else {
g.setStroke(LARGE_INNER_TEXT_STROKE);
}
g.setColor(Color.WHITE);
g.draw(shape);
// draw inner
g.setColor(col!=null ? col : LABEL_FONT_COLOUR);
textLayout.draw(g, (int) screenPos.getX(), (int) (screenPos.getY() + size.getY() / 2));
textQuadtree.insert(envelope, envelope);
}
// set back original hints
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, oldAAHintVal);
g.setRenderingHint(RenderingHints.KEY_RENDERING, oldRHintVal);
return drawText;
}
/**
* @param textLayout
* @return
*/
private Point2D.Double getSize(TextLayout textLayout) {
Rectangle2D initialBounds = textLayout.getBounds();
Point2D.Double size = new Point2D.Double(initialBounds.getWidth(), initialBounds.getHeight());
return size;
}
/**
* @param pnt
* @return
*/
private Font getFont(int fontSize) {
// get correct Font
Font font = LABEL_FONT;
if (fontSize > 0) {
// Font is apparently a lightweight object (see http://stackoverflow.com/questions/6102602/java-awt-is-font-a-lightweight-object)
// so this should be OK.
font = new Font(Font.SANS_SERIF, Font.BOLD,fontSize);
}
return font;
}
}