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,49 +176,115 @@ private Q_SLOTS:
|
||||
}
|
||||
}
|
||||
|
||||
void ribbonSlotsFollowEndpointHeights()
|
||||
void ribbonsDoNotCross_data()
|
||||
{
|
||||
QTest::addColumn<int>("seed");
|
||||
QTest::newRow("dense pipeline") << int(DenseSeed);
|
||||
QTest::newRow("links skipping stages") << int(SkipSeed);
|
||||
QTest::newRow("back and sideways moves") << int(BackMoveSeed);
|
||||
}
|
||||
|
||||
// The "braid" bug: ribbons twisting over each other. Checked on the drawn
|
||||
// geometry itself: no two ribbons may overlap anywhere between their ends.
|
||||
void ribbonsDoNotCross()
|
||||
{
|
||||
QFETCH(int, seed);
|
||||
JobsDatabase db;
|
||||
seedPipeline(db, seed);
|
||||
|
||||
SankeyModel model;
|
||||
for (const QSizeF size : {QSizeF(600, 300), QSizeF(900, 500), QSizeF(400, 800)}) {
|
||||
model.reload(size.width(), size.height());
|
||||
QVERIFY(!model.isEmpty());
|
||||
verifyNoOverlaps(model, std::numeric_limits<qreal>::max());
|
||||
}
|
||||
}
|
||||
|
||||
// With one node per outcome, ribbons from different stages into different
|
||||
// outcomes can't always keep their order; any such crossing must stay in
|
||||
// the final gap where they rejoin, never braid across the diagram.
|
||||
void crossingsOnlyWhereRibbonsRejoin()
|
||||
{
|
||||
JobsDatabase db;
|
||||
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())});
|
||||
links.insert(m.value(QStringLiteral("fromStage")).toString() + QStringLiteral(">") + m.value(QStringLiteral("toStage")).toString(),
|
||||
m.value(QStringLiteral("value")).toInt());
|
||||
}
|
||||
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);
|
||||
}
|
||||
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()
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user