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
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:
+1
-1
@@ -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
@@ -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();
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user