Stop the Sankey ribbons braiding: split, then rejoin

Ribbons crossed over each other on their way to the outcome column.
Every funnel stage sits alone in its column at the same height, so
ordering a node's ribbon slots by the other end's node height tied,
and the tie-break ordered them against the geometry. Early drop-offs
also left from mid-node, above later stages' drop-offs, and backward
history moves were drawn right to left over everything.

- Count one left-to-right path per application: the funnel stages it
  reached, then its current stage if that is an outcome. Ghosted then
  Rejected counts only as Rejected; moving back from Offer keeps Offer.
- Lane-based routing: drop-offs travel in lanes below each column's
  node, links skipping a stage travel above it; stacks are top-aligned
  and the whole block is centered. Every ribbon crossing a gap between
  columns shares its x endpoints, and slot/lane orders are kept equal at
  both ends of every gap, so ribbons can only cross where they rejoin
  the outcome nodes. Drop-off lanes stay level rather than rising.
- Outcomes are ordered by the average stage their ribbons come from,
  which keeps those last crossings to a minimum.
- sankeylayouttest now parses the drawn pathData and fails if any two
  ribbons overlap (the previous layout overlapped by up to 150px on the
  same data); adds forward-path counting and content-centering tests,
  replacing the slot-order and per-column-centering tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk
This commit is contained in:
2026-09-11 15:42:13 -05:00
co-authored by Claude Opus 5
parent 260704cf9b
commit 18dd49aaba
2 changed files with 818 additions and 290 deletions
+265 -31
View File
@@ -8,10 +8,13 @@
#include "jobsdatabase.h" #include "jobsdatabase.h"
#include "sankeymodel.h" #include "sankeymodel.h"
#include <QPointF>
#include <QRegularExpression>
#include <QSqlDatabase> #include <QSqlDatabase>
#include <QSqlQuery> #include <QSqlQuery>
#include <QTemporaryDir> #include <QTemporaryDir>
#include <QtTest> #include <QtTest>
#include <limits>
#include <memory> #include <memory>
// SankeyModel always opens JobsDatabase::defaultPath(), so each test points // SankeyModel always opens JobsDatabase::defaultPath(), so each test points
@@ -173,49 +176,115 @@ private Q_SLOTS:
} }
} }
void ribbonSlotsFollowEndpointHeights() void ribbonsDoNotCross_data()
{
QTest::addColumn<int>("seed");
QTest::newRow("dense pipeline") << int(DenseSeed);
QTest::newRow("links skipping stages") << int(SkipSeed);
QTest::newRow("back and sideways moves") << int(BackMoveSeed);
}
// The "braid" bug: ribbons twisting over each other. Checked on the drawn
// geometry itself: no two ribbons may overlap anywhere between their ends.
void ribbonsDoNotCross()
{
QFETCH(int, seed);
JobsDatabase db;
seedPipeline(db, seed);
SankeyModel model;
for (const QSizeF size : {QSizeF(600, 300), QSizeF(900, 500), QSizeF(400, 800)}) {
model.reload(size.width(), size.height());
QVERIFY(!model.isEmpty());
verifyNoOverlaps(model, std::numeric_limits<qreal>::max());
}
}
// With one node per outcome, ribbons from different stages into different
// outcomes can't always keep their order; any such crossing must stay in
// the final gap where they rejoin, never braid across the diagram.
void crossingsOnlyWhereRibbonsRejoin()
{ {
JobsDatabase db; JobsDatabase db;
seedDensePipeline(db); seedPipeline(db, LargeSeed);
SankeyModel model;
model.reload(900, 500);
QVERIFY(!model.isEmpty());
QList<qreal> xs;
for (const QVariant &v : model.nodes()) {
const qreal x = v.toMap().value(QStringLiteral("x")).toReal();
if (!xs.contains(x)) {
xs.append(x);
}
}
std::sort(xs.begin(), xs.end());
QVERIFY(xs.size() >= 2);
verifyNoOverlaps(model, xs.at(xs.size() - 2) + nodeWidth);
}
// Each application is one left-to-right path: the funnel stages it
// reached, then its current stage if that is an outcome.
void historyCollapsesToForwardPaths()
{
JobsDatabase db;
seedPipeline(db, BackMoveSeed);
SankeyModel model; SankeyModel model;
model.reload(600, 300); model.reload(600, 300);
QVERIFY(!model.isEmpty());
QHash<QString, QVariantMap> nodeByStage; QHash<QString, int> links;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
}
const auto center = [&](const QString &stage) {
const QVariantMap n = nodeByStage.value(stage);
return n.value(QStringLiteral("y")).toReal() + n.value(QStringLiteral("height")).toReal() / 2.0;
};
// Outgoing ribbons must stack top-to-bottom in order of their
// target's height, incoming ones by their source's height, or the
// ribbons twist over each other right at the node.
QHash<QString, QList<QPair<qreal, qreal>>> outgoing; // sourceY -> target center
QHash<QString, QList<QPair<qreal, qreal>>> incoming; // targetY -> source center
for (const QVariant &v : model.links()) { for (const QVariant &v : model.links()) {
const QVariantMap m = v.toMap(); const QVariantMap m = v.toMap();
outgoing[m.value(QStringLiteral("fromStage")).toString()].append( links.insert(m.value(QStringLiteral("fromStage")).toString() + QStringLiteral(">") + m.value(QStringLiteral("toStage")).toString(),
{m.value(QStringLiteral("sourceY")).toReal(), center(m.value(QStringLiteral("toStage")).toString())}); m.value(QStringLiteral("value")).toInt());
incoming[m.value(QStringLiteral("toStage")).toString()].append(
{m.value(QStringLiteral("targetY")).toReal(), center(m.value(QStringLiteral("fromStage")).toString())});
} }
const auto verifySorted = [](QList<QPair<qreal, qreal>> slots) { const QHash<QString, int> expected{
std::sort(slots.begin(), slots.end()); {QStringLiteral("Start>Applied"), 4},
for (int i = 1; i < slots.size(); ++i) { {QStringLiteral("Applied>Screening"), 2},
QVERIFY(slots.at(i).second >= slots.at(i - 1).second - 0.01); {QStringLiteral("Applied>Interview"), 2},
} {QStringLiteral("Interview>Offer"), 1},
{QStringLiteral("Screening>Rejected"), 1},
{QStringLiteral("Screening>Withdrawn"), 1},
}; };
for (const auto &slots : std::as_const(outgoing)) { QCOMPARE(links, expected);
verifySorted(slots);
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
// Ghosted was superseded by Rejected, so it isn't an outcome anyone ended in.
QVERIFY(m.value(QStringLiteral("stage")).toString() != QStringLiteral("Ghosted"));
if (m.value(QStringLiteral("stage")).toString() == QStringLiteral("Start")) {
QCOMPARE(m.value(QStringLiteral("value")).toInt(), 4);
}
} }
for (const auto &slots : std::as_const(incoming)) { }
verifySorted(slots);
void contentIsVerticallyCentered()
{
JobsDatabase db;
seedPipeline(db, DenseSeed);
SankeyModel model;
model.reload(600, 300);
qreal top = std::numeric_limits<qreal>::max();
qreal bottom = std::numeric_limits<qreal>::lowest();
for (const QVariant &v : model.nodes()) {
const QVariantMap node = v.toMap();
top = qMin(top, node.value(QStringLiteral("y")).toReal());
bottom = qMax(bottom, node.value(QStringLiteral("y")).toReal() + node.value(QStringLiteral("height")).toReal());
} }
for (const QVariant &v : model.links()) {
const Ribbon ribbon = parseRibbon(v.toMap());
for (const QList<QPointF> *edge : {&ribbon.top, &ribbon.bottom}) {
for (const QPointF &p : *edge) {
top = qMin(top, p.y());
bottom = qMax(bottom, p.y());
}
}
}
QVERIFY2(qAbs(top - (300.0 - bottom)) < 0.05, qPrintable(QStringLiteral("top margin %1, bottom margin %2").arg(top).arg(300.0 - bottom)));
} }
void sparseColumnsFillWidth() void sparseColumnsFillWidth()
@@ -303,6 +372,171 @@ private Q_SLOTS:
} }
private: private:
enum Seed {
DenseSeed,
SkipSeed,
BackMoveSeed,
LargeSeed,
};
static void seedPipeline(JobsDatabase &db, int seed)
{
const auto S = [](const char *stage) {
return QString::fromLatin1(stage);
};
switch (seed) {
case DenseSeed:
seedDensePipeline(db);
break;
case SkipSeed:
walkJob(db, {S("Interview"), S("Offer"), S("Accepted")});
walkJob(db, {S("Screening"), S("Interview"), S("Rejected")});
walkJob(db, {S("Screening"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Accepted")});
walkJob(db, {S("Ghosted")});
walkJob(db, {});
addJobAt(db, S("Interview"));
break;
case BackMoveSeed:
walkJob(db, {S("Screening"), S("Ghosted"), S("Rejected")});
walkJob(db, {S("Interview"), S("Offer"), S("Interview")});
walkJob(db, {S("Rejected"), S("Interview")});
walkJob(db, {S("Screening"), S("Withdrawn")});
break;
case LargeSeed:
for (int i = 0; i < 12; ++i) {
walkJob(db, {});
}
for (int i = 0; i < 6; ++i) {
walkJob(db, {S("Ghosted")});
}
for (int i = 0; i < 5; ++i) {
walkJob(db, {S("Rejected")});
}
walkJob(db, {S("Withdrawn")});
for (int i = 0; i < 4; ++i) {
walkJob(db, {S("Screening"), S("Rejected")});
}
walkJob(db, {S("Screening"), S("Ghosted")});
walkJob(db, {S("Screening")});
for (int i = 0; i < 3; ++i) {
walkJob(db, {S("Screening"), S("Interview"), S("Rejected")});
}
walkJob(db, {S("Screening"), S("Interview"), S("Withdrawn")});
walkJob(db, {S("Interview"), S("Onsite"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Accepted")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer"), S("Rejected")});
walkJob(db, {S("Screening"), S("Interview"), S("Onsite"), S("Offer")});
break;
}
}
static void addJobAt(JobsDatabase &db, const QString &stage)
{
Job job;
job.company = QStringLiteral("Co");
job.title = QStringLiteral("Title");
job.stage = stage;
QVERIFY(db.addJob(job));
}
/// A ribbon's outline as drawn: its top and bottom edges, each sampled
/// left to right.
struct Ribbon {
QString name;
QList<QPointF> top;
QList<QPointF> bottom;
};
/// Parses the absolute M/C/L/Z path SankeyModel generates. The outline
/// runs along the top edge, drops straight down at the target, and runs
/// back along the bottom edge.
static Ribbon parseRibbon(const QVariantMap &link)
{
Ribbon ribbon;
ribbon.name = link.value(QStringLiteral("fromStage")).toString() + QStringLiteral("->") + link.value(QStringLiteral("toStage")).toString();
static const QRegularExpression token(QStringLiteral("[MCLZ]|-?\\d+(?:\\.\\d+)?"));
QStringList tokens;
auto it = token.globalMatch(link.value(QStringLiteral("pathData")).toString());
while (it.hasNext()) {
tokens.append(it.next().captured());
}
QList<QPointF> *edge = &ribbon.top;
QPointF current;
int i = 0;
// Read coordinates one at a time: argument evaluation order is
// unspecified, so QPointF(next(), next()) could swap x and y.
const auto nextPoint = [&]() {
const qreal x = tokens.value(i++).toDouble();
const qreal y = tokens.value(i++).toDouble();
return QPointF(x, y);
};
while (i < tokens.size()) {
const QString command = tokens.at(i++);
if (command == QLatin1String("M")) {
current = nextPoint();
edge->append(current);
} else if (command == QLatin1String("L")) {
const QPointF next = nextPoint();
if (edge == &ribbon.top && qAbs(next.x() - current.x()) < 1e-6 && qAbs(next.y() - current.y()) > 1e-6) {
edge = &ribbon.bottom; // the drop at the target
}
edge->append(next);
current = next;
} else if (command == QLatin1String("C")) {
const QPointF c1 = nextPoint();
const QPointF c2 = nextPoint();
const QPointF end = nextPoint();
constexpr int steps = 32;
for (int step = 1; step <= steps; ++step) {
const qreal t = static_cast<qreal>(step) / steps;
const qreal u = 1.0 - t;
edge->append(current * (u * u * u) + c1 * (3 * u * u * t) + c2 * (3 * u * t * t) + end * (t * t * t));
}
current = end;
}
}
std::reverse(ribbon.bottom.begin(), ribbon.bottom.end());
return ribbon;
}
static qreal yAt(const QList<QPointF> &edge, qreal x)
{
for (int i = 1; i < edge.size(); ++i) {
const QPointF &a = edge.at(i - 1);
const QPointF &b = edge.at(i);
if (x >= a.x() && x <= b.x()) {
return b.x() - a.x() < 1e-9 ? a.y() : a.y() + (b.y() - a.y()) * (x - a.x()) / (b.x() - a.x());
}
}
return x < edge.first().x() ? edge.first().y() : edge.last().y();
}
/// Fails if any two ribbons overlap vertically anywhere in the x range
/// they share, left of xLimit (their shared end points excluded).
static void verifyNoOverlaps(const SankeyModel &model, qreal xLimit)
{
QList<Ribbon> ribbons;
for (const QVariant &v : model.links()) {
ribbons.append(parseRibbon(v.toMap()));
}
for (int a = 0; a < ribbons.size(); ++a) {
for (int b = a + 1; b < ribbons.size(); ++b) {
const Ribbon &ra = ribbons.at(a);
const Ribbon &rb = ribbons.at(b);
const qreal lo = qMax(ra.top.first().x(), rb.top.first().x()) + 0.25;
const qreal hi = std::min({ra.top.last().x(), rb.top.last().x(), xLimit}) - 0.25;
for (int step = 0; step <= 400 && lo < hi; ++step) {
const qreal x = lo + (hi - lo) * step / 400.0;
const qreal overlap = qMin(yAt(ra.bottom, x), yAt(rb.bottom, x)) - qMax(yAt(ra.top, x), yAt(rb.top, x));
QVERIFY2(overlap < 0.5, qPrintable(QStringLiteral("%1 and %2 overlap by %3px at x=%4").arg(ra.name, rb.name).arg(overlap).arg(x)));
}
}
}
}
static void makeJob(JobsDatabase &db, const QString &stage) static void makeJob(JobsDatabase &db, const QString &stage)
{ {
Job job; Job job;
+553 -259
View File
@@ -9,21 +9,25 @@
#include <KLocalizedString> #include <KLocalizedString>
#include <QColor>
#include <QHash> #include <QHash>
#include <QPair> #include <QPair>
#include <QPointF>
#include <QSet> #include <QSet>
#include <QVariantMap> #include <QVariantMap>
#include <algorithm> #include <algorithm>
#include <iterator> #include <cmath>
#include <numeric> #include <limits>
using namespace Qt::Literals::StringLiterals; using namespace Qt::Literals::StringLiterals;
namespace namespace
{ {
QString num(qreal v)
QString num(qreal value)
{ {
return QString::number(v, 'f', 2); return QString::number(value, 'f', 2);
} }
QString point(qreal x, qreal y) QString point(qreal x, qreal y)
@@ -31,17 +35,60 @@ QString point(qreal x, qreal y)
return num(x) + u","_s + num(y); return num(x) + u","_s + num(y);
} }
/// Cubic S-curve with horizontal tangents from the current point (xa, ya) to
/// (xb, yb). Every ribbon crossing the same gap between two columns uses the
/// same xa/xb, so two ribbons in the same vertical order at both ends of a
/// gap cannot cross inside it.
QString curveTo(qreal xa, qreal ya, qreal xb, qreal yb)
{
const qreal mid = (xa + xb) / 2.0;
return u" C"_s + point(mid, ya) + u" "_s + point(mid, yb) + u" "_s + point(xb, yb);
}
/// Outcomes other than Accepted: drop-offs end here. Accepted continues the
/// main line of the funnel instead.
bool isSink(const QString &stage)
{
return JobStage::isTerminal(stage) && stage != QLatin1String("Accepted");
}
struct NodeInfo { struct NodeInfo {
QString stage; QString stage;
int column = 0; int column = 0;
int rank = 0; ///< Index among the columns actually present.
int order = 0; int order = 0;
int value = 0; int value = 0;
bool sink = false;
qreal x = 0; qreal x = 0;
qreal y = 0; qreal y = 0;
qreal width = 0; qreal width = 0;
qreal height = 0; qreal height = 0;
}; };
}
struct LinkInfo {
QString from;
QString to;
int value = 0;
int fromIndex = 0;
int toIndex = 0;
int fromRank = 0;
int toRank = 0;
bool sink = false; ///< Ends in a drop-off outcome.
qreal thickness = 0;
qreal sourceY = 0;
qreal targetY = 0;
/// Top y of the ribbon in each column it passes through (fromRank+1 .. toRank-1).
QHash<int, qreal> laneY;
bool passesColumns() const
{
return toRank - fromRank > 1;
}
};
} // namespace
SankeyModel::SankeyModel(QObject *parent) SankeyModel::SankeyModel(QObject *parent)
: QObject(parent) : QObject(parent)
@@ -65,12 +112,51 @@ bool SankeyModel::isEmpty() const
void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding) void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding)
{ {
m_db.reopenIfPathChanged();
m_counts.clear(); m_counts.clear();
const QList<StageTransition> transitions = m_db.stageTransitions();
for (const StageTransition &t : transitions) { // Each application is drawn once, as one left-to-right path: the funnel
const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage; // stages it reached in order, then its current stage if that is an
m_counts[{from, t.toStage}] += 1; // outcome. Backward or sideways moves (Offer back to Interview, Ghosted
// then Rejected) are history, not flow; drawing them as ribbons would
// run right-to-left across everything else.
QList<int> jobOrder;
QHash<int, QStringList> stagesByJob;
for (const StageTransition &transition : m_db.stageTransitions()) {
const QString stage = JobStage::canonical(transition.toStage);
if (!JobStage::isValid(stage)) {
continue;
}
if (!stagesByJob.contains(transition.jobId)) {
jobOrder.append(transition.jobId);
}
stagesByJob[transition.jobId].append(stage);
} }
for (int jobId : std::as_const(jobOrder)) {
const QStringList stages = stagesByJob.value(jobId);
QStringList path{QString::fromLatin1(JobStage::Start)};
int furthestColumn = JobStage::column(path.first());
for (const QString &stage : stages) {
if (JobStage::isTerminal(stage)) {
continue;
}
const int column = JobStage::column(stage);
if (column > furthestColumn) {
path.append(stage);
furthestColumn = column;
}
}
if (!stages.isEmpty() && JobStage::isTerminal(stages.last())) {
path.append(stages.last());
}
for (int i = 0; i + 1 < path.size(); ++i) {
++m_counts[{path.at(i), path.at(i + 1)}];
}
}
relayout(width, height, nodeWidth, padding); relayout(width, height, nodeWidth, padding);
} }
@@ -79,156 +165,85 @@ void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal pad
m_nodes.clear(); m_nodes.clear();
m_links.clear(); m_links.clear();
if (width <= 0 || height <= 0 || m_counts.isEmpty()) { if (width <= 0 || height <= 0 || nodeWidth <= 0 || m_counts.isEmpty()) {
Q_EMIT changed(); Q_EMIT changed();
return; return;
} }
padding = qMax<qreal>(0.0, padding);
//
// Graph. Only left-to-right links are laid out; reload() never produces
// anything else, but stay safe against arbitrary counts.
//
QHash<QString, int> inbound; QHash<QString, int> inbound;
QHash<QString, int> outbound; QHash<QString, int> outbound;
QSet<QString> stageNames; QSet<QString> stageNames;
QList<QPair<QString, QString>> linkKeys;
for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) { for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) {
const QString &from = it.key().first; const QString &from = it.key().first;
const QString &to = it.key().second; const QString &to = it.key().second;
if (it.value() <= 0 || from.isEmpty() || to.isEmpty() || JobStage::column(to) <= JobStage::column(from)) {
continue;
}
outbound[from] += it.value(); outbound[from] += it.value();
inbound[to] += it.value(); inbound[to] += it.value();
stageNames.insert(from); stageNames.insert(from);
stageNames.insert(to); stageNames.insert(to);
linkKeys.append(it.key());
} }
if (stageNames.isEmpty()) {
Q_EMIT changed();
return;
}
//
// Nodes and the columns actually present.
//
QList<NodeInfo> nodeList; QList<NodeInfo> nodeList;
nodeList.reserve(stageNames.size()); nodeList.reserve(stageNames.size());
for (const QString &stage : std::as_const(stageNames)) { for (const QString &stage : std::as_const(stageNames)) {
NodeInfo n; NodeInfo node;
n.stage = stage; node.stage = stage;
n.column = JobStage::column(stage); node.column = JobStage::column(stage);
n.order = JobStage::orderInColumn(stage); node.order = JobStage::orderInColumn(stage);
n.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0)); node.sink = isSink(stage);
nodeList.append(n); // Applications still sitting in a stage make its inflow exceed its
// outflow; the node must be tall enough for the larger side.
node.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0));
nodeList.append(node);
} }
std::sort(nodeList.begin(), nodeList.end(), [](const NodeInfo &a, const NodeInfo &b) { std::sort(nodeList.begin(), nodeList.end(), [](const NodeInfo &a, const NodeInfo &b) {
if (a.column != b.column) { if (a.column != b.column) {
return a.column < b.column; return a.column < b.column;
} }
return a.order < b.order; if (a.order != b.order) {
return a.order < b.order;
}
return a.stage < b.stage;
}); });
QHash<int, QList<int>> columnIndices; QList<int> usedColumns;
for (int i = 0; i < nodeList.size(); ++i) { for (const NodeInfo &node : std::as_const(nodeList)) {
columnIndices[nodeList.at(i).column].append(i); if (!usedColumns.contains(node.column)) {
usedColumns.append(node.column);
}
} }
// Columns are spread by their rank among the columns actually present,
// not their canonical index, so a sparse pipeline (say Start, Applied,
// Rejected) still fills the width instead of leaving dead stretches for
// the unused stages in between.
QList<int> usedColumns = columnIndices.keys();
std::sort(usedColumns.begin(), usedColumns.end()); std::sort(usedColumns.begin(), usedColumns.end());
QHash<int, int> rankOfColumn;
for (int i = 0; i < usedColumns.size(); ++i) {
rankOfColumn.insert(usedColumns.at(i), i);
}
const int rankCount = usedColumns.size(); const int rankCount = usedColumns.size();
constexpr qreal minNodeHeight = 2.0;
// A single vertical scale is shared by every column: the column with the
// largest total flow determines it, so no column can overflow the
// available height.
qreal scale = 1.0;
bool haveScale = false;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
int total = 0;
for (int idx : it.value()) {
total += nodeList.at(idx).value;
}
if (total <= 0) {
continue;
}
const qreal gaps = padding * qMax(0, it.value().size() - 1);
const qreal available = qMax<qreal>(1.0, height - gaps);
const qreal candidate = available / total;
if (!haveScale || candidate < scale) {
scale = candidate;
haveScale = true;
}
}
if (!haveScale) {
scale = 1.0;
}
// Tiny nodes get clamped up to minNodeHeight, which consumes height the
// raw totals above didn't account for; shrink the shared scale until
// every column fits with its clamped nodes included. Terminates because
// the scale only decreases and the clamped set only grows.
bool again = haveScale;
while (again) {
again = false;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
const qreal gaps = padding * qMax(0, it.value().size() - 1);
qreal clampedHeight = 0;
qreal freeTotal = 0;
for (int idx : it.value()) {
const qreal value = nodeList.at(idx).value;
if (value * scale < minNodeHeight) {
clampedHeight += minNodeHeight;
} else {
freeTotal += value;
}
}
if (freeTotal <= 0) {
continue;
}
const qreal available = height - gaps - clampedHeight;
if (available <= 0) {
continue;
}
const qreal candidate = available / freeTotal;
if (candidate < scale) {
scale = candidate;
again = true;
}
}
}
// Per-column minimum node height: backs off from minNodeHeight when even
// that many clamped nodes would overflow a very short viewport.
QHash<int, qreal> minHeightByColumn;
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
const int count = it.value().size();
const qreal gaps = padding * qMax(0, count - 1);
minHeightByColumn.insert(it.key(), qMin(minNodeHeight, qMax<qreal>(0.5, (height - gaps) / count)));
}
for (auto it = columnIndices.begin(); it != columnIndices.end(); ++it) {
const QList<int> &idxs = it.value();
const qreal minHeight = minHeightByColumn.value(it.key());
// Columns are top-aligned rather than centered: the funnel's success
// path then runs level along the top while drop-off ribbons peel
// downward into the open space beneath it, instead of every column
// being centered and the ribbons weaving up and down to meet.
const qreal startY = 0.0;
const int rank = rankOfColumn.value(it.key());
const qreal x = rankCount > 1 ? rank * (width - nodeWidth) / (rankCount - 1) : (width - nodeWidth) / 2.0;
qreal cursorY = startY;
for (int idx : idxs) {
NodeInfo &n = nodeList[idx];
n.x = x;
n.y = cursorY;
n.width = nodeWidth;
n.height = qMax(n.value * scale, minHeight);
cursorY += n.height + padding;
}
}
QHash<QString, int> nodeIndexByStage; QHash<QString, int> nodeIndexByStage;
for (int i = 0; i < nodeList.size(); ++i) { for (int i = 0; i < nodeList.size(); ++i) {
nodeList[i].rank = usedColumns.indexOf(nodeList.at(i).column);
nodeIndexByStage.insert(nodeList.at(i).stage, i); nodeIndexByStage.insert(nodeList.at(i).stage, i);
} }
QList<QPair<QString, QString>> linkKeys = m_counts.keys(); //
// Links.
//
std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair<QString, QString> &a, const QPair<QString, QString> &b) { std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair<QString, QString> &a, const QPair<QString, QString> &b) {
const int aFrom = nodeIndexByStage.value(a.first); const int aFrom = nodeIndexByStage.value(a.first);
const int bFrom = nodeIndexByStage.value(b.first); const int bFrom = nodeIndexByStage.value(b.first);
@@ -238,177 +253,456 @@ void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal pad
return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second); return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second);
}); });
// Ribbons share the node's vertical scale, but their minimum visible QList<LinkInfo> links;
// thickness can add up past the node it stacks against; total the raw links.reserve(linkKeys.size());
// thicknesses per node and side first, then squeeze each ribbon by its
// endpoints' overflow so the stack always stays inside both nodes.
QList<qreal> rawThickness;
rawThickness.reserve(linkKeys.size());
QHash<QString, qreal> outboundThickness;
QHash<QString, qreal> inboundThickness;
for (const auto &key : std::as_const(linkKeys)) { for (const auto &key : std::as_const(linkKeys)) {
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(key.first)); LinkInfo link;
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(key.second)); link.from = key.first;
const qreal minThickness = qMin<qreal>(1.5, qMin(minHeightByColumn.value(fromNode.column), minHeightByColumn.value(toNode.column))); link.to = key.second;
const qreal thickness = qMax(m_counts.value(key) * scale, minThickness); link.value = m_counts.value(key);
rawThickness.append(thickness); link.fromIndex = nodeIndexByStage.value(key.first);
outboundThickness[key.first] += thickness; link.toIndex = nodeIndexByStage.value(key.second);
inboundThickness[key.second] += thickness; link.fromRank = nodeList.at(link.fromIndex).rank;
link.toRank = nodeList.at(link.toIndex).rank;
link.sink = nodeList.at(link.toIndex).sink;
links.append(link);
} }
QList<qreal> finalThickness; //
finalThickness.reserve(linkKeys.size()); // Order the drop-off outcomes top to bottom by where their applications
for (int i = 0; i < linkKeys.size(); ++i) { // dropped off: outcomes fed by later stages sit higher. Drop-off ribbons
const QPair<QString, QString> &key = linkKeys.at(i); // travel in lanes ordered the same way (latest stage innermost), so this
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(key.first)); // keeps the ribbons' order when they rejoin as close as possible to the
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(key.second)); // order they travelled in.
const qreal outFactor = qMin<qreal>(1.0, fromNode.height / outboundThickness.value(key.first)); //
const qreal inFactor = qMin<qreal>(1.0, toNode.height / inboundThickness.value(key.second)); QHash<int, qreal> meanSourceRank;
finalThickness.append(rawThickness.at(i) * qMin(outFactor, inFactor)); {
QHash<int, qreal> weighted;
QHash<int, int> total;
for (const LinkInfo &link : std::as_const(links)) {
if (link.sink) {
weighted[link.toIndex] += static_cast<qreal>(link.value) * link.fromRank;
total[link.toIndex] += link.value;
}
}
for (auto it = total.constBegin(); it != total.constEnd(); ++it) {
meanSourceRank.insert(it.key(), weighted.value(it.key()) / it.value());
}
}
QList<int> sinkOrder;
for (int i = 0; i < nodeList.size(); ++i) {
if (nodeList.at(i).sink) {
sinkOrder.append(i);
}
}
std::sort(sinkOrder.begin(), sinkOrder.end(), [&](int a, int b) {
const qreal ma = meanSourceRank.value(a);
const qreal mb = meanSourceRank.value(b);
if (!qFuzzyCompare(ma + 1.0, mb + 1.0)) {
return ma > mb;
}
return a < b; // nodeList is already sorted by JobStage::orderInColumn().
});
QHash<int, int> sinkPosition;
for (int i = 0; i < sinkOrder.size(); ++i) {
sinkPosition.insert(sinkOrder.at(i), i);
} }
// Each end of a ribbon gets its slot on the node independently: outgoing //
// ribbons stack in order of their target's height, incoming ones in // What each column holds, top to bottom:
// order of their source's height (d3-sankey style). One global order for //
// both ends would let a low slot head for a high target and twist over // over-lanes main-line links skipping this column (e.g. Applied -> Interview)
// its siblings. // spine node the funnel stage (or Accepted)
const auto nodeCenter = [&](const QString &stage) { // under-lanes drop-off links on their way to an outcome
const NodeInfo &n = nodeList.at(nodeIndexByStage.value(stage)); // sinks the drop-off outcomes themselves (last column only)
return n.y + n.height / 2.0; //
// Lanes keep one order along their whole run, and a stage's drop-offs join
// the under-lanes on top (innermost), so drop-offs peel off each stage and
// nest around each other instead of braiding.
//
QList<QList<int>> overLanes(rankCount);
QList<QList<int>> underLanes(rankCount);
QList<QList<int>> spineNodes(rankCount);
QList<QList<int>> sinkNodes(rankCount);
for (int i = 0; i < links.size(); ++i) {
const LinkInfo &link = links.at(i);
for (int rank = link.fromRank + 1; rank < link.toRank; ++rank) {
(link.sink ? underLanes : overLanes)[rank].append(i);
}
}
for (int rank = 0; rank < rankCount; ++rank) {
std::sort(overLanes[rank].begin(), overLanes[rank].end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (la.fromRank != lb.fromRank) {
return la.fromRank < lb.fromRank;
}
if (la.toRank != lb.toRank) {
return la.toRank > lb.toRank;
}
return a < b;
});
std::sort(underLanes[rank].begin(), underLanes[rank].end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (la.fromRank != lb.fromRank) {
return la.fromRank > lb.fromRank;
}
if (sinkPosition.value(la.toIndex) != sinkPosition.value(lb.toIndex)) {
return sinkPosition.value(la.toIndex) < sinkPosition.value(lb.toIndex);
}
return a < b;
});
}
for (int i = 0; i < nodeList.size(); ++i) {
if (!nodeList.at(i).sink) {
spineNodes[nodeList.at(i).rank].append(i);
}
}
for (int index : std::as_const(sinkOrder)) {
sinkNodes[nodeList.at(index).rank].append(index);
}
//
// Shared scale: the largest for which every column's stack fits.
//
// Link thickness is always exactly value * scale. Nodes get a small
// visual minimum height, which never changes link widths.
//
constexpr qreal minimumNodeHeight = 2.0;
const auto laneThickness = [&](const QList<int> &lanes, qreal scale) {
qreal sum = 0;
for (int index : lanes) {
sum += links.at(index).value * scale;
}
return sum;
}; };
QList<int> order(linkKeys.size()); const auto blockCount = [&](int rank) {
std::iota(order.begin(), order.end(), 0); return static_cast<int>(!overLanes.at(rank).isEmpty()) + static_cast<int>(!underLanes.at(rank).isEmpty()) + spineNodes.at(rank).size()
+ sinkNodes.at(rank).size();
QList<qreal> sourceYTop(linkKeys.size()); };
std::sort(order.begin(), order.end(), [&](int a, int b) { QList<qreal> minimumHeight(rankCount, 0.0);
const QPair<QString, QString> &ka = linkKeys.at(a); for (int rank = 0; rank < rankCount; ++rank) {
const QPair<QString, QString> &kb = linkKeys.at(b); const int nodeCount = spineNodes.at(rank).size() + sinkNodes.at(rank).size();
if (ka.first != kb.first) { if (nodeCount > 0) {
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first); const qreal available = qMax<qreal>(0.0, height - padding * qMax(0, blockCount(rank) - 1));
minimumHeight[rank] = qMin(minimumNodeHeight, available / nodeCount);
} }
const qreal ya = nodeCenter(ka.second);
const qreal yb = nodeCenter(kb.second);
if (!qFuzzyCompare(ya, yb)) {
return ya < yb;
}
return nodeIndexByStage.value(ka.second) < nodeIndexByStage.value(kb.second);
});
QHash<QString, qreal> sourceCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
sourceCursor.insert(n.stage, n.y);
} }
for (int i : std::as_const(order)) { const auto stackHeight = [&](int rank, qreal scale) {
sourceYTop[i] = sourceCursor.value(linkKeys.at(i).first); qreal used = padding * qMax(0, blockCount(rank) - 1);
sourceCursor[linkKeys.at(i).first] = sourceYTop.at(i) + finalThickness.at(i); used += laneThickness(overLanes.at(rank), scale) + laneThickness(underLanes.at(rank), scale);
for (const QList<int> *group : {&spineNodes.at(rank), &sinkNodes.at(rank)}) {
for (int index : *group) {
used += qMax(minimumHeight.at(rank), nodeList.at(index).value * scale);
}
}
return used;
};
const auto fits = [&](qreal scale) {
for (int rank = 0; rank < rankCount; ++rank) {
if (stackHeight(rank, scale) > height + 0.0001) {
return false;
}
}
return true;
};
qreal scale = 0.0;
if (fits(0.0)) {
qreal low = 0.0;
qreal high = 0.0;
for (const NodeInfo &node : std::as_const(nodeList)) {
if (node.value > 0) {
high = qMax(high, height / static_cast<qreal>(node.value));
}
}
for (int iteration = 0; iteration < 60; ++iteration) {
const qreal middle = (low + high) / 2.0;
if (fits(middle)) {
low = middle;
} else {
high = middle;
}
}
scale = low;
} }
QList<qreal> targetYTop(linkKeys.size()); for (LinkInfo &link : links) {
std::sort(order.begin(), order.end(), [&](int a, int b) { link.thickness = link.value * scale;
const QPair<QString, QString> &ka = linkKeys.at(a);
const QPair<QString, QString> &kb = linkKeys.at(b);
if (ka.second != kb.second) {
return nodeIndexByStage.value(ka.second) < nodeIndexByStage.value(kb.second);
}
const qreal ya = nodeCenter(ka.first);
const qreal yb = nodeCenter(kb.first);
if (!qFuzzyCompare(ya, yb)) {
return ya < yb;
}
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
});
QHash<QString, qreal> targetCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
targetCursor.insert(n.stage, n.y);
} }
for (int i : std::as_const(order)) { for (int i = 0; i < nodeList.size(); ++i) {
targetYTop[i] = targetCursor.value(linkKeys.at(i).second); NodeInfo &node = nodeList[i];
targetCursor[linkKeys.at(i).second] = targetYTop.at(i) + finalThickness.at(i); node.width = nodeWidth;
node.height = qMax(minimumHeight.at(node.rank), node.value * scale);
} }
for (int i = 0; i < linkKeys.size(); ++i) { //
const QPair<QString, QString> &key = linkKeys.at(i); // Positions. Columns spread over the width by rank; every stack starts at
const QString &from = key.first; // the same top, and the tallest one is centered vertically.
const QString &to = key.second; //
const int value = m_counts.value(key); qreal contentHeight = 0;
for (int rank = 0; rank < rankCount; ++rank) {
contentHeight = qMax(contentHeight, stackHeight(rank, scale));
}
const qreal top = qMax<qreal>(0.0, (height - contentHeight) / 2.0);
const qreal bottom = top + contentHeight;
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from)); QList<qreal> columnX(rankCount);
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to)); for (int rank = 0; rank < rankCount; ++rank) {
columnX[rank] = rankCount > 1 ? rank * (width - nodeWidth) / static_cast<qreal>(rankCount - 1) : (width - nodeWidth) / 2.0;
}
for (NodeInfo &node : nodeList) {
node.x = columnX.at(node.rank);
}
const qreal thickness = finalThickness.at(i); //
const qreal y0Top = sourceYTop.at(i); // Where each ribbon leaves its source, top to bottom: links skipping
const qreal y0Bottom = y0Top + thickness; // ahead over later stages, the link to the next stage, then drop-offs.
const qreal y1Top = targetYTop.at(i); // Applications still sitting in the stage leave the bottom of the node
const qreal y1Bottom = y1Top + thickness; // empty. A column has at most one main-line node, so the next stage's
// position never needs to break a tie here.
//
QList<QList<int>> outgoing(nodeList.size());
QList<QList<int>> incoming(nodeList.size());
for (int i = 0; i < links.size(); ++i) {
outgoing[links.at(i).fromIndex].append(i);
incoming[links.at(i).toIndex].append(i);
}
const qreal x0 = fromNode.x + fromNode.width; const auto outCategory = [&](const LinkInfo &link) {
const qreal x1 = toNode.x; if (link.sink) {
const qreal midX = (x0 + x1) / 2.0; return 2;
}
return link.passesColumns() ? 0 : 1;
};
const auto assignOutSlots = [&](int n) {
QList<int> &slotOrder = outgoing[n];
std::sort(slotOrder.begin(), slotOrder.end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
const int ca = outCategory(la);
const int cb = outCategory(lb);
if (ca != cb) {
return ca < cb;
}
if (ca == 0 && la.toRank != lb.toRank) {
return la.toRank > lb.toRank;
}
if (ca == 2 && sinkPosition.value(la.toIndex) != sinkPosition.value(lb.toIndex)) {
return sinkPosition.value(la.toIndex) < sinkPosition.value(lb.toIndex);
}
return a < b;
});
qreal cursor = nodeList.at(n).y;
for (int index : std::as_const(slotOrder)) {
links[index].sourceY = cursor;
cursor += links.at(index).thickness;
}
};
const QString path = u"M"_s + point(x0, y0Top) + u" C"_s + point(midX, y0Top) + u" "_s + point(midX, y1Top) + u" "_s + point(x1, y1Top) + u" L"_s QList<qreal> sinkCursor(rankCount, top);
+ point(x1, y1Bottom) + u" C"_s + point(midX, y1Bottom) + u" "_s + point(midX, y0Bottom) + u" "_s + point(x0, y0Bottom) + u" Z"_s; for (int rank = 0; rank < rankCount; ++rank) {
qreal cursor = top;
bool first = true;
const auto startBlock = [&]() {
if (!first) {
cursor += padding;
}
first = false;
};
QColor linkColor = JobStage::color(from); if (!overLanes.at(rank).isEmpty()) {
linkColor.setAlphaF(0.5f); startBlock();
for (int index : overLanes.at(rank)) {
links[index].laneY.insert(rank, cursor);
cursor += links.at(index).thickness;
}
}
for (int index : spineNodes.at(rank)) {
startBlock();
nodeList[index].y = cursor;
cursor += nodeList.at(index).height;
assignOutSlots(index);
}
if (!underLanes.at(rank).isEmpty()) {
// Drop-off lanes stay level instead of rising whenever the stage
// above them gets shorter: each lane sits no higher than it was in
// the previous column (or where it left its stage), and only moves
// down to make room. They still keep their order, and are pulled
// back up only as far as needed to stay inside the diagram.
startBlock();
const QList<int> &lanes = underLanes.at(rank);
for (int index : lanes) {
LinkInfo &link = links[index];
const qreal previous = link.fromRank == rank - 1 ? link.sourceY : link.laneY.value(rank - 1);
const qreal y = qMax(cursor, previous);
link.laneY.insert(rank, y);
cursor = y + link.thickness;
}
qreal limit = bottom;
for (auto it = lanes.crbegin(); it != lanes.crend(); ++it) {
LinkInfo &link = links[*it];
const qreal y = qMin(link.laneY.value(rank), limit - link.thickness);
link.laneY.insert(rank, y);
limit = y;
}
cursor = links.at(lanes.last()).laneY.value(rank) + links.at(lanes.last()).thickness;
}
sinkCursor[rank] = first ? cursor : cursor + padding;
}
//
// Drop-off outcomes: stacked in the last column, as close as possible to
// the height their ribbons arrive at, so rejoining stays shallow.
//
for (int rank = 0; rank < rankCount; ++rank) {
const QList<int> &sinks = sinkNodes.at(rank);
if (sinks.isEmpty()) {
continue;
}
qreal arrival = std::numeric_limits<qreal>::max();
for (int n : sinks) {
for (int index : std::as_const(incoming.at(n))) {
const LinkInfo &link = links.at(index);
arrival = qMin(arrival, link.passesColumns() ? link.laneY.value(link.toRank - 1) : link.sourceY);
}
}
qreal groupHeight = padding * (sinks.size() - 1);
for (int n : sinks) {
groupHeight += nodeList.at(n).height;
}
const qreal lowest = sinkCursor.at(rank);
const qreal highest = qMax(lowest, bottom - groupHeight);
qreal cursor = qBound(lowest, arrival, highest);
for (int n : sinks) {
nodeList[n].y = cursor;
cursor += nodeList.at(n).height + padding;
}
}
//
// Where each ribbon enters its target, top to bottom: for a stage, links
// arriving over earlier stages first, then the link from the previous
// stage; for a drop-off outcome, the latest stage first, matching the
// lane order the ribbons arrive in.
//
for (int n = 0; n < nodeList.size(); ++n) {
QList<int> &slotOrder = incoming[n];
const bool sink = nodeList.at(n).sink;
std::sort(slotOrder.begin(), slotOrder.end(), [&](int a, int b) {
const LinkInfo &la = links.at(a);
const LinkInfo &lb = links.at(b);
if (sink) {
if (la.fromRank != lb.fromRank) {
return la.fromRank > lb.fromRank;
}
return a < b;
}
if (la.passesColumns() != lb.passesColumns()) {
return la.passesColumns();
}
if (la.fromRank != lb.fromRank) {
return la.fromRank < lb.fromRank;
}
return a < b;
});
qreal cursor = nodeList.at(n).y;
for (int index : std::as_const(slotOrder)) {
links[index].targetY = cursor;
cursor += links.at(index).thickness;
}
}
//
// Ribbon paths: an S-curve through each gap between columns and a straight
// run across every column the ribbon passes.
//
for (const LinkInfo &link : std::as_const(links)) {
if (link.thickness <= 0.0) {
continue;
}
const NodeInfo &fromNode = nodeList.at(link.fromIndex);
const NodeInfo &toNode = nodeList.at(link.toIndex);
// Top edge, left to right: (x, y) at each column boundary.
QList<QPointF> edge;
edge.append({fromNode.x + fromNode.width, link.sourceY});
for (int rank = link.fromRank + 1; rank < link.toRank; ++rank) {
const qreal y = link.laneY.value(rank);
edge.append({columnX.at(rank), y});
edge.append({columnX.at(rank) + nodeWidth, y});
}
edge.append({toNode.x, link.targetY});
QString path = u"M"_s + point(edge.first().x(), edge.first().y());
for (int i = 1; i < edge.size(); ++i) {
const QPointF &a = edge.at(i - 1);
const QPointF &b = edge.at(i);
// Odd steps cross a gap; even steps run straight across a column.
path += i % 2 == 1 ? curveTo(a.x(), a.y(), b.x(), b.y()) : u" L"_s + point(b.x(), b.y());
}
path += u" L"_s + point(edge.last().x(), edge.last().y() + link.thickness);
for (int i = edge.size() - 1; i > 0; --i) {
const QPointF &a = edge.at(i);
const QPointF &b = edge.at(i - 1);
path += i % 2 == 1 ? curveTo(a.x(), a.y() + link.thickness, b.x(), b.y() + link.thickness) : u" L"_s + point(b.x(), b.y() + link.thickness);
}
path += u" Z"_s;
QColor linkColor = JobStage::color(link.from);
linkColor.setAlphaF(0.5);
m_links.append(QVariantMap{ m_links.append(QVariantMap{
{u"fromStage"_s, from}, {u"fromStage"_s, link.from},
{u"toStage"_s, to}, {u"toStage"_s, link.to},
{u"value"_s, value}, {u"value"_s, link.value},
{u"pathData"_s, path}, {u"pathData"_s, path},
{u"color"_s, linkColor}, {u"color"_s, linkColor},
{u"thickness"_s, thickness}, {u"thickness"_s, link.thickness},
{u"sourceY"_s, y0Top}, {u"sourceY"_s, link.sourceY},
{u"targetY"_s, y1Top}, {u"targetY"_s, link.targetY},
}); });
} }
// Labels sit to the right of each node except in the last used column, //
// whose labels go to the left (d3-sankey style) so nothing ever renders // Nodes and their labels. Labels go right of every column but the last,
// past the right edge. Each label is capped to the gap before the next // whose labels go left.
// column in use, and the final gap is split between the right-side label //
// of the penultimate column and the left-side label of the last one, so
// text elides instead of overlapping.
QList<qreal> columnStarts;
for (const NodeInfo &n : std::as_const(nodeList)) {
if (!columnStarts.contains(n.x)) {
columnStarts.append(n.x);
}
}
std::sort(columnStarts.begin(), columnStarts.end());
constexpr qreal labelMargin = 8.0; constexpr qreal labelMargin = 8.0;
constexpr qreal minLabelWidth = 24.0; constexpr qreal minLabelWidth = 24.0;
const int lastRank = rankCount - 1;
for (const NodeInfo &n : std::as_const(nodeList)) { for (const NodeInfo &node : std::as_const(nodeList)) {
const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData()); const QString label = node.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(node.stage.toUtf8().constData());
const int columnPos = static_cast<int>(std::distance(columnStarts.begin(), std::find(columnStarts.begin(), columnStarts.end(), n.x))); const bool labelOnRight = node.rank < lastRank || lastRank == 0;
const int lastPos = columnStarts.size() - 1; qreal labelWidth = 0.0;
const bool labelOnRight = columnPos < lastPos || lastPos == 0; if (lastRank == 0) {
qreal labelWidth = 0; labelWidth = width - (node.x + node.width) - labelMargin;
if (lastPos == 0) { } else if (node.rank < lastRank) {
labelWidth = width - (n.x + n.width) - labelMargin; const qreal gap = columnX.at(node.rank + 1) - (node.x + node.width);
} else if (columnPos < lastPos) { // The last gap is shared with the last column's labels, which
const qreal gap = columnStarts.at(columnPos + 1) - (n.x + n.width); // are drawn on its left.
// The outcome names in the last column run longer than the const qreal share = node.rank == lastRank - 1 ? 0.4 : 1.0;
// penultimate stage's, so they get the bigger share of the gap. labelWidth = gap * share - 2.0 * labelMargin;
const qreal share = columnPos == lastPos - 1 ? 0.4 : 1.0;
labelWidth = gap * share - 2 * labelMargin;
} else { } else {
const qreal gap = n.x - (columnStarts.at(columnPos - 1) + n.width); const qreal gap = node.x - (columnX.at(node.rank - 1) + node.width);
labelWidth = gap * 0.6 - 2 * labelMargin; labelWidth = gap * 0.6 - 2.0 * labelMargin;
} }
labelWidth = qMax(labelWidth, minLabelWidth); labelWidth = qMax(labelWidth, minLabelWidth);
m_nodes.append(QVariantMap{ m_nodes.append(QVariantMap{
{u"stage"_s, n.stage}, {u"stage"_s, node.stage},
{u"label"_s, label}, {u"label"_s, label},
{u"x"_s, n.x}, {u"x"_s, node.x},
{u"y"_s, n.y}, {u"y"_s, node.y},
{u"width"_s, n.width}, {u"width"_s, node.width},
{u"height"_s, n.height}, {u"height"_s, node.height},
{u"value"_s, n.value}, {u"value"_s, node.value},
{u"color"_s, JobStage::color(n.stage)}, {u"color"_s, JobStage::color(node.stage)},
{u"labelWidth"_s, labelWidth}, {u"labelWidth"_s, labelWidth},
{u"labelOnRight"_s, labelOnRight}, {u"labelOnRight"_s, labelOnRight},
}); });