PlayingSpaceWithParticipants.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.sequencediagram.teoz;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import net.sourceforge.plantuml.asciiverse.AsciiBlock;
import net.sourceforge.plantuml.asciiverse.InfinitePlan;
import net.sourceforge.plantuml.sequencediagram.Event;
import net.sourceforge.plantuml.sequencediagram.Message;
import net.sourceforge.plantuml.sequencediagram.MessageExo;
import net.sourceforge.plantuml.sequencediagram.Participant;
import net.sourceforge.plantuml.klimt.UClip;
import net.sourceforge.plantuml.klimt.UTranslate;
import net.sourceforge.plantuml.klimt.color.HColors;
import net.sourceforge.plantuml.klimt.drawing.UGraphic;
import net.sourceforge.plantuml.klimt.font.StringBounder;
import net.sourceforge.plantuml.klimt.geom.VerticalAlignment;
import net.sourceforge.plantuml.klimt.geom.XDimension2D;
import net.sourceforge.plantuml.klimt.shape.TextBlockMemoized;
import net.sourceforge.plantuml.klimt.shape.ULine;
import net.sourceforge.plantuml.real.Real;
import net.sourceforge.plantuml.real.RealOrigin;
import net.sourceforge.plantuml.real.RealUtils;
import net.sourceforge.plantuml.skin.Context2D;
import net.sourceforge.plantuml.skin.SimpleContext2D;
public class PlayingSpaceWithParticipants extends TextBlockMemoized implements AsciiBlock {
private final PlayingSpace playingSpace;
private int pageIndex;
public PlayingSpaceWithParticipants(PlayingSpace playingSpace) {
this.playingSpace = Objects.requireNonNull(playingSpace);
}
@Override
public XDimension2D calculateDimensionSlow(StringBounder stringBounder) {
final double width = playingSpace.getMaxX(stringBounder).getCurrentValue()
- playingSpace.getMinX(stringBounder).getCurrentValue();
// getPreferredHeight() triggers the layout pass that positions the tiles,
// so it must be called before getYMin()/getYMax()
final double fullHeight = playingSpace.getPreferredHeight(stringBounder);
final double pageHeight = getYMax(fullHeight) - getYMin();
final int factor = playingSpace.isShowFootbox() ? 2 : 1;
final double height = pageHeight + factor * playingSpace.getLivingSpaces().getHeadHeight(stringBounder);
return new XDimension2D(width, height);
}
// Warning: both methods below rely on the tiles' YGauge being solved: they are
// meaningful only after a layout pass, that is after
// playingSpace.getPreferredHeight() has been called at least once.
// Like in Puma (see PageSplitter), consecutive pages slightly overlap on the
// newpage separator, so that the dashed line is visible at the bottom of the
// page ending there and at the top of the page starting there.
private double getYMin() {
if (pageIndex == 0)
return 0;
return playingSpace.getNewpageTiles().get(pageIndex - 1).getYGauge().getMin().getCurrentValue();
}
private double getYMax(double fullHeight) {
final List<NewpageTile> newpages = playingSpace.getNewpageTiles();
if (pageIndex >= newpages.size())
return fullHeight;
final NewpageTile newpage = newpages.get(pageIndex);
return Math.min(newpage.getYGauge().getMin().getCurrentValue() + newpage.getPreferredHeight(), fullHeight);
}
@Override
public void asciiDraw(InfinitePlan plan) {
// Minimal ASCII rendering, in the spirit of drawU(): the participant
// heads, then the lifelines, then the messages drawn by their own tiles
// (CommunicationTile.asciiDraw), reusing the Teoz tile tree as topology.
final LivingSpaces livingSpaces = playingSpace.getLivingSpaces();
final List<Participant> participants = new ArrayList<>(livingSpaces.participants());
if (participants.size() == 0)
return;
// --- X layout: a dedicated ASCII Real graph, convention 1.0 = 1 char,
// on its own RealLine, separate from the pixel xorigin. Chain each
// participant's asciiPosB off a shared origin (exactly like the pixel
// createMainTile() chains posB off xorigin), let the participants and
// the tiles declare their ASCII constraints, then compile once. Every
// injected delta is an integer, so the resolved columns are exact
// integers (no quantization).
final RealOrigin asciiXOrigin = RealUtils.createOrigin();
Real asciiCurrent = asciiXOrigin.addAtLeast(0);
for (Participant p : participants) {
final LivingSpace ls = livingSpaces.get(p);
ls.setAsciiPosB(asciiCurrent);
asciiCurrent = asciiCurrent.addAtLeast(p.asciiDimension().getWidth());
}
livingSpaces.asciiAddConstraints();
for (Tile tile : playingSpace.getTiles())
tile.asciiAddConstraints();
addAsciiParallelSiblingDisjointConstraints();
asciiXOrigin.compileNow();
// Heads (top boxes), drawn by the participants themselves at their
// resolved left column.
for (Participant p : participants)
p.asciiDraw(plan.move((int) livingSpaces.get(p).getAsciiLeftColumn().getCurrentValue(), 0));
final int headBottom = 2;
int y = headBottom + 1;
// Messages, drawn by their own tiles (row 0 = label start, arrow row
// right after โ see CommunicationTile.asciiDraw()/asciiDimension()).
// Row assignment goes through GroupingTile.computeAsciiLayout() (shared
// with GroupingTile's own body-stacking loop, not duplicated here): a
// flat per-tile sum wastes space on a `&` run, since two parallel
// messages would stack sequentially instead of sharing a row span โ the
// same double-counting bug computeBodyHeight() fixed on the pixel side
// (see ASCIIVERSE.md ยง32.7 bug 1, and GroupingTile.computeAsciiLayout()'s
// own comment for exactly what this fix does and does not claim to fix).
// Every top-level tile must have a real asciiDraw()/asciiDimension()
// override to reach this loop โ there is no catch
// (UnsupportedOperationException)
// here (anymore): a tile that hasn't been migrated to ASCII yet crashes
// loudly instead of being silently skipped, which is the point (see
// EmptyTile for the fix this policy forced: it needed a real, if trivial,
// override rather than falling through to the AsciiBlock "not migrated"
// default).
final List<Tile> topTiles = playingSpace.getTiles();
final GroupingTile.AsciiLayout layout = GroupingTile.computeAsciiLayout(topTiles);
for (int i = 0; i < topTiles.size(); i++)
topTiles.get(i).asciiDraw(plan.move(0, y + layout.rowOf[i]));
y += layout.totalHeight;
final int bottomY = y;
// Footer boxes, mirroring drawU()'s playingSpace.isShowFootbox() branch:
// each participant draws itself a second time, right below the lifelines.
if (playingSpace.isShowFootbox())
for (Participant p : participants)
p.asciiDraw(plan.move((int) livingSpaces.get(p).getAsciiLeftColumn().getCurrentValue(), bottomY));
// Lifelines from the heads down to the last message, at each
// participant's resolved lifeline column, only into empty cells so the
// arrows already drawn are never overwritten.
final char vLine = plan.getVLineChar();
for (Participant p : participants) {
final int lx = (int) livingSpaces.get(p).getAsciiLifeColumn().getCurrentValue();
for (int yy = headBottom + 1; yy < bottomY; yy++)
if (plan.getCharAt(lx, yy) == ' ')
plan.move(lx, yy).drawChar(vLine);
plan.move(lx, headBottom).muteLifelineBelowBox();
plan.move(lx, bottomY).muteLifelineAboveBox();
}
}
@Override
public void drawU(UGraphic ug) {
final StringBounder stringBounder = ug.getStringBounder();
final Context2D context = new SimpleContext2D(false);
final LivingSpaces livingSpaces = playingSpace.getLivingSpaces();
final double headHeight = livingSpaces.getHeadHeight(stringBounder);
// getPreferredHeight() triggers the layout pass that positions the tiles,
// so it must be called before getYMin()/getYMax()
final double fullHeight = playingSpace.getPreferredHeight(stringBounder);
final double ymin = getYMin();
final double ymax = getYMax(fullHeight);
final double pageHeight = ymax - ymin;
// The body is translated so that the current page starts right below the
// heads, and clipped so that the content of the other pages stays hidden.
UGraphic ugBody = ug.apply(UTranslate.dy(headHeight - ymin));
if (playingSpace.getNbPages() > 1) {
final UClip clip = new UClip(-1000, ymin, Double.MAX_VALUE, pageHeight + 1);
ugBody = ugBody.apply(clip);
}
playingSpace.drawBackground(ugBody);
// Lifelines and liveboxes use absolute positions over the whole diagram:
// they must be drawn with the full height, the clip trims them to the page
livingSpaces.drawLifeLines(ugBody, fullHeight, context);
livingSpaces.drawHeads(ug, context, VerticalAlignment.BOTTOM);
if (playingSpace.isShowFootbox())
livingSpaces.drawHeads(ug.apply(UTranslate.dy(pageHeight + headHeight)), context, VerticalAlignment.TOP);
playingSpace.drawForeground(ugBody);
// drawNewPages(ug.apply(UTranslate.dy(headHeight)));
}
public Real getMinX(StringBounder stringBounder) {
return playingSpace.getMinX(stringBounder);
}
public int getNbPages() {
return playingSpace.getNbPages();
}
public void setIndex(int index) {
if (index != this.pageIndex) {
this.pageIndex = index;
invalidateDimensionCache();
}
}
private List<Double> yNewPages() {
return playingSpace.yNewPages();
}
// ASCII counterpart of PlayingSpace.addParallelSiblingDisjointConstraints()
// -- same reasoning, same clustering rule, mirrored on the ASCII Real
// graph via getAsciiMinX()/getAsciiMaxX() instead of getMinX()/getMaxX().
// A GroupingTile's ASCII footprint already includes its own
// ASCII_FRAME_MARGIN, so this closes the ASCII half of the same bug (see
// GroupingTile.getAsciiMinX()/getAsciiMaxX() and the `altpar_001`/
// `altpar_007` investigation): without it, a `&`-parallel self-message's
// loop/label and a sibling group's frame margin can each be individually
// correct yet still overlap, because neither one knows about the other.
private void addAsciiParallelSiblingDisjointConstraints() {
final List<Tile> cluster = new ArrayList<>();
for (Tile tile : playingSpace.getTiles()) {
if (GroupingTile.isParallel(tile)) {
for (Tile other : cluster)
ensureAsciiDisjoint(other, tile);
cluster.add(tile);
} else {
cluster.clear();
cluster.add(tile);
}
}
}
private void ensureAsciiDisjoint(Tile a, Tile b) {
final Real aMin = a.getAsciiMinX();
final Real aMax = a.getAsciiMaxX();
final Real bMin = b.getAsciiMinX();
final Real bMax = b.getAsciiMaxX();
if (aMin == null || aMax == null || bMin == null || bMax == null)
return;
// Mirrors PlayingSpace.ensureDisjoint()'s pixel-side reasoning exactly
// (see its extensive comment): direction must be decided from a safe,
// non-aggregated Real (a LivingSpace's own getAsciiPosB(), never a
// GroupingTile's getAsciiMinX()/getAsciiMaxX()), and two tiles sharing a
// participant anchor must be skipped entirely -- forcing disjointness
// between two footprints that share their own anchor point can demand
// anchor.posB >= anchor.posB + something, which the ASCII RealLine can
// no more satisfy than the pixel one (same "Infinite Loop?" failure mode
// reproduced with `alice->alice:A` & `alice->bob:B`).
final LivingSpace aAnchor = findAnchorLivingSpace(a);
final LivingSpace bAnchor = findAnchorLivingSpace(b);
if (aAnchor == null || bAnchor == null || aAnchor == bAnchor)
return;
if (aAnchor.getAsciiPosB().getCurrentValue() <= bAnchor.getAsciiPosB().getCurrentValue())
bMin.ensureBiggerThan(aMax);
else
aMin.ensureBiggerThan(bMax);
}
// ASCII counterpart of PlayingSpace.findAnchorLivingSpace() -- identical
// logic, kept as a separate copy rather than shared/static since it reads
// playingSpace.getLivingSpaces() (an instance, not a static context) and
// GroupingTile's child list the same way the pixel version does.
private LivingSpace findAnchorLivingSpace(Tile tile) {
final Event event = tile.getEvent();
final LivingSpaces livingSpaces = playingSpace.getLivingSpaces();
if (event instanceof Message)
return livingSpaces.get(((Message) event).getParticipant1());
if (event instanceof MessageExo)
return livingSpaces.get(((MessageExo) event).getParticipant());
if (tile instanceof GroupingTile)
for (Tile child : ((GroupingTile) tile).getChildTilesForAnchor()) {
final LivingSpace found = findAnchorLivingSpace(child);
if (found != null)
return found;
}
return null;
}
private void drawNewPages(UGraphic ug) {
ug = ug.apply(HColors.BLUE);
for (Double change : yNewPages()) {
if (change == 0 || change == Double.MAX_VALUE) {
continue;
}
ug.apply(UTranslate.dy(change)).draw(ULine.hline(100));
}
}
}