v0.1.3: redesign the Sankey pipeline layout
Lint / REUSE compliance (push) Failing after 13s
Build and Test / Build and run tests (push) Failing after 44s
Lint / clang-format (push) Failing after 57s
Build and Publish Flatpak / Build Flatpak (push) Failing after 14s
Build and Publish Flatpak / Deploy hosted repo to Pages (push) Skipped

- Spread columns by rank among the stages actually present, so sparse
  pipelines fill the width instead of leaving dead stretches
- Top-align columns: the funnel's success path runs level along the top
  and drop-off ribbons only ever descend
- Give each ribbon its slot per node side (outgoing by target height,
  incoming by source height) so ribbons stop braiding over each other
- Move the last column's labels to its left so they always stay
  on-screen, with a theme halo to stay legible over ribbons
- Never hide small-node labels; elide with tooltip instead
- Guarantee columns and ribbons fit the viewport (clamp-aware scale,
  spill-proof ribbon thickness)
- Debounce resize relayouts and stop re-querying SQLite on resize
- Canonicalize stage names on write (CLI "rejected" -> "Rejected") and
  repair pre-existing rows on database open
- Take Sankey node width/padding from Kirigami units
- Add QuickShapesPrivate configure-time guard for QtQuick.Shapes
- Extend sankeylayouttest: viewport fit, ribbon containment, slot
  ordering, sparse-column spread, canonicalization, degenerate sizes
- README: build-dependency and generator fixes, document SettingsPage

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019FXfh48mASRsE1WEhy9jcM
This commit is contained in:
2026-08-31 18:51:49 -05:00
co-authored by Claude Fable 5
parent 078343ff96
commit 506b66cfdd
11 changed files with 572 additions and 87 deletions
+4 -1
View File
@@ -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
+8 -4
View File
@@ -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).
+265 -16
View File
@@ -8,6 +8,8 @@
#include "jobsdatabase.h"
#include "sankeymodel.h"
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QTemporaryDir>
#include <QtTest>
#include <memory>
@@ -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<qreal, qreal> columnHeights;
QHash<qreal, int> 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<QString, QVariantMap> nodeByStage;
for (const QVariant &v : model.nodes()) {
const QVariantMap m = v.toMap();
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
}
QHash<QString, qreal> outboundTotal;
QHash<QString, qreal> 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<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
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);
}
};
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<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());
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<QTemporaryDir> m_dir;
};
@@ -64,6 +64,16 @@
<content_rating type="oars-1.1"/>
<releases>
<release version="0.1.3" date="2026-08-31">
<description>
<ul>
<li>Redesign the Sankey pipeline layout: top-aligned funnel, columns spread across the full width, labels always visible</li>
<li>Order ribbons at each stage to stop them braiding over each other</li>
<li>Canonicalize stage names written via the CLI (e.g. "rejected" now maps to "Rejected") and repair existing databases</li>
<li>Debounce window-resize relayouts and stop re-reading the database on resize</li>
</ul>
</description>
</release>
<release version="0.1.2" date="2026-07-03">
<description>
<ul>
+1 -1
View File
@@ -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;
}
+25 -3
View File
@@ -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();
+13
View File
@@ -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<QString, int> columns{
+5
View File
@@ -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.
+29 -9
View File
@@ -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
}
}
+202 -50
View File
@@ -15,6 +15,7 @@
#include <QVariantMap>
#include <algorithm>
#include <iterator>
#include <numeric>
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<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;
}
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<StageTransition> transitions = m_db.stageTransitions();
QHash<QPair<QString, QString>, 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<QString, int> inbound;
QHash<QString, int> outbound;
QSet<QString> 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<int, QList<int>> 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<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
@@ -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<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();
qreal totalHeight = 0;
for (int idx : idxs) {
totalHeight += qMax<qreal>(nodeList.at(idx).value * scale, 2.0);
}
const qreal gaps = padding * qMax(0, idxs.size() - 1);
const qreal startY = qMax<qreal>(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<qreal>(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<QString, qreal> sourceCursor;
QHash<QString, qreal> targetCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
sourceCursor.insert(n.stage, n.y);
targetCursor.insert(n.stage, n.y);
}
QList<QPair<QString, QString>> linkKeys = counts.keys();
QList<QPair<QString, QString>> linkKeys = m_counts.keys();
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);
@@ -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<qreal> rawThickness;
rawThickness.reserve(linkKeys.size());
QHash<QString, qreal> outboundThickness;
QHash<QString, qreal> 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<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;
}
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));
}
// 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<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);
}
const qreal ya = nodeCenter(ka.second);
const qreal yb = nodeCenter(kb.second);
if (!qFuzzyCompare(ya, yb)) {
return ya < yb;
}
return nodeIndexByStage.value(ka.second) < nodeIndexByStage.value(kb.second);
});
QHash<QString, qreal> sourceCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
sourceCursor.insert(n.stage, n.y);
}
for (int i : std::as_const(order)) {
sourceYTop[i] = sourceCursor.value(linkKeys.at(i).first);
sourceCursor[linkKeys.at(i).first] = sourceYTop.at(i) + finalThickness.at(i);
}
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);
}
const qreal ya = nodeCenter(ka.first);
const qreal yb = nodeCenter(kb.first);
if (!qFuzzyCompare(ya, yb)) {
return ya < yb;
}
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
});
QHash<QString, qreal> targetCursor;
for (const NodeInfo &n : std::as_const(nodeList)) {
targetCursor.insert(n.stage, n.y);
}
for (int i : std::as_const(order)) {
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 = counts.value(key);
const qreal thickness = qMax<qreal>(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<qreal> 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<int>(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<qreal>(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},
});
}
+10 -3
View File
@@ -8,7 +8,9 @@
#include "jobsdatabase.h"
#include <QHash>
#include <QObject>
#include <QPair>
#include <QQmlEngine>
#include <QVariantList>
@@ -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<QPair<QString, QString>, int> m_counts;
QVariantList m_nodes;
QVariantList m_links;
};