RoleLabels.java

/* ========================================================================
 * PlantUML : a free UML diagram generator
 * ========================================================================
 *
 * (C) Copyright 2009-2024, Arnaud Roques
 *
 * Project Info:  https://plantuml.com
 * 
 * If you like this project or if you find it useful, you can support us at:
 * 
 * https://plantuml.com/patreon (only 1$ per month!)
 * https://plantuml.com/paypal
 * 
 * This file is part of PlantUML.
 *
 * PlantUML is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * PlantUML 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 General Public
 * License for more details.
 *
 * You should have received a copy of the GNU General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
 * USA.
 *
 *
 * Original Author:  Arnaud Roques
 *
 * 
 */
package net.sourceforge.plantuml.svek;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import net.sourceforge.plantuml.klimt.color.HColor;
import net.sourceforge.plantuml.klimt.creole.Display;
import net.sourceforge.plantuml.klimt.font.FontConfiguration;
import net.sourceforge.plantuml.klimt.geom.HorizontalAlignment;
import net.sourceforge.plantuml.klimt.geom.RectangleArea;
import net.sourceforge.plantuml.klimt.geom.VerticalAlignment;
import net.sourceforge.plantuml.klimt.geom.XDimension2D;
import net.sourceforge.plantuml.klimt.geom.XPoint2D;
import net.sourceforge.plantuml.klimt.shape.TextBlock;
import net.sourceforge.plantuml.klimt.shape.TextBlockUtils;
import net.sourceforge.plantuml.skin.Pragma;
import net.sourceforge.plantuml.skin.VisibilityModifier;
import net.sourceforge.plantuml.skin.rose.Rose;
import net.sourceforge.plantuml.style.ISkinParam;

/**
 * Role labels of an association (<code>A "1"/"-owner" --> B</code>), shared by
 * all the layout engines (Graphviz, Smetana and ELK).
 * <ul>
 * <li>{@link #create} builds the text block, turning a leading visibility
 * character into the same icon as the one used for class attributes.</li>
 * <li>{@link #getPosition} decides where the block is drawn: on the other side
 * of the line from the quantifier, and never on top of the line.</li>
 * </ul>
 */
public final class RoleLabels {

	private static final double GAP = 2;
	private static final double MIN_DISTANCE_FOR_DIRECTION = 15;
	private static final double SEARCH_STEP = 2;
	private static final double SEARCH_RANGE = 30;

	private RoleLabels() {
	}

	public static TextBlock create(String role, Pragma pragma, FontConfiguration font, ISkinParam skinParam) {
		// A role is a property of the class, so it gets the icons of the fields
		// (hollow), as the attributes do, and not the ones of the methods (filled).
		VisibilityModifier visibilityModifier = null;
		String text = role;
		if (skinParam.classAttributeIconSize() > 0) {
			visibilityModifier = VisibilityModifier.getVisibilityModifier(role, true);
			if (visibilityModifier != null)
				text = role.substring(1).trim();
		}

		TextBlock result = Display.getWithNewlines(pragma, text).create(font,
				skinParam.getDefaultTextAlignment(HorizontalAlignment.CENTER), skinParam);

		if (visibilityModifier != null) {
			final HColor fore = new Rose().getHtmlColor(skinParam, visibilityModifier.getForeground());
			TextBlock icon = visibilityModifier.getUBlock(skinParam.classAttributeIconSize(), fore, null, false);
			icon = TextBlockUtils.withMargin(icon, 0, 1, 2, 0);
			result = TextBlockUtils.mergeLR(icon, result, VerticalAlignment.CENTER);
		}
		return result;
	}

	/**
	 * Computes the top-left corner of the role label.
	 * 
	 * The role is preferably drawn on the other side of the line from the
	 * quantifier, at the same height (or at the same abscissa for a horizontal
	 * line). If this collides with one of the <code>obstacles</code> (typically the
	 * node the line is attached to: a line leaving a node diagonally can have its
	 * "other side" inside the node), the role is moved to the closest place where it has
	 * some room.
	 * 
	 * @param qDim          size of the quantifier, which is drawn at
	 *                      <code>quantifierPos</code> by the layout engine
	 * @param rDim          size of the role
	 * @param thisEndpoint  end of the line where the label is attached
	 * @param otherEndpoint the opposite end of the line
	 * @param pathSamples   points of the line, or <code>null</code> to use the
	 *                      straight segment between the two endpoints
	 * @param obstacles     areas to keep free, in the same coordinates as the
	 *                      other parameters (may be empty)
	 */
	public static XPoint2D getPosition(XDimension2D qDim, XDimension2D rDim, XPoint2D quantifierPos,
			XPoint2D thisEndpoint, XPoint2D otherEndpoint, Collection<XPoint2D> pathSamples,
			Collection<RectangleArea> obstacles) {
		final Collection<XPoint2D> samples = pathSamples != null ? pathSamples
				: sampleSegment(thisEndpoint, otherEndpoint);

		// Graphviz places the quantifier relatively to the direction of the line where
		// it leaves the node, not relatively to the direction from one end to the
		// other: a diagonal line can leave a node vertically and then bend.
		final XPoint2D towards = getPointAwayFrom(thisEndpoint, otherEndpoint, samples);
		final double dirX = towards.getX() - thisEndpoint.getX();
		final double dirY = towards.getY() - thisEndpoint.getY();

		if (Math.abs(dirX) + Math.abs(dirY) < 0.001)
			return new XPoint2D(quantifierPos.getX(), quantifierPos.getY() + qDim.getHeight());

		final XPoint2D preferred = getMirroredPosition(qDim, rDim, quantifierPos, thisEndpoint, dirX, dirY, samples);

		final List<RectangleArea> forbidden = new ArrayList<>(obstacles);
		forbidden.add(new RectangleArea(quantifierPos.getX(), quantifierPos.getY(),
				quantifierPos.getX() + qDim.getWidth(), quantifierPos.getY() + qDim.getHeight()));
		if (isFree(preferred, rDim, forbidden, null))
			return preferred;

		final XPoint2D alternative = searchFreePosition(qDim, rDim, quantifierPos, thisEndpoint, dirX, dirY, samples,
				forbidden);
		return alternative != null ? alternative : preferred;
	}

	private static XPoint2D getMirroredPosition(XDimension2D qDim, XDimension2D rDim, XPoint2D quantifierPos,
			XPoint2D thisEndpoint, double dirX, double dirY, Collection<XPoint2D> samples) {
		if (Math.abs(dirY) >= Math.abs(dirX)) {
			// Mostly vertical: mirror across the line X
			final double roleY = quantifierPos.getY();
			final double qCenterX = quantifierPos.getX() + qDim.getWidth() / 2;
			final double lineX = thisEndpoint.getX();
			double roleX;
			if (qCenterX < lineX) {
				roleX = lineX + GAP;
				// The line is not vertical: at the height of the label, it may be further
				// to the right than at its end.
				for (XPoint2D pt : samples)
					if (pt.getY() >= roleY - GAP && pt.getY() <= roleY + rDim.getHeight() + GAP)
						roleX = Math.max(roleX, pt.getX() + GAP);
			} else {
				roleX = lineX - rDim.getWidth() - GAP;
				for (XPoint2D pt : samples)
					if (pt.getY() >= roleY - GAP && pt.getY() <= roleY + rDim.getHeight() + GAP)
						roleX = Math.min(roleX, pt.getX() - rDim.getWidth() - GAP);
			}
			return new XPoint2D(roleX, roleY);
		}

		// Mostly horizontal: mirror across the line Y
		final double roleX = quantifierPos.getX();
		final double qCenterY = quantifierPos.getY() + qDim.getHeight() / 2;
		final double lineY = thisEndpoint.getY();
		double roleY;
		if (qCenterY < lineY) {
			roleY = lineY + GAP;
			for (XPoint2D pt : samples)
				if (pt.getX() >= roleX - GAP && pt.getX() <= roleX + rDim.getWidth() + GAP)
					roleY = Math.max(roleY, pt.getY() + GAP);
		} else {
			roleY = lineY - rDim.getHeight() - GAP;
			for (XPoint2D pt : samples)
				if (pt.getX() >= roleX - GAP && pt.getX() <= roleX + rDim.getWidth() + GAP)
					roleY = Math.min(roleY, pt.getY() - rDim.getHeight() - GAP);
		}
		return new XPoint2D(roleX, roleY);
	}

	/**
	 * Looks for the free place the closest to the mirror image of the quantifier
	 * across the line, in the neighbourhood of the end of the line.
	 */
	private static XPoint2D searchFreePosition(XDimension2D qDim, XDimension2D rDim, XPoint2D quantifierPos,
			XPoint2D endpoint, double dirX, double dirY, Collection<XPoint2D> samples,
			Collection<RectangleArea> forbidden) {
		final double length = Math.hypot(dirX, dirY);
		final double tx = dirX / length;
		final double ty = dirY / length;

		final double qx = quantifierPos.getX() + qDim.getWidth() / 2;
		final double qy = quantifierPos.getY() + qDim.getHeight() / 2;
		final double along = (qx - endpoint.getX()) * tx + (qy - endpoint.getY()) * ty;
		final double idealX = 2 * (endpoint.getX() + tx * along) - qx;
		final double idealY = 2 * (endpoint.getY() + ty * along) - qy;

		XPoint2D best = null;
		double bestDistance = Double.MAX_VALUE;
		for (double x = endpoint.getX() - rDim.getWidth() - SEARCH_RANGE; x <= endpoint.getX()
				+ SEARCH_RANGE; x += SEARCH_STEP)
			for (double y = endpoint.getY() - rDim.getHeight() - SEARCH_RANGE; y <= endpoint.getY()
					+ SEARCH_RANGE; y += SEARCH_STEP) {
				// Sliding along the line is preferred to moving away from it
				final double deltaX = x + rDim.getWidth() / 2 - idealX;
				final double deltaY = y + rDim.getHeight() / 2 - idealY;
				final double distance = Math.abs(deltaX * tx + deltaY * ty) + 2 * Math.abs(-deltaX * ty + deltaY * tx);
				if (distance >= bestDistance)
					continue;

				final XPoint2D candidate = new XPoint2D(x, y);
				if (isFree(candidate, rDim, forbidden, samples)) {
					best = candidate;
					bestDistance = distance;
				}
			}
		return best;
	}

	private static boolean isFree(XPoint2D pos, XDimension2D dim, Collection<RectangleArea> forbidden,
			Collection<XPoint2D> samples) {
		final double x1 = pos.getX();
		final double y1 = pos.getY();
		final double x2 = x1 + dim.getWidth();
		final double y2 = y1 + dim.getHeight();
		for (RectangleArea area : forbidden)
			if (x1 < area.getMaxX() && x2 > area.getMinX() && y1 < area.getMaxY() && y2 > area.getMinY())
				return false;

		if (samples != null)
			for (XPoint2D pt : samples)
				if (pt.getX() >= x1 - GAP && pt.getX() <= x2 + GAP && pt.getY() >= y1 - GAP
						&& pt.getY() <= y2 + GAP)
					return false;

		return true;
	}

	/**
	 * The point of the line the closest to the endpoint, among the ones that are
	 * far enough from it to give a meaningful direction.
	 */
	private static XPoint2D getPointAwayFrom(XPoint2D endpoint, XPoint2D otherEndpoint, Collection<XPoint2D> samples) {
		XPoint2D result = otherEndpoint;
		double best = Double.MAX_VALUE;
		for (XPoint2D pt : samples) {
			final double distance = pt.distance(endpoint);
			if (distance >= MIN_DISTANCE_FOR_DIRECTION && distance < best) {
				best = distance;
				result = pt;
			}
		}
		return result;
	}

	private static Collection<XPoint2D> sampleSegment(XPoint2D p1, XPoint2D p2) {
		final List<XPoint2D> result = new ArrayList<>();
		final int steps = Math.max(1, (int) Math.ceil(p1.distance(p2) / 2));
		for (int i = 0; i <= steps; i++) {
			final double t = i / (double) steps;
			result.add(new XPoint2D(p1.getX() + (p2.getX() - p1.getX()) * t, p1.getY() + (p2.getY() - p1.getY()) * t));
		}
		return result;
	}

}