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:
+265
-31
@@ -8,10 +8,13 @@
|
||||
#include "jobsdatabase.h"
|
||||
#include "sankeymodel.h"
|
||||
|
||||
#include <QPointF>
|
||||
#include <QRegularExpression>
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
|
||||
// SankeyModel always opens JobsDatabase::defaultPath(), so each test points
|
||||
@@ -173,51 +176,117 @@ 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;
|
||||
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;
|
||||
model.reload(600, 300);
|
||||
QVERIFY(!model.isEmpty());
|
||||
|
||||
QHash<QString, QVariantMap> 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<QString, QList<QPair<qreal, qreal>>> outgoing; // sourceY -> target center
|
||||
QHash<QString, QList<QPair<qreal, qreal>>> incoming; // targetY -> source center
|
||||
QHash<QString, int> 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())});
|
||||
}
|
||||
const auto verifySorted = [](QList<QPair<qreal, qreal>> 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);
|
||||
links.insert(m.value(QStringLiteral("fromStage")).toString() + QStringLiteral(">") + m.value(QStringLiteral("toStage")).toString(),
|
||||
m.value(QStringLiteral("value")).toInt());
|
||||
}
|
||||
const QHash<QString, int> 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<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()
|
||||
{
|
||||
JobsDatabase db;
|
||||
@@ -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<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)
|
||||
{
|
||||
Job job;
|
||||
|
||||
+548
-254
@@ -9,21 +9,25 @@
|
||||
|
||||
#include <KLocalizedString>
|
||||
|
||||
#include <QColor>
|
||||
#include <QHash>
|
||||
#include <QPair>
|
||||
#include <QPointF>
|
||||
#include <QSet>
|
||||
#include <QVariantMap>
|
||||
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
#include <numeric>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
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<int, qreal> 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<StageTransition> 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<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);
|
||||
}
|
||||
|
||||
@@ -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<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> outbound;
|
||||
QSet<QString> stageNames;
|
||||
QList<QPair<QString, QString>> 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<NodeInfo> 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;
|
||||
}
|
||||
if (a.order != b.order) {
|
||||
return a.order < b.order;
|
||||
}
|
||||
return a.stage < b.stage;
|
||||
});
|
||||
|
||||
QHash<int, QList<int>> columnIndices;
|
||||
for (int i = 0; i < nodeList.size(); ++i) {
|
||||
columnIndices[nodeList.at(i).column].append(i);
|
||||
QList<int> 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<int> usedColumns = columnIndices.keys();
|
||||
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();
|
||||
|
||||
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;
|
||||
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<QPair<QString, QString>> linkKeys = m_counts.keys();
|
||||
//
|
||||
// Links.
|
||||
//
|
||||
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 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<qreal> rawThickness;
|
||||
rawThickness.reserve(linkKeys.size());
|
||||
QHash<QString, qreal> outboundThickness;
|
||||
QHash<QString, qreal> inboundThickness;
|
||||
QList<LinkInfo> 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<qreal>(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<qreal> finalThickness;
|
||||
finalThickness.reserve(linkKeys.size());
|
||||
for (int i = 0; i < linkKeys.size(); ++i) {
|
||||
const QPair<QString, QString> &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<qreal>(1.0, fromNode.height / outboundThickness.value(key.first));
|
||||
const qreal inFactor = qMin<qreal>(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<int, qreal> meanSourceRank;
|
||||
{
|
||||
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
|
||||
// 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<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;
|
||||
};
|
||||
const auto blockCount = [&](int rank) {
|
||||
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> 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<qreal>(0.0, height - padding * qMax(0, blockCount(rank) - 1));
|
||||
minimumHeight[rank] = qMin(minimumNodeHeight, available / nodeCount);
|
||||
}
|
||||
}
|
||||
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<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;
|
||||
};
|
||||
QList<int> order(linkKeys.size());
|
||||
std::iota(order.begin(), order.end(), 0);
|
||||
|
||||
QList<qreal> sourceYTop(linkKeys.size());
|
||||
std::sort(order.begin(), order.end(), [&](int a, int b) {
|
||||
const QPair<QString, QString> &ka = linkKeys.at(a);
|
||||
const QPair<QString, QString> &kb = linkKeys.at(b);
|
||||
if (ka.first != kb.first) {
|
||||
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
|
||||
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));
|
||||
}
|
||||
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);
|
||||
for (int iteration = 0; iteration < 60; ++iteration) {
|
||||
const qreal middle = (low + high) / 2.0;
|
||||
if (fits(middle)) {
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
scale = low;
|
||||
}
|
||||
|
||||
for (LinkInfo &link : links) {
|
||||
link.thickness = link.value * scale;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
//
|
||||
// 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<qreal>(0.0, (height - contentHeight) / 2.0);
|
||||
const qreal bottom = top + contentHeight;
|
||||
|
||||
QList<qreal> columnX(rankCount);
|
||||
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);
|
||||
}
|
||||
|
||||
//
|
||||
// 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<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 auto outCategory = [&](const LinkInfo &link) {
|
||||
if (link.sink) {
|
||||
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;
|
||||
});
|
||||
QHash<QString, qreal> sourceCursor;
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
sourceCursor.insert(n.stage, n.y);
|
||||
qreal cursor = nodeList.at(n).y;
|
||||
for (int index : std::as_const(slotOrder)) {
|
||||
links[index].sourceY = cursor;
|
||||
cursor += links.at(index).thickness;
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
QList<qreal> 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;
|
||||
};
|
||||
|
||||
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<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;
|
||||
}
|
||||
|
||||
QList<qreal> targetYTop(linkKeys.size());
|
||||
std::sort(order.begin(), order.end(), [&](int a, int b) {
|
||||
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);
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
const qreal ya = nodeCenter(ka.first);
|
||||
const qreal yb = nodeCenter(kb.first);
|
||||
if (!qFuzzyCompare(ya, yb)) {
|
||||
return ya < yb;
|
||||
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);
|
||||
}
|
||||
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
|
||||
}
|
||||
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;
|
||||
});
|
||||
QHash<QString, qreal> targetCursor;
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
targetCursor.insert(n.stage, n.y);
|
||||
qreal cursor = nodeList.at(n).y;
|
||||
for (int index : std::as_const(slotOrder)) {
|
||||
links[index].targetY = cursor;
|
||||
cursor += links.at(index).thickness;
|
||||
}
|
||||
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 < linkKeys.size(); ++i) {
|
||||
const QPair<QString, QString> &key = linkKeys.at(i);
|
||||
const QString &from = key.first;
|
||||
const QString &to = key.second;
|
||||
const int value = m_counts.value(key);
|
||||
//
|
||||
// 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);
|
||||
|
||||
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from));
|
||||
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to));
|
||||
// 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});
|
||||
|
||||
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;
|
||||
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;
|
||||
|
||||
const qreal x0 = fromNode.x + fromNode.width;
|
||||
const qreal x1 = toNode.x;
|
||||
const qreal midX = (x0 + x1) / 2.0;
|
||||
|
||||
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;
|
||||
|
||||
QColor linkColor = JobStage::color(from);
|
||||
linkColor.setAlphaF(0.5f);
|
||||
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<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());
|
||||
|
||||
//
|
||||
// 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<int>(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},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user