TextAnimation.java

package org.opentrafficsim.draw;

import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.Shape;
import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.ImageObserver;
import java.io.Serializable;
import java.rmi.RemoteException;
import java.util.function.Supplier;

import org.djutils.draw.Oriented;
import org.djutils.draw.bounds.Bounds2d;
import org.djutils.draw.line.Polygon2d;
import org.djutils.draw.point.OrientedPoint2d;
import org.djutils.draw.point.Point2d;
import org.opentrafficsim.base.geometry.OtsLocatable;

import nl.tudelft.simulation.dsol.animation.d2.Renderable2d;
import nl.tudelft.simulation.language.d2.Angle;
import nl.tudelft.simulation.naming.context.Contextualized;

/**
 * Display a text for another Locatable object.
 * <p>
 * Copyright (c) 2013-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
 * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
 * </p>
 * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
 * @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
 * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
 * @param <L> locatable type
 * @param <T> text animation type
 */
public abstract class TextAnimation<L extends OtsLocatable, T extends TextAnimation<L, T>> implements OtsLocatable, Serializable
{
    /** */
    private static final long serialVersionUID = 20161211L;

    /** The object for which the text is displayed. */
    private final L source;

    /** The text to display. */
    private Supplier<String> text;

    /** The horizontal movement of the text, in meters. */
    private float dx;

    /** The vertical movement of the text, in meters. */
    private float dy;

    /** Whether to center or not. */
    private final TextAlignment textAlignment;

    /** The color of the text. */
    private Color color;

    /** FontSize the size of the font; default = 2.0 (meters). */
    private final float fontSize;

    /** Minimum font size to trigger scaling. */
    private final float minFontSize;

    /** Maximum font size to trigger scaling. */
    private final float maxFontSize;

    /** The animation implementation. */
    private final AnimationImpl animationImpl;

    /** The font. */
    private Font font;

    /** Access to the current background color. */
    private final ContrastToBackground background;

    /** Render dependent on font scale. */
    private final ScaleDependentRendering scaleDependentRendering;

    /** Whether the location is dynamic. */
    private boolean dynamic = false;

    /** Location of this text. */
    private OrientedPoint2d location;

    /**
     * Construct a new TextAnimation.
     * @param source the object for which the text is displayed
     * @param text the text to display
     * @param dx the horizontal movement of the text, in meters
     * @param dy the vertical movement of the text, in meters
     * @param textAlignment where to place the text
     * @param color the color of the text
     * @param fontSize the size of the font; default = 2.0 (meters)
     * @param minFontSize minimum font size resulting from scaling
     * @param maxFontSize maximum font size resulting from scaling
     * @param contextualized context provider.
     * @param background allows querying the background color and adaptation of the actual color of the text to ensure contrast
     * @param scaleDependentRendering suppress rendering when font scale is too small
     */
    @SuppressWarnings("checkstyle:parameternumber")
    public TextAnimation(final L source, final Supplier<String> text, final float dx, final float dy,
            final TextAlignment textAlignment, final Color color, final float fontSize, final float minFontSize,
            final float maxFontSize, final Contextualized contextualized, final ContrastToBackground background,
            final ScaleDependentRendering scaleDependentRendering)
    {
        this.source = source;
        this.text = text;
        this.dx = dx;
        this.dy = dy;
        this.textAlignment = textAlignment;
        this.color = color;
        this.fontSize = fontSize;
        this.minFontSize = minFontSize;
        this.maxFontSize = maxFontSize;
        this.background = background;
        this.scaleDependentRendering = scaleDependentRendering;

        this.font = new Font("SansSerif", Font.PLAIN, 2);
        if (this.fontSize != 2.0f)
        {
            this.font = this.font.deriveFont(this.fontSize);
        }

        this.animationImpl = new AnimationImpl(this, contextualized);
    }

    /**
     * Construct a new TextAnimation without contrast to background protection and no minimum font scale.
     * @param source the object for which the text is displayed
     * @param text the text to display
     * @param dx the horizontal movement of the text, in meters
     * @param dy the vertical movement of the text, in meters
     * @param textAlignment where to place the text
     * @param color the color of the text
     * @param fontSize the size of the font; default = 2.0 (meters)
     * @param minFontSize minimum font size resulting from scaling
     * @param maxFontSize maximum font size resulting from scaling
     * @param contextualized context provider
     * @param scaleDependentRendering render text only when bigger than minimum scale
     */
    @SuppressWarnings("checkstyle:parameternumber")
    public TextAnimation(final L source, final Supplier<String> text, final float dx, final float dy,
            final TextAlignment textAlignment, final Color color, final float fontSize, final float minFontSize,
            final float maxFontSize, final Contextualized contextualized, final ScaleDependentRendering scaleDependentRendering)
    {
        this(source, text, dx, dy, textAlignment, color, fontSize, minFontSize, maxFontSize, contextualized, null,
                scaleDependentRendering);
    }

    /**
     * @param source the object for which the text is displayed
     * @param text the text to display
     * @param dx the horizontal movement of the text, in meters
     * @param dy the vertical movement of the text, in meters
     * @param textAlignment where to place the text
     * @param color the color of the text
     * @param contextualized context provider
     * @param scaleDependentRendering render text only when bigger than minimum scale
     */
    public TextAnimation(final L source, final Supplier<String> text, final float dx, final float dy,
            final TextAlignment textAlignment, final Color color, final Contextualized contextualized,
            final ScaleDependentRendering scaleDependentRendering)
    {
        this(source, text, dx, dy, textAlignment, color, 2.0f, 12.0f, 50f, contextualized, scaleDependentRendering);
    }

    /**
     * Sets whether the location of this text is dynamic.
     * @param dynamic whether the location of this text is dynamic.
     * @return for method chaining.
     */
    @SuppressWarnings("unchecked")
    public T setDynamic(final boolean dynamic)
    {
        this.dynamic = dynamic;
        return (T) this;
    }

    @Override
    public OrientedPoint2d getLocation()
    {
        if (this.location == null || this.dynamic)
        {
            Point2d p = this.source.getLocation();
            if (p instanceof Oriented)
            {
                // draw not upside down.
                double a = Angle.normalizePi(((Oriented<?>) p).getDirZ());
                if (a > Math.PI / 2.0 || a < -0.99 * Math.PI / 2.0)
                {
                    a += Math.PI;
                }
                this.location = new OrientedPoint2d(p, a);
            }
            else
            {
                this.location = new OrientedPoint2d(p, 0.0);
            }
        }
        return this.location;
    }

    @Override
    public final Bounds2d getBounds()
    {
        return new Bounds2d(2.0, 2.0);
    }

    @Override
    public Polygon2d getContour()
    {
        return new Polygon2d(new double[] {-1.0, 1.0, 1.0, -1.0}, new double[] {-1.0, -1.0, 1.0, 1.0});
    }

    /**
     * paint() method so it can be overridden or extended.
     * @param graphics the graphics object
     * @param observer the observer
     */
    @SuppressWarnings("checkstyle:designforextension")
    public void paint(final Graphics2D graphics, final ImageObserver observer)
    {
        double scale = Math.sqrt(graphics.getTransform().getDeterminant());
        Rectangle2D scaledFontRectangle;
        String str = this.text.get();
        synchronized (this.font)
        {
            if (!this.scaleDependentRendering.isRendered(scale))
            {
                return;
            }
            if (scale < this.minFontSize / this.fontSize)
            {
                graphics.setFont(this.font.deriveFont((float) (this.minFontSize / scale)));
                FontMetrics fm = graphics.getFontMetrics();
                scaledFontRectangle = fm.getStringBounds(str, graphics);
            }
            else if (scale > this.maxFontSize / this.fontSize)
            {
                graphics.setFont(this.font.deriveFont((float) (this.maxFontSize / scale)));
                FontMetrics fm = graphics.getFontMetrics();
                scaledFontRectangle = fm.getStringBounds(str, graphics);
            }
            else
            {
                graphics.setFont(this.font);
                FontMetrics fm = graphics.getFontMetrics();
                scaledFontRectangle = fm.getStringBounds(str, graphics);
            }
            Color useColor = this.color;
            if (null != this.background && isSimilar(useColor, this.background.getBackgroundColor()))
            {
                // Construct an alternative color
                if (Color.BLACK.equals(useColor))
                {
                    useColor = Color.WHITE;
                }
                else
                {
                    useColor = Color.BLACK;
                }
            }

            float dxText =
                    this.textAlignment.equals(TextAlignment.LEFT) ? 0.0f : this.textAlignment.equals(TextAlignment.CENTER)
                            ? (float) -scaledFontRectangle.getWidth() / 2.0f : (float) -scaledFontRectangle.getWidth();
            Object antialias = graphics.getRenderingHint(RenderingHints.KEY_ANTIALIASING);
            graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            if (null != this.background)
            {
                // Draw transparent rectangle with background color to makes sure all of the text is visible, even when it is
                // drawn outside of the bounds of the object that supplies the background color, or on parts of the object that
                // have a different color (e.g. driver dot, brake lights, etc.).
                double r = scaledFontRectangle.getHeight() / 2.0; // rounding
                double dh = scaledFontRectangle.getHeight() / 5.0; // baseline shift
                Shape s = new RoundRectangle2D.Double(this.dx - scaledFontRectangle.getWidth() - dxText,
                        this.dy + dh - scaledFontRectangle.getHeight(), scaledFontRectangle.getWidth(),
                        scaledFontRectangle.getHeight(), r, r);
                Color bg = this.background.getBackgroundColor();
                graphics.setColor(new Color(bg.getRed(), bg.getGreen(), bg.getBlue(), 92));
                graphics.fill(s);
            }
            graphics.setColor(useColor);
            graphics.drawString(str, dxText + this.dx, -this.dy);

            graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, antialias);
        }
    }

    /**
     * Returns whether two colors are similar.
     * @param color1 color 1.
     * @param color2 color 2.
     * @return whether two colors are similar.
     */
    private boolean isSimilar(final Color color1, final Color color2)
    {
        int r = color1.getRed() - color2.getRed();
        int g = color1.getGreen() - color2.getGreen();
        int b = color1.getBlue() - color2.getBlue();
        return r * r + g * g + b * b < 2000;
        // this threshold may need to be tweaked, it used to be color.equals(color) which is too narrow
    }

    /**
     * Destroy the text animation.
     * @param contextProvider the object with a Context
     */
    public final void destroy(final Contextualized contextProvider)
    {
        this.animationImpl.destroy(contextProvider);
    }

    /**
     * Retrieve the source.
     * @return the source
     */
    protected final L getSource()
    {
        return this.source;
    }

    /**
     * Retrieve dx.
     * @return the value of dx
     */
    protected final float getDx()
    {
        return this.dx;
    }

    /**
     * Retrieve dy.
     * @return the value of dy
     */
    protected final float getDy()
    {
        return this.dy;
    }

    /**
     * Sets a new offset.
     * @param x dx
     * @param y dy
     */
    protected final void setXY(final float x, final float y)
    {
        this.dx = x;
        this.dy = y;
    }

    @Override
    public double getZ() throws RemoteException
    {
        return DrawLevel.LABEL.getZ();
    }

    /**
     * Retrieve the text alignment.
     * @return the text alignment
     */
    protected final TextAlignment getTextAlignment()
    {
        return this.textAlignment;
    }

    /**
     * Retrieve the font size.
     * @return the font size
     */
    protected final float getFontSize()
    {
        return this.fontSize;
    }

    /**
     * Retrieve the font.
     * @return the font
     */
    protected final Font getFont()
    {
        return this.font;
    }

    /**
     * Retrieve the current text.
     * @return the current text
     */
    protected final String getText()
    {
        return this.text.get();
    }

    /**
     * Update the text.
     * @param text the new text
     */
    public final void setText(final Supplier<String> text)
    {
        this.text = text;
    }

    /**
     * Retrieve the current color.
     * @return the current color
     */
    protected final Color getColor()
    {
        return this.color;
    }

    /**
     * Update the color.
     * @param color the new color
     */
    protected final void setColor(final Color color)
    {
        this.color = color;
    }

    /**
     * Retrieve the current flip status.
     * @return the current flip status
     */
    public final boolean isFlip()
    {
        return this.animationImpl.isFlip();
    }

    /**
     * Update the flip status.
     * @param flip the new flip status
     */
    public final void setFlip(final boolean flip)
    {
        this.animationImpl.setFlip(flip);
    }

    /**
     * Retrieve the current rotation status.
     * @return the current rotation status
     */
    public final boolean isRotate()
    {
        return this.animationImpl.isRotate();
    }

    /**
     * Update the rotation status.
     * @param rotate the new rotation status
     */
    public final void setRotate(final boolean rotate)
    {
        this.animationImpl.setRotate(rotate);

    }

    /**
     * Retrieve the current scale status.
     * @return the current scale status
     */
    public final boolean isScale()
    {
        return this.animationImpl.isScale();
    }

    /**
     * Update the scale status.
     * @param scale the new scale status
     */
    public final void setScale(final boolean scale)
    {
        this.animationImpl.setScale(scale);
    }

    /**
     * Retrieve the current translate status.
     * @return the current translate status
     */
    public final boolean isTranslate()
    {
        return this.animationImpl.isTranslate();
    }

    /**
     * Update the translate status.
     * @param translate the new translate status
     */
    public final void setTranslate(final boolean translate)
    {
        this.animationImpl.setTranslate(translate);
    }

    /**
     * The implementation of the text animation.
     * <p>
     * Copyright (c) 2013-2024 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved.
     * <br>
     * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
     * </p>
     * @author <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
     * @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
     * @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
     */
    private static class AnimationImpl extends Renderable2d<TextAnimation<?, ?>>
    {
        /** */
        private static final long serialVersionUID = 20170400L;

        /**
         * Construct a new AnimationImpl.
         * @param source the source
         * @param contextualized context provider.
         */
        AnimationImpl(final TextAnimation<?, ?> source, final Contextualized contextualized)
        {
            super(source, contextualized);
        }

        @Override
        public final void paint(final Graphics2D graphics, final ImageObserver observer)
        {
            getSource().paint(graphics, observer);
        }

        @Override
        public boolean contains(final Point2d pointWorldCoordinates, final Bounds2d extent)
        {
            return false;
        }

        @Override
        public final String toString()
        {
            return "TextAnimation.AnimationImpl []";
        }
    }

    /**
     * Retrieve the scale dependent rendering qualifier (used in cloning).
     * @return the rendering qualifier of this TextAnimation
     */
    protected ScaleDependentRendering getScaleDependentRendering()
    {
        return this.scaleDependentRendering;
    }

    /**
     * Interface to obtain the color of the background.
     */
    public interface ContrastToBackground
    {
        /**
         * Retrieve the color of the background.
         * @return the (current) color of the background
         */
        Color getBackgroundColor();
    }

    /**
     * Determine if a Feature object should be rendered.
     */
    public interface ScaleDependentRendering
    {
        /**
         * Determine if a Text should be rendered, depending on the scale.
         * @param scale the current font scale
         * @return true if the text should be rendered at the scale; false if the text should not be rendered at the scale
         */
        boolean isRendered(double scale);
    }

    /** Always render the Text. */
    public static final ScaleDependentRendering RENDERALWAYS = new ScaleDependentRendering()
    {
        @Override
        public boolean isRendered(final double scale)
        {
            return true;
        }
    };

    /** Don't render texts when smaller than 1. */
    public static final ScaleDependentRendering RENDERWHEN1 = new ScaleDependentRendering()
    {
        @Override
        public boolean isRendered(final double scale)
        {
            return scale >= 1.0;
        }
    };

    /** Don't render texts when smaller than 2. */
    public static final ScaleDependentRendering RENDERWHEN10 = new ScaleDependentRendering()
    {
        @Override
        public boolean isRendered(final double scale)
        {
            return scale >= 0.1;
        }
    };

    /** Don't render texts when smaller than 2. */
    public static final ScaleDependentRendering RENDERWHEN100 = new ScaleDependentRendering()
    {
        @Override
        public boolean isRendered(final double scale)
        {
            return scale >= 0.01;
        }
    };

}