From 18dd49aaba703532dbc298654e2d6f88e58af551 Mon Sep 17 00:00:00 2001 From: Austin Bennett Date: Fri, 11 Sep 2026 15:42:13 -0500 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01BxDf7HqD1xPnsZP8wt3NTk --- autotests/sankeylayouttest.cpp | 296 ++++++++++-- src/sankeymodel.cpp | 812 ++++++++++++++++++++++----------- 2 files changed, 818 insertions(+), 290 deletions(-) diff --git a/autotests/sankeylayouttest.cpp b/autotests/sankeylayouttest.cpp index 35ed503..8917093 100644 --- a/autotests/sankeylayouttest.cpp +++ b/autotests/sankeylayouttest.cpp @@ -8,10 +8,13 @@ #include "jobsdatabase.h" #include "sankeymodel.h" +#include +#include #include #include #include #include +#include #include // SankeyModel always opens JobsDatabase::defaultPath(), so each test points @@ -173,49 +176,115 @@ private Q_SLOTS: } } - void ribbonSlotsFollowEndpointHeights() + void ribbonsDoNotCross_data() + { + QTest::addColumn("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::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; - seedDensePipeline(db); + seedPipeline(db, LargeSeed); + + SankeyModel model; + model.reload(900, 500); + QVERIFY(!model.isEmpty()); + + QList 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; model.reload(600, 300); - QVERIFY(!model.isEmpty()); - QHash nodeByStage; - 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>> outgoing; // sourceY -> target center - QHash>> incoming; // targetY -> source center + QHash links; for (const QVariant &v : model.links()) { const QVariantMap m = v.toMap(); - outgoing[m.value(QStringLiteral("fromStage")).toString()].append( - {m.value(QStringLiteral("sourceY")).toReal(), center(m.value(QStringLiteral("toStage")).toString())}); - incoming[m.value(QStringLiteral("toStage")).toString()].append( - {m.value(QStringLiteral("targetY")).toReal(), center(m.value(QStringLiteral("fromStage")).toString())}); + links.insert(m.value(QStringLiteral("fromStage")).toString() + QStringLiteral(">") + m.value(QStringLiteral("toStage")).toString(), + m.value(QStringLiteral("value")).toInt()); } - const auto verifySorted = [](QList> slots) { - std::sort(slots.begin(), slots.end()); - for (int i = 1; i < slots.size(); ++i) { - QVERIFY(slots.at(i).second >= slots.at(i - 1).second - 0.01); - } + const QHash expected{ + {QStringLiteral("Start>Applied"), 4}, + {QStringLiteral("Applied>Screening"), 2}, + {QStringLiteral("Applied>Interview"), 2}, + {QStringLiteral("Interview>Offer"), 1}, + {QStringLiteral("Screening>Rejected"), 1}, + {QStringLiteral("Screening>Withdrawn"), 1}, }; - for (const auto &slots : std::as_const(outgoing)) { - verifySorted(slots); + QCOMPARE(links, expected); + + 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::max(); + qreal bottom = std::numeric_limits::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 *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() @@ -303,6 +372,171 @@ private Q_SLOTS: } 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 top; + QList 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 *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(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 &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 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) { Job job; diff --git a/src/sankeymodel.cpp b/src/sankeymodel.cpp index cf42508..10bf7bb 100644 --- a/src/sankeymodel.cpp +++ b/src/sankeymodel.cpp @@ -9,21 +9,25 @@ #include +#include #include #include +#include #include #include + #include -#include -#include +#include +#include using namespace Qt::Literals::StringLiterals; 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) @@ -31,17 +35,60 @@ QString point(qreal x, qreal 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 { QString stage; int column = 0; + int rank = 0; ///< Index among the columns actually present. int order = 0; int value = 0; + bool sink = false; + qreal x = 0; qreal y = 0; qreal width = 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 laneY; + + bool passesColumns() const + { + return toRank - fromRank > 1; + } +}; + +} // namespace SankeyModel::SankeyModel(QObject *parent) : QObject(parent) @@ -65,12 +112,51 @@ bool SankeyModel::isEmpty() const void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding) { + m_db.reopenIfPathChanged(); m_counts.clear(); - const QList transitions = m_db.stageTransitions(); - for (const StageTransition &t : transitions) { - const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage; - m_counts[{from, t.toStage}] += 1; + + // Each application is drawn once, as one left-to-right path: the funnel + // stages it reached in order, then its current stage if that is an + // 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 jobOrder; + QHash 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); } @@ -79,156 +165,85 @@ void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal pad m_nodes.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(); return; } + padding = qMax(0.0, padding); + + // + // Graph. Only left-to-right links are laid out; reload() never produces + // anything else, but stay safe against arbitrary counts. + // QHash inbound; QHash outbound; QSet stageNames; + QList> linkKeys; + for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) { const QString &from = it.key().first; 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(); inbound[to] += it.value(); stageNames.insert(from); stageNames.insert(to); + linkKeys.append(it.key()); } + if (stageNames.isEmpty()) { + Q_EMIT changed(); + return; + } + + // + // Nodes and the columns actually present. + // QList nodeList; nodeList.reserve(stageNames.size()); for (const QString &stage : std::as_const(stageNames)) { - NodeInfo n; - n.stage = stage; - n.column = JobStage::column(stage); - n.order = JobStage::orderInColumn(stage); - n.value = qMax(inbound.value(stage, 0), outbound.value(stage, 0)); - nodeList.append(n); + NodeInfo node; + node.stage = stage; + node.column = JobStage::column(stage); + node.order = JobStage::orderInColumn(stage); + node.sink = isSink(stage); + // 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) { if (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> columnIndices; - for (int i = 0; i < nodeList.size(); ++i) { - columnIndices[nodeList.at(i).column].append(i); + QList usedColumns; + for (const NodeInfo &node : std::as_const(nodeList)) { + 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 usedColumns = columnIndices.keys(); std::sort(usedColumns.begin(), usedColumns.end()); - QHash rankOfColumn; - for (int i = 0; i < usedColumns.size(); ++i) { - rankOfColumn.insert(usedColumns.at(i), i); - } 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(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 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(0.5, (height - gaps) / count))); - } - - for (auto it = columnIndices.begin(); it != columnIndices.end(); ++it) { - const QList &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 nodeIndexByStage; for (int i = 0; i < nodeList.size(); ++i) { + nodeList[i].rank = usedColumns.indexOf(nodeList.at(i).column); nodeIndexByStage.insert(nodeList.at(i).stage, i); } - QList> linkKeys = m_counts.keys(); + // + // Links. + // std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair &a, const QPair &b) { const int aFrom = nodeIndexByStage.value(a.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); }); - // Ribbons share the node's vertical scale, but their minimum visible - // thickness can add up past the node it stacks against; total the raw - // thicknesses per node and side first, then squeeze each ribbon by its - // endpoints' overflow so the stack always stays inside both nodes. - QList rawThickness; - rawThickness.reserve(linkKeys.size()); - QHash outboundThickness; - QHash inboundThickness; + QList links; + links.reserve(linkKeys.size()); for (const auto &key : std::as_const(linkKeys)) { - const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(key.first)); - const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(key.second)); - const qreal minThickness = qMin(1.5, qMin(minHeightByColumn.value(fromNode.column), minHeightByColumn.value(toNode.column))); - const qreal thickness = qMax(m_counts.value(key) * scale, minThickness); - rawThickness.append(thickness); - outboundThickness[key.first] += thickness; - inboundThickness[key.second] += thickness; + LinkInfo link; + link.from = key.first; + link.to = key.second; + link.value = m_counts.value(key); + link.fromIndex = nodeIndexByStage.value(key.first); + link.toIndex = nodeIndexByStage.value(key.second); + 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 finalThickness; - finalThickness.reserve(linkKeys.size()); - for (int i = 0; i < linkKeys.size(); ++i) { - const QPair &key = linkKeys.at(i); - const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(key.first)); - const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(key.second)); - const qreal outFactor = qMin(1.0, fromNode.height / outboundThickness.value(key.first)); - const qreal inFactor = qMin(1.0, toNode.height / inboundThickness.value(key.second)); - finalThickness.append(rawThickness.at(i) * qMin(outFactor, inFactor)); + // + // Order the drop-off outcomes top to bottom by where their applications + // dropped off: outcomes fed by later stages sit higher. Drop-off ribbons + // travel in lanes ordered the same way (latest stage innermost), so this + // keeps the ribbons' order when they rejoin as close as possible to the + // order they travelled in. + // + QHash meanSourceRank; + { + QHash weighted; + QHash total; + for (const LinkInfo &link : std::as_const(links)) { + if (link.sink) { + weighted[link.toIndex] += static_cast(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 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 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 - // 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 - // its siblings. - const auto nodeCenter = [&](const QString &stage) { - const NodeInfo &n = nodeList.at(nodeIndexByStage.value(stage)); - return n.y + n.height / 2.0; + // + // What each column holds, top to bottom: + // + // over-lanes main-line links skipping this column (e.g. Applied -> Interview) + // spine node the funnel stage (or Accepted) + // under-lanes drop-off links on their way to an outcome + // sinks the drop-off outcomes themselves (last column only) + // + // 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> overLanes(rankCount); + QList> underLanes(rankCount); + QList> spineNodes(rankCount); + QList> 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 &lanes, qreal scale) { + qreal sum = 0; + for (int index : lanes) { + sum += links.at(index).value * scale; + } + return sum; }; - QList order(linkKeys.size()); - std::iota(order.begin(), order.end(), 0); - - QList sourceYTop(linkKeys.size()); - std::sort(order.begin(), order.end(), [&](int a, int b) { - const QPair &ka = linkKeys.at(a); - const QPair &kb = linkKeys.at(b); - if (ka.first != kb.first) { - return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first); + const auto blockCount = [&](int rank) { + return static_cast(!overLanes.at(rank).isEmpty()) + static_cast(!underLanes.at(rank).isEmpty()) + spineNodes.at(rank).size() + + sinkNodes.at(rank).size(); + }; + QList minimumHeight(rankCount, 0.0); + for (int rank = 0; rank < rankCount; ++rank) { + const int nodeCount = spineNodes.at(rank).size() + sinkNodes.at(rank).size(); + if (nodeCount > 0) { + const qreal available = qMax(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 sourceCursor; - for (const NodeInfo &n : std::as_const(nodeList)) { - sourceCursor.insert(n.stage, n.y); } - for (int i : std::as_const(order)) { - sourceYTop[i] = sourceCursor.value(linkKeys.at(i).first); - sourceCursor[linkKeys.at(i).first] = sourceYTop.at(i) + finalThickness.at(i); + const auto stackHeight = [&](int rank, qreal scale) { + qreal used = padding * qMax(0, blockCount(rank) - 1); + used += laneThickness(overLanes.at(rank), scale) + laneThickness(underLanes.at(rank), scale); + for (const QList *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(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 targetYTop(linkKeys.size()); - std::sort(order.begin(), order.end(), [&](int a, int b) { - const QPair &ka = linkKeys.at(a); - const QPair &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 targetCursor; - for (const NodeInfo &n : std::as_const(nodeList)) { - targetCursor.insert(n.stage, n.y); + for (LinkInfo &link : links) { + link.thickness = link.value * scale; } - for (int i : std::as_const(order)) { - targetYTop[i] = targetCursor.value(linkKeys.at(i).second); - targetCursor[linkKeys.at(i).second] = targetYTop.at(i) + finalThickness.at(i); + for (int i = 0; i < nodeList.size(); ++i) { + NodeInfo &node = nodeList[i]; + node.width = nodeWidth; + node.height = qMax(minimumHeight.at(node.rank), node.value * scale); } - for (int i = 0; i < linkKeys.size(); ++i) { - const QPair &key = linkKeys.at(i); - const QString &from = key.first; - const QString &to = key.second; - const int value = m_counts.value(key); + // + // Positions. Columns spread over the width by rank; every stack starts at + // the same top, and the tallest one is centered vertically. + // + qreal contentHeight = 0; + for (int rank = 0; rank < rankCount; ++rank) { + contentHeight = qMax(contentHeight, stackHeight(rank, scale)); + } + const qreal top = qMax(0.0, (height - contentHeight) / 2.0); + const qreal bottom = top + contentHeight; - const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from)); - const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to)); + QList columnX(rankCount); + for (int rank = 0; rank < rankCount; ++rank) { + columnX[rank] = rankCount > 1 ? rank * (width - nodeWidth) / static_cast(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); - const qreal y0Bottom = y0Top + thickness; - const qreal y1Top = targetYTop.at(i); - const qreal y1Bottom = y1Top + thickness; + // + // Where each ribbon leaves its source, top to bottom: links skipping + // ahead over later stages, the link to the next stage, then drop-offs. + // Applications still sitting in the stage leave the bottom of the node + // empty. A column has at most one main-line node, so the next stage's + // position never needs to break a tie here. + // + QList> outgoing(nodeList.size()); + QList> 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 qreal x1 = toNode.x; - const qreal midX = (x0 + x1) / 2.0; + const auto outCategory = [&](const LinkInfo &link) { + if (link.sink) { + return 2; + } + return link.passesColumns() ? 0 : 1; + }; + const auto assignOutSlots = [&](int n) { + QList &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 - + point(x1, y1Bottom) + u" C"_s + point(midX, y1Bottom) + u" "_s + point(midX, y0Bottom) + u" "_s + point(x0, y0Bottom) + u" Z"_s; + QList sinkCursor(rankCount, top); + 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); - linkColor.setAlphaF(0.5f); + if (!overLanes.at(rank).isEmpty()) { + 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 &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 &sinks = sinkNodes.at(rank); + if (sinks.isEmpty()) { + continue; + } + qreal arrival = std::numeric_limits::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 &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 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{ - {u"fromStage"_s, from}, - {u"toStage"_s, to}, - {u"value"_s, value}, + {u"fromStage"_s, link.from}, + {u"toStage"_s, link.to}, + {u"value"_s, link.value}, {u"pathData"_s, path}, {u"color"_s, linkColor}, - {u"thickness"_s, thickness}, - {u"sourceY"_s, y0Top}, - {u"targetY"_s, y1Top}, + {u"thickness"_s, link.thickness}, + {u"sourceY"_s, link.sourceY}, + {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 - // past the right edge. Each label is capped to the gap before the next - // 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 columnStarts; - for (const NodeInfo &n : std::as_const(nodeList)) { - if (!columnStarts.contains(n.x)) { - columnStarts.append(n.x); - } - } - std::sort(columnStarts.begin(), columnStarts.end()); - + // + // Nodes and their labels. Labels go right of every column but the last, + // whose labels go left. + // constexpr qreal labelMargin = 8.0; constexpr qreal minLabelWidth = 24.0; + const int lastRank = rankCount - 1; - for (const NodeInfo &n : std::as_const(nodeList)) { - const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData()); + for (const NodeInfo &node : std::as_const(nodeList)) { + const QString label = node.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(node.stage.toUtf8().constData()); - const int columnPos = static_cast(std::distance(columnStarts.begin(), std::find(columnStarts.begin(), columnStarts.end(), n.x))); - const int lastPos = columnStarts.size() - 1; - const bool labelOnRight = columnPos < lastPos || lastPos == 0; - qreal labelWidth = 0; - if (lastPos == 0) { - labelWidth = width - (n.x + n.width) - labelMargin; - } else if (columnPos < lastPos) { - const qreal gap = columnStarts.at(columnPos + 1) - (n.x + n.width); - // The outcome names in the last column run longer than the - // penultimate stage's, so they get the bigger share of the gap. - const qreal share = columnPos == lastPos - 1 ? 0.4 : 1.0; - labelWidth = gap * share - 2 * labelMargin; + const bool labelOnRight = node.rank < lastRank || lastRank == 0; + qreal labelWidth = 0.0; + if (lastRank == 0) { + labelWidth = width - (node.x + node.width) - labelMargin; + } else if (node.rank < lastRank) { + const qreal gap = columnX.at(node.rank + 1) - (node.x + node.width); + // The last gap is shared with the last column's labels, which + // are drawn on its left. + const qreal share = node.rank == lastRank - 1 ? 0.4 : 1.0; + labelWidth = gap * share - 2.0 * labelMargin; } else { - const qreal gap = n.x - (columnStarts.at(columnPos - 1) + n.width); - labelWidth = gap * 0.6 - 2 * labelMargin; + const qreal gap = node.x - (columnX.at(node.rank - 1) + node.width); + labelWidth = gap * 0.6 - 2.0 * labelMargin; } labelWidth = qMax(labelWidth, minLabelWidth); m_nodes.append(QVariantMap{ - {u"stage"_s, n.stage}, + {u"stage"_s, node.stage}, {u"label"_s, label}, - {u"x"_s, n.x}, - {u"y"_s, n.y}, - {u"width"_s, n.width}, - {u"height"_s, n.height}, - {u"value"_s, n.value}, - {u"color"_s, JobStage::color(n.stage)}, + {u"x"_s, node.x}, + {u"y"_s, node.y}, + {u"width"_s, node.width}, + {u"height"_s, node.height}, + {u"value"_s, node.value}, + {u"color"_s, JobStage::color(node.stage)}, {u"labelWidth"_s, labelWidth}, {u"labelOnRight"_s, labelOnRight}, });