diff --git a/CMakeLists.txt b/CMakeLists.txt index 658ad93..86c14eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,7 +5,7 @@ cmake_minimum_required(VERSION 3.16) -project(kareer VERSION 0.1.2 LANGUAGES CXX) +project(kareer VERSION 0.1.3 LANGUAGES CXX) set(REQUIRED_QT_VERSION 6.6.0) set(REQUIRED_KF_VERSION 6.5.0) @@ -33,6 +33,9 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS Widgets Qml Quick + # QtQuick.Shapes runtime QML module (SankeyDiagram.qml); configure-time + # guard only, nothing links against it. + QuickShapesPrivate QuickControls2 Sql Test diff --git a/README.md b/README.md index 6bc753d..dfb0e40 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ Requires Qt 6, KDE Frameworks 6, Kirigami, Kirigami Addons and the CMake toolchain. On Arch/CachyOS: ```sh -sudo pacman -S --needed cmake extra-cmake-modules base-devel \ - qt6-base qt6-declarative kirigami kirigami-addons \ +sudo pacman -S --needed cmake ninja extra-cmake-modules base-devel \ + qt6-base qt6-declarative vulkan-headers kirigami kirigami-addons \ ki18n kcoreaddons kiconthemes kcrash kitemmodels \ kcolorscheme qqc2-desktop-style ``` @@ -109,11 +109,14 @@ sudo pacman -S --needed cmake extra-cmake-modules base-devel \ Then: ```sh -cmake -B build -G Ninja +cmake -B build cmake --build build ./build/bin/kareer ``` +(If you have `ninja` installed, add `-G Ninja` to the configure step or +use the `ninja-dev` preset: `cmake --preset ninja-dev`.) + Run the tests with `ctest --test-dir build`. ## Architecture @@ -136,4 +139,5 @@ Run the tests with `ctest --test-dir build`. - `appcolorscheme.{h,cpp}` - QML-facing wrapper around `KColorSchemeManager` for the Preferences page. - `qml/` - Kirigami UI: `ApplicationsPage` (list + search), `ApplicationEditPage` (add/edit/delete form), `DashboardPage` - (stat cards + pipeline), `SankeyDiagram` (the renderer). + (stat cards + pipeline), `SankeyDiagram` (the renderer), + `SettingsPage` (the Preferences page). diff --git a/autotests/sankeylayouttest.cpp b/autotests/sankeylayouttest.cpp index 2de6dab..35ed503 100644 --- a/autotests/sankeylayouttest.cpp +++ b/autotests/sankeylayouttest.cpp @@ -8,6 +8,8 @@ #include "jobsdatabase.h" #include "sankeymodel.h" +#include +#include #include #include #include @@ -18,6 +20,13 @@ class SankeyLayoutTest : public QObject { Q_OBJECT + // Defaults of SankeyModel::reload()/relayout(), which every test uses. + static constexpr qreal nodeWidth = 16.0; + static constexpr qreal rowPadding = 10.0; + // The label margin/floor baked into the layout (sankeymodel.cpp). + static constexpr qreal labelMargin = 8.0; + static constexpr qreal eps = 0.01; + private Q_SLOTS: void init() { @@ -30,23 +39,13 @@ private Q_SLOTS: { JobsDatabase db; - auto makeJob = [&](const QString &stage) { - Job job; - job.company = QStringLiteral("Co"); - job.title = QStringLiteral("Title"); - QVERIFY(db.addJob(job)); - if (stage != QStringLiteral("Applied")) { - QVERIFY(db.setStage(job.id, stage)); - } - }; - - makeJob(QStringLiteral("Applied")); - makeJob(QStringLiteral("Applied")); - makeJob(QStringLiteral("Screening")); - makeJob(QStringLiteral("Rejected")); + makeJob(db, QStringLiteral("Applied")); + makeJob(db, QStringLiteral("Applied")); + makeJob(db, QStringLiteral("Screening")); + makeJob(db, QStringLiteral("Rejected")); SankeyModel model; - model.relayout(600, 400); + model.reload(600, 400); QVERIFY(!model.isEmpty()); @@ -85,13 +84,263 @@ private Q_SLOTS: QVERIFY(db.isOpen()); SankeyModel model; - model.relayout(400, 300); + model.reload(400, 300); QVERIFY(model.isEmpty()); QVERIFY(model.nodes().isEmpty()); QVERIFY(model.links().isEmpty()); } + void geometryFitsViewport() + { + JobsDatabase db; + seedDensePipeline(db); + + SankeyModel model; + for (const QSizeF size : {QSizeF(600, 300), QSizeF(600, 40)}) { + model.reload(size.width(), size.height()); + QVERIFY(!model.isEmpty()); + + QHash columnHeights; + QHash columnCounts; + for (const QVariant &v : model.nodes()) { + const QVariantMap m = v.toMap(); + const qreal x = m.value(QStringLiteral("x")).toReal(); + const qreal y = m.value(QStringLiteral("y")).toReal(); + const qreal w = m.value(QStringLiteral("width")).toReal(); + const qreal h = m.value(QStringLiteral("height")).toReal(); + QVERIFY(x >= -eps); + QVERIFY(x + w <= size.width() + eps); + QVERIFY(y >= -eps); + QVERIFY(y + h <= size.height() + eps); + columnHeights[x] += h; + columnCounts[x] += 1; + + // The label rect must lie inside the viewport too. + const qreal labelWidth = m.value(QStringLiteral("labelWidth")).toReal(); + if (m.value(QStringLiteral("labelOnRight")).toBool()) { + QVERIFY(x + w + labelMargin + labelWidth <= size.width() + eps); + } else { + QVERIFY(x - labelMargin - labelWidth >= -eps); + } + } + for (auto it = columnHeights.constBegin(); it != columnHeights.constEnd(); ++it) { + const qreal gaps = rowPadding * (columnCounts.value(it.key()) - 1); + QVERIFY(it.value() + gaps <= size.height() + eps); + } + } + } + + void ribbonsStayInsideNodes() + { + JobsDatabase db; + seedDensePipeline(db); + + 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); + } + + QHash outboundTotal; + QHash inboundTotal; + for (const QVariant &v : model.links()) { + const QVariantMap m = v.toMap(); + const qreal thickness = m.value(QStringLiteral("thickness")).toReal(); + QVERIFY(thickness > 0); + + const QVariantMap fromNode = nodeByStage.value(m.value(QStringLiteral("fromStage")).toString()); + const QVariantMap toNode = nodeByStage.value(m.value(QStringLiteral("toStage")).toString()); + const qreal sourceY = m.value(QStringLiteral("sourceY")).toReal(); + const qreal targetY = m.value(QStringLiteral("targetY")).toReal(); + + QVERIFY(sourceY >= fromNode.value(QStringLiteral("y")).toReal() - eps); + QVERIFY(sourceY + thickness <= fromNode.value(QStringLiteral("y")).toReal() + fromNode.value(QStringLiteral("height")).toReal() + eps); + QVERIFY(targetY >= toNode.value(QStringLiteral("y")).toReal() - eps); + QVERIFY(targetY + thickness <= toNode.value(QStringLiteral("y")).toReal() + toNode.value(QStringLiteral("height")).toReal() + eps); + + outboundTotal[m.value(QStringLiteral("fromStage")).toString()] += thickness; + inboundTotal[m.value(QStringLiteral("toStage")).toString()] += thickness; + } + for (auto it = outboundTotal.constBegin(); it != outboundTotal.constEnd(); ++it) { + QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps); + } + for (auto it = inboundTotal.constBegin(); it != inboundTotal.constEnd(); ++it) { + QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps); + } + } + + void ribbonSlotsFollowEndpointHeights() + { + JobsDatabase db; + seedDensePipeline(db); + + 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 + 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> 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); + } + }; + for (const auto &slots : std::as_const(outgoing)) { + verifySorted(slots); + } + for (const auto &slots : std::as_const(incoming)) { + verifySorted(slots); + } + } + + void sparseColumnsFillWidth() + { + JobsDatabase db; + makeJob(db, QStringLiteral("Applied")); + makeJob(db, QStringLiteral("Applied")); + makeJob(db, QStringLiteral("Rejected")); + + SankeyModel model; + model.reload(600, 300); + + // Only Start, Applied and Rejected are present: their columns should + // spread evenly over the full width, not sit at canonical positions. + 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()); + QCOMPARE(xs.size(), 3); + QVERIFY(qAbs(xs.at(0)) < eps); + QVERIFY(qAbs(xs.at(1) - (600 - nodeWidth) / 2.0) < eps); + QVERIFY(qAbs(xs.at(2) - (600 - nodeWidth)) < eps); + } + + void caseVariantStageIsCanonicalized() + { + JobsDatabase db; + + Job job; + job.company = QStringLiteral("Co"); + job.title = QStringLiteral("Title"); + QVERIFY(db.addJob(job)); + QVERIFY(db.setStage(job.id, QStringLiteral("rejected"))); + QCOMPARE(db.jobById(job.id)->stage, QStringLiteral("Rejected")); + + SankeyModel model; + model.reload(600, 300); + QStringList stages; + for (const QVariant &v : model.nodes()) { + stages.append(v.toMap().value(QStringLiteral("stage")).toString()); + } + QVERIFY(stages.contains(QStringLiteral("Rejected"))); + QVERIFY(!stages.contains(QStringLiteral("rejected"))); + + // Pre-canonicalization rows already in the database are repaired the + // next time it is opened (migrate() normalization). + { + QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("fixup")); + raw.setDatabaseName(m_dir->path() + QStringLiteral("/test.sqlite")); + QVERIFY(raw.open()); + QSqlQuery q(raw); + QVERIFY(q.exec(QStringLiteral("UPDATE jobs SET stage = 'ghosted'"))); + QVERIFY(q.exec(QStringLiteral("UPDATE stage_history SET to_stage = 'ghosted' WHERE to_stage = 'Rejected'"))); + raw.close(); + } + QSqlDatabase::removeDatabase(QStringLiteral("fixup")); + + JobsDatabase reopened; + QVERIFY(reopened.isOpen()); + QCOMPARE(reopened.jobById(job.id)->stage, QStringLiteral("Ghosted")); + const auto transitions = reopened.stageTransitions(); + for (const StageTransition &t : transitions) { + QVERIFY(t.toStage != QStringLiteral("ghosted")); + } + } + + void degenerateSizesDoNotCrash() + { + JobsDatabase db; + seedDensePipeline(db); + + SankeyModel model; + model.reload(0, 0); + QVERIFY(model.isEmpty()); + model.relayout(-5, 100); + QVERIFY(model.isEmpty()); + model.relayout(5, 5); + model.relayout(2000, 2); + model.relayout(600, 300); + QVERIFY(!model.isEmpty()); + } + private: + static void makeJob(JobsDatabase &db, const QString &stage) + { + Job job; + job.company = QStringLiteral("Co"); + job.title = QStringLiteral("Title"); + QVERIFY(db.addJob(job)); + if (stage != QStringLiteral("Applied")) { + QVERIFY(db.setStage(job.id, stage)); + } + } + + static void walkJob(JobsDatabase &db, const QStringList &stages) + { + Job job; + job.company = QStringLiteral("Co"); + job.title = QStringLiteral("Title"); + QVERIFY(db.addJob(job)); + for (const QString &stage : stages) { + QVERIFY(db.setStage(job.id, stage)); + } + } + + /// Populates every column, including all four terminal outcomes sharing + /// the last one. + static void seedDensePipeline(JobsDatabase &db) + { + walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Offer"), QStringLiteral("Accepted")}); + walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Rejected")}); + walkJob(db, {QStringLiteral("Ghosted")}); + walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Rejected")}); + walkJob(db, {QStringLiteral("Withdrawn")}); + walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Rejected")}); + walkJob(db, {}); + walkJob(db, {}); + walkJob(db, {}); + walkJob(db, {}); + } + std::unique_ptr m_dir; }; diff --git a/io.github.toservetheking.Kareer.metainfo.xml b/io.github.toservetheking.Kareer.metainfo.xml index 74ebf26..31ad41b 100644 --- a/io.github.toservetheking.Kareer.metainfo.xml +++ b/io.github.toservetheking.Kareer.metainfo.xml @@ -64,6 +64,16 @@ + + +
    +
  • Redesign the Sankey pipeline layout: top-aligned funnel, columns spread across the full width, labels always visible
  • +
  • Order ribbons at each stage to stop them braiding over each other
  • +
  • Canonicalize stage names written via the CLI (e.g. "rejected" now maps to "Rejected") and repair existing databases
  • +
  • Debounce window-resize relayouts and stop re-reading the database on resize
  • +
+
+
    diff --git a/src/clicommands.cpp b/src/clicommands.cpp index 63fcc62..e61240c 100644 --- a/src/clicommands.cpp +++ b/src/clicommands.cpp @@ -443,7 +443,7 @@ int runStage(const QString &program, const QStringList &args) if (parser.isSet(u"json"_s)) { printJson(jobToJson(*job)); } else { - QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(stage) << Qt::endl; + QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(job->stage) << Qt::endl; } return 0; } diff --git a/src/jobsdatabase.cpp b/src/jobsdatabase.cpp index a4baae6..cfd6b77 100644 --- a/src/jobsdatabase.cpp +++ b/src/jobsdatabase.cpp @@ -132,6 +132,26 @@ bool JobsDatabase::migrate() return false; } + // Rows written before stages were canonicalized on write (e.g. + // "rejected" via the CLI) would otherwise show up as separate Sankey + // nodes with default column/color. lower() is ASCII-only, which is fine: + // the stage vocabulary is ASCII. + for (const QString &stage : JobStage::canonicalStages()) { + for (const QString &statement : { + u"UPDATE jobs SET stage = :s WHERE stage != :s AND lower(stage) = lower(:s)"_s, + u"UPDATE stage_history SET to_stage = :s WHERE to_stage != :s AND lower(to_stage) = lower(:s)"_s, + u"UPDATE stage_history SET from_stage = :s WHERE from_stage != :s AND lower(from_stage) = lower(:s)"_s, + }) { + QSqlQuery normalize(db); + normalize.prepare(statement); + normalize.bindValue(u":s"_s, stage); + if (!normalize.exec()) { + m_lastError = normalize.lastError().text(); + return false; + } + } + } + return true; } @@ -202,6 +222,7 @@ bool JobsDatabase::addJob(Job &job) m_lastError = u"Unknown stage '%1'"_s.arg(job.stage); return false; } + job.stage = JobStage::canonical(job.stage); QSqlDatabase db = QSqlDatabase::database(m_connectionName); const QDateTime now = QDateTime::currentDateTimeUtc(); @@ -299,13 +320,14 @@ bool JobsDatabase::setStage(int id, const QString &newStage) m_lastError = u"Unknown stage '%1'"_s.arg(newStage); return false; } + const QString stage = JobStage::canonical(newStage); const auto current = jobById(id); if (!current) { m_lastError = u"No job with id %1"_s.arg(id); return false; } - if (current->stage.compare(newStage, Qt::CaseInsensitive) == 0) { + if (current->stage.compare(stage, Qt::CaseInsensitive) == 0) { return true; } @@ -314,7 +336,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage) QSqlQuery query(db); query.prepare(u"UPDATE jobs SET stage = :stage, updated_at = :updated_at WHERE id = :id"_s); - query.bindValue(u":stage"_s, newStage); + query.bindValue(u":stage"_s, stage); query.bindValue(u":updated_at"_s, now.toString(Qt::ISODate)); query.bindValue(u":id"_s, id); if (!query.exec()) { @@ -326,7 +348,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage) history.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s); history.bindValue(u":job_id"_s, id); history.bindValue(u":from_stage"_s, current->stage); - history.bindValue(u":to_stage"_s, newStage); + history.bindValue(u":to_stage"_s, stage); history.bindValue(u":changed_at"_s, now.toString(Qt::ISODate)); if (!history.exec()) { m_lastError = history.lastError().text(); diff --git a/src/jobstage.cpp b/src/jobstage.cpp index eb7efc3..ee36f1b 100644 --- a/src/jobstage.cpp +++ b/src/jobstage.cpp @@ -32,6 +32,19 @@ bool isValid(const QString &stage) return canonicalStages().contains(stage, Qt::CaseInsensitive); } +QString canonical(const QString &stage) +{ + if (stage.compare(QLatin1String(Start), Qt::CaseInsensitive) == 0) { + return QString::fromLatin1(Start); + } + for (const QString &candidate : canonicalStages()) { + if (stage.compare(candidate, Qt::CaseInsensitive) == 0) { + return candidate; + } + } + return stage; +} + int column(const QString &stage) { static const QHash columns{ diff --git a/src/jobstage.h b/src/jobstage.h index 99f911c..0a84d00 100644 --- a/src/jobstage.h +++ b/src/jobstage.h @@ -25,6 +25,11 @@ QStringList canonicalStages(); bool isValid(const QString &stage); +/// Canonical-case spelling for a stage matched case-insensitively +/// ("rejected" -> "Rejected", "start" -> Start). Unknown input is returned +/// unchanged. +QString canonical(const QString &stage); + /// Sankey column index. Start = 0; Applied..Offer walk the funnel; the three /// terminal outcomes (Accepted/Rejected/Withdrawn/Ghosted) share the last /// column so a rejection right after Applied is still a valid (longer) link. diff --git a/src/qml/SankeyDiagram.qml b/src/qml/SankeyDiagram.qml index 6a0eaf9..abbca36 100644 --- a/src/qml/SankeyDiagram.qml +++ b/src/qml/SankeyDiagram.qml @@ -18,21 +18,29 @@ Item { id: root readonly property bool empty: sankeyModel.empty + readonly property real nodeWidth: Math.round(Kirigami.Units.gridUnit * 0.75) + readonly property real rowPadding: Kirigami.Units.gridUnit SankeyModel { id: sankeyModel } function refresh(): void { - if (root.width > 0 && root.height > 0) { - sankeyModel.relayout(root.width, root.height); - } + sankeyModel.reload(root.width, root.height, root.nodeWidth, root.rowPadding); } - onWidthChanged: refresh() - onHeightChanged: refresh() + // Resizes only need fresh geometry, not a database re-read, and are + // debounced so a window drag doesn't rebuild every delegate per pixel. + onWidthChanged: relayoutTimer.restart() + onHeightChanged: relayoutTimer.restart() Component.onCompleted: refresh() + Timer { + id: relayoutTimer + interval: 150 + onTriggered: sankeyModel.relayout(root.width, root.height, root.nodeWidth, root.rowPadding) + } + Kirigami.PlaceholderMessage { anchors.centerIn: parent width: parent.width - Kirigami.Units.gridUnit * 4 @@ -46,7 +54,6 @@ Item { model: sankeyModel.links delegate: Shape { required property var modelData - asynchronous: true ShapePath { fillColor: modelData.color strokeColor: "transparent" @@ -79,14 +86,27 @@ Item { hoverEnabled: true } + // Subtle backing so the label stays legible where ribbons pass + // underneath it. + Rectangle { + anchors.fill: labelHeading + anchors.margins: -Kirigami.Units.smallSpacing / 2 + radius: 3 + color: Kirigami.Theme.backgroundColor + opacity: 0.6 + } + Kirigami.Heading { + id: labelHeading level: 5 - anchors.left: parent.right + anchors.left: nodeDelegate.modelData.labelOnRight ? parent.right : undefined + anchors.right: nodeDelegate.modelData.labelOnRight ? undefined : parent.left anchors.leftMargin: Kirigami.Units.smallSpacing + anchors.rightMargin: Kirigami.Units.smallSpacing anchors.verticalCenter: parent.verticalCenter - width: nodeDelegate.modelData.labelWidth + width: Math.min(nodeDelegate.modelData.labelWidth, implicitWidth) + horizontalAlignment: nodeDelegate.modelData.labelOnRight ? Text.AlignLeft : Text.AlignRight text: nodeDelegate.modelData.label - visible: nodeDelegate.height >= Kirigami.Units.gridUnit elide: Text.ElideRight } } diff --git a/src/sankeymodel.cpp b/src/sankeymodel.cpp index 5948196..8932b9c 100644 --- a/src/sankeymodel.cpp +++ b/src/sankeymodel.cpp @@ -15,6 +15,7 @@ #include #include #include +#include using namespace Qt::Literals::StringLiterals; @@ -62,25 +63,23 @@ bool SankeyModel::isEmpty() const return m_nodes.isEmpty(); } -void SankeyModel::relayout(qreal width, qreal height) +void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding) +{ + 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; + } + relayout(width, height, nodeWidth, padding); +} + +void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal padding) { m_nodes.clear(); m_links.clear(); - if (width <= 0 || height <= 0) { - Q_EMIT changed(); - return; - } - - const QList transitions = m_db.stageTransitions(); - - QHash, int> counts; - for (const StageTransition &t : transitions) { - const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage; - counts[{from, t.toStage}] += 1; - } - - if (counts.isEmpty()) { + if (width <= 0 || height <= 0 || m_counts.isEmpty()) { Q_EMIT changed(); return; } @@ -88,7 +87,7 @@ void SankeyModel::relayout(qreal width, qreal height) QHash inbound; QHash outbound; QSet stageNames; - for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) { + for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) { const QString &from = it.key().first; const QString &to = it.key().second; outbound[from] += it.value(); @@ -116,14 +115,23 @@ void SankeyModel::relayout(qreal width, qreal height) }); QHash> columnIndices; - int maxColumn = 0; for (int i = 0; i < nodeList.size(); ++i) { columnIndices[nodeList.at(i).column].append(i); - maxColumn = qMax(maxColumn, nodeList.at(i).column); } - constexpr qreal nodeWidth = 16.0; - constexpr qreal padding = 10.0; + // 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 @@ -150,15 +158,59 @@ void SankeyModel::relayout(qreal width, qreal height) 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(); - qreal totalHeight = 0; - for (int idx : idxs) { - totalHeight += qMax(nodeList.at(idx).value * scale, 2.0); - } - const qreal gaps = padding * qMax(0, idxs.size() - 1); - const qreal startY = qMax(0.0, (height - totalHeight - gaps) / 2.0); - const qreal x = maxColumn > 0 ? (it.key() * (width - nodeWidth) / maxColumn) : 0.0; + 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) { @@ -166,7 +218,7 @@ void SankeyModel::relayout(qreal width, qreal height) n.x = x; n.y = cursorY; n.width = nodeWidth; - n.height = qMax(n.value * scale, 2.0); + n.height = qMax(n.value * scale, minHeight); cursorY += n.height + padding; } } @@ -176,14 +228,7 @@ void SankeyModel::relayout(qreal width, qreal height) nodeIndexByStage.insert(nodeList.at(i).stage, i); } - QHash sourceCursor; - QHash targetCursor; - for (const NodeInfo &n : std::as_const(nodeList)) { - sourceCursor.insert(n.stage, n.y); - targetCursor.insert(n.stage, n.y); - } - - QList> linkKeys = counts.keys(); + QList> linkKeys = m_counts.keys(); 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); @@ -193,22 +238,107 @@ void SankeyModel::relayout(qreal width, qreal height) 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; 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; + } + + 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)); + } + + // 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; + }; + 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 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); + } + + 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 (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 &key = linkKeys.at(i); const QString &from = key.first; const QString &to = key.second; - const int value = counts.value(key); - const qreal thickness = qMax(value * scale, 1.5); + const int value = m_counts.value(key); const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from)); const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to)); - const qreal y0Top = sourceCursor.value(from); + const qreal thickness = finalThickness.at(i); + const qreal y0Top = sourceYTop.at(i); const qreal y0Bottom = y0Top + thickness; - sourceCursor[from] = y0Bottom; - - const qreal y1Top = targetCursor.value(to); + const qreal y1Top = targetYTop.at(i); const qreal y1Bottom = y1Top + thickness; - targetCursor[to] = y1Bottom; const qreal x0 = fromNode.x + fromNode.width; const qreal x1 = toNode.x; @@ -226,14 +356,18 @@ void SankeyModel::relayout(qreal width, qreal height) {u"value"_s, value}, {u"pathData"_s, path}, {u"color"_s, linkColor}, + {u"thickness"_s, thickness}, + {u"sourceY"_s, y0Top}, + {u"targetY"_s, y1Top}, }); } - // Labels are drawn to the right of each node. Without a bound, a long - // label (e.g. "Applications") can run into the next column's node and - // its own label. Cap each node's label to the gap before the next - // distinct column actually in use (skipping empty columns), so text - // elides instead of overlapping. + // 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)) { @@ -242,12 +376,29 @@ void SankeyModel::relayout(qreal width, qreal height) } std::sort(columnStarts.begin(), columnStarts.end()); + constexpr qreal labelMargin = 8.0; + constexpr qreal minLabelWidth = 24.0; + for (const NodeInfo &n : std::as_const(nodeList)) { const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData()); const int columnPos = static_cast(std::distance(columnStarts.begin(), std::find(columnStarts.begin(), columnStarts.end(), n.x))); - const qreal nextColumnX = columnPos + 1 < columnStarts.size() ? columnStarts.at(columnPos + 1) : width; - const qreal labelWidth = qMax(24.0, nextColumnX - (n.x + n.width) - 8.0); + 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; + } else { + const qreal gap = n.x - (columnStarts.at(columnPos - 1) + n.width); + labelWidth = gap * 0.6 - 2 * labelMargin; + } + labelWidth = qMax(labelWidth, minLabelWidth); m_nodes.append(QVariantMap{ {u"stage"_s, n.stage}, @@ -259,6 +410,7 @@ void SankeyModel::relayout(qreal width, qreal height) {u"value"_s, n.value}, {u"color"_s, JobStage::color(n.stage)}, {u"labelWidth"_s, labelWidth}, + {u"labelOnRight"_s, labelOnRight}, }); } diff --git a/src/sankeymodel.h b/src/sankeymodel.h index 6b48ffa..45d51cd 100644 --- a/src/sankeymodel.h +++ b/src/sankeymodel.h @@ -8,7 +8,9 @@ #include "jobsdatabase.h" +#include #include +#include #include #include @@ -39,15 +41,20 @@ public: bool isEmpty() const; public Q_SLOTS: - /// Recomputes node/link geometry to fit within (width, height) logical - /// pixels. Call whenever the data or the available viewport changes. - void relayout(qreal width, qreal height); + /// Re-reads the stage history from the database, then lays it out to fit + /// within (width, height) logical pixels. Call whenever the data changes. + void reload(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0); + + /// Recomputes node/link geometry from the cached stage history. Call on + /// viewport resizes; unlike reload() this never touches the database. + void relayout(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0); Q_SIGNALS: void changed(); private: JobsDatabase m_db; + QHash, int> m_counts; QVariantList m_nodes; QVariantList m_links; };