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
+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;
};