1/*
2 * SPDX-FileCopyrightText: 2020 Arjen Hiemstra <ahiemstra@heimr.nl>
3 *
4 * SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
5 */
6
7#include "LegendLayout.h"
8
9#include <cmath>
10
11#include "Chart.h"
12#include "ItemBuilder.h"
13#include "datasource/ChartDataSource.h"
14
15qreal sizeWithSpacing(int count, qreal size, qreal spacing)
16{
17 return size * count + spacing * (count - 1);
18}
19
20LegendLayoutAttached::LegendLayoutAttached(QObject *parent)
21 : QObject(parent)
22{
23}
24
25qreal LegendLayoutAttached::minimumWidth() const
26{
27 return m_minimumWidth.value_or(u: 0.0);
28}
29
30void LegendLayoutAttached::setMinimumWidth(qreal newMinimumWidth)
31{
32 if (newMinimumWidth == m_minimumWidth) {
33 return;
34 }
35
36 m_minimumWidth = newMinimumWidth;
37 Q_EMIT minimumWidthChanged();
38}
39
40bool LegendLayoutAttached::isMinimumWidthValid() const
41{
42 return m_minimumWidth.has_value();
43}
44
45qreal LegendLayoutAttached::preferredWidth() const
46{
47 return m_preferredWidth.value_or(u: 0.0);
48}
49
50void LegendLayoutAttached::setPreferredWidth(qreal newPreferredWidth)
51{
52 if (newPreferredWidth == m_preferredWidth) {
53 return;
54 }
55
56 m_preferredWidth = newPreferredWidth;
57 Q_EMIT preferredWidthChanged();
58}
59
60bool LegendLayoutAttached::isPreferredWidthValid() const
61{
62 return m_preferredWidth.has_value();
63}
64
65qreal LegendLayoutAttached::maximumWidth() const
66{
67 return m_maximumWidth.value_or(u: 0.0);
68}
69
70void LegendLayoutAttached::setMaximumWidth(qreal newMaximumWidth)
71{
72 if (newMaximumWidth == m_maximumWidth) {
73 return;
74 }
75
76 m_maximumWidth = newMaximumWidth;
77 Q_EMIT maximumWidthChanged();
78}
79
80bool LegendLayoutAttached::isMaximumWidthValid() const
81{
82 return m_maximumWidth.has_value();
83}
84
85LegendLayout::LegendLayout(QQuickItem *parent)
86 : QQuickItem(parent)
87{
88}
89
90qreal LegendLayout::horizontalSpacing() const
91{
92 return m_horizontalSpacing;
93}
94
95void LegendLayout::setHorizontalSpacing(qreal newHorizontalSpacing)
96{
97 if (newHorizontalSpacing == m_horizontalSpacing) {
98 return;
99 }
100
101 m_horizontalSpacing = newHorizontalSpacing;
102 polish();
103 Q_EMIT horizontalSpacingChanged();
104}
105
106qreal LegendLayout::verticalSpacing() const
107{
108 return m_verticalSpacing;
109}
110
111void LegendLayout::setVerticalSpacing(qreal newVerticalSpacing)
112{
113 if (newVerticalSpacing == m_verticalSpacing) {
114 return;
115 }
116
117 m_verticalSpacing = newVerticalSpacing;
118 polish();
119 Q_EMIT verticalSpacingChanged();
120}
121
122qreal LegendLayout::preferredWidth() const
123{
124 return m_preferredWidth;
125}
126
127void LegendLayout::componentComplete()
128{
129 QQuickItem::componentComplete();
130
131 m_completed = true;
132 polish();
133}
134
135void LegendLayout::updatePolish()
136{
137 if (!m_completed) {
138 return;
139 }
140
141 int columns = 0;
142 int rows = 0;
143 qreal itemWidth = 0.0;
144 qreal itemHeight = 0.0;
145
146 qreal layoutWidth = width();
147
148 std::tie(args&: columns, args&: rows, args&: itemWidth, args&: itemHeight) = determineColumns();
149
150 auto column = 0;
151 auto row = 0;
152
153 const auto items = childItems();
154 for (auto item : items) {
155 if (!item->isVisible() || item->implicitWidth() <= 0 || item->implicitHeight() <= 0) {
156 continue;
157 }
158
159 auto attached = static_cast<LegendLayoutAttached *>(qmlAttachedPropertiesObject<LegendLayout>(obj: item, create: true));
160
161 auto x = (itemWidth + m_horizontalSpacing) * column;
162 auto y = (itemHeight + m_verticalSpacing) * row;
163
164 item->setPosition(QPointF{x, y});
165 item->setWidth(std::clamp(val: itemWidth, lo: attached->minimumWidth(), hi: attached->maximumWidth()));
166
167 // If we are in single column mode, we are most likely width constrained.
168 // In that case, we should make sure items do not exceed our own width,
169 // so we can trigger things like text eliding.
170 if (layoutWidth > 0 && item->width() > layoutWidth && columns == 1) {
171 item->setWidth(layoutWidth);
172 }
173
174 column++;
175 if (column >= columns) {
176 row++;
177 column = 0;
178 }
179 }
180
181 setImplicitSize(sizeWithSpacing(count: columns, size: itemWidth, spacing: m_horizontalSpacing), sizeWithSpacing(count: rows, size: itemHeight, spacing: m_verticalSpacing));
182}
183
184void LegendLayout::geometryChange(const QRectF &newGeometry, const QRectF &oldGeometry)
185{
186 if (newGeometry != oldGeometry) {
187 polish();
188 }
189 QQuickItem::geometryChange(newGeometry, oldGeometry);
190}
191
192void LegendLayout::itemChange(QQuickItem::ItemChange change, const QQuickItem::ItemChangeData &data)
193{
194 if (change == QQuickItem::ItemVisibleHasChanged || change == QQuickItem::ItemSceneChange) {
195 polish();
196 }
197
198 if (change == QQuickItem::ItemChildAddedChange) {
199 auto item = data.item;
200
201 connect(sender: item, signal: &QQuickItem::implicitWidthChanged, context: this, slot: &LegendLayout::polish);
202 connect(sender: item, signal: &QQuickItem::implicitHeightChanged, context: this, slot: &LegendLayout::polish);
203 connect(sender: item, signal: &QQuickItem::visibleChanged, context: this, slot: &LegendLayout::polish);
204
205 auto attached = static_cast<LegendLayoutAttached *>(qmlAttachedPropertiesObject<LegendLayout>(obj: item, create: true));
206 connect(sender: attached, signal: &LegendLayoutAttached::minimumWidthChanged, context: this, slot: &LegendLayout::polish);
207 connect(sender: attached, signal: &LegendLayoutAttached::preferredWidthChanged, context: this, slot: &LegendLayout::polish);
208 connect(sender: attached, signal: &LegendLayoutAttached::maximumWidthChanged, context: this, slot: &LegendLayout::polish);
209
210 polish();
211 }
212
213 if (change == QQuickItem::ItemChildRemovedChange) {
214 auto item = data.item;
215
216 item->disconnect(receiver: this);
217 auto attached = static_cast<LegendLayoutAttached *>(qmlAttachedPropertiesObject<LegendLayout>(obj: item, create: false));
218 if (attached) {
219 attached->disconnect(receiver: this);
220 }
221
222 polish();
223 }
224
225 QQuickItem::itemChange(change, data);
226}
227
228// Determine how many columns and rows should be used for placing items and how
229// large each item should be.
230std::tuple<int, int, qreal, qreal> LegendLayout::determineColumns()
231{
232 auto minWidth = -std::numeric_limits<qreal>::max();
233 auto preferredWidth = -std::numeric_limits<qreal>::max();
234 auto maxWidth = std::numeric_limits<qreal>::max();
235 auto maxHeight = -std::numeric_limits<qreal>::max();
236
237 const auto items = childItems();
238
239 // Keep track of actual visual and visible items, since childItems() also
240 // includes stuff like repeaters.
241 auto itemCount = 0;
242
243 // First, we determine the minimum, preferred and maximum width of all
244 // items. These are determined from the attached object, or implicitWidth
245 // for minimum size if minimumWidth has not been set.
246 //
247 // We also determine the maximum height of items so we do not need to do
248 // that later.
249 for (auto item : items) {
250 if (!item->isVisible() || item->implicitWidth() <= 0 || item->implicitHeight() <= 0) {
251 continue;
252 }
253
254 auto attached = static_cast<LegendLayoutAttached *>(qmlAttachedPropertiesObject<LegendLayout>(obj: item, create: true));
255
256 if (attached->isMinimumWidthValid()) {
257 minWidth = std::max(a: minWidth, b: attached->minimumWidth());
258 } else {
259 minWidth = std::max(a: minWidth, b: item->implicitWidth());
260 }
261
262 if (attached->isPreferredWidthValid()) {
263 preferredWidth = std::max(a: preferredWidth, b: attached->preferredWidth());
264 }
265
266 if (attached->isMaximumWidthValid()) {
267 maxWidth = std::min(a: maxWidth, b: attached->maximumWidth());
268 }
269
270 maxHeight = std::max(a: maxHeight, b: item->implicitHeight());
271
272 itemCount++;
273 }
274
275 if (itemCount == 0) {
276 return std::make_tuple(args: 0, args: 0, args: 0, args: 0);
277 }
278
279 auto availableWidth = width();
280 // Check if we have a valid width. If we cannot even fit a horizontalSpacing
281 // we cannot do anything with the width and most likely did not get a width
282 // assigned, so come up with some reasonable default width.
283 //
284 // For the default, layout everything in a full row, using either maxWidth
285 // for each item if we have it or minWidth if we do not.
286 if (availableWidth <= m_horizontalSpacing) {
287 if (maxWidth <= 0.0) {
288 availableWidth = sizeWithSpacing(count: itemCount, size: minWidth, spacing: m_horizontalSpacing);
289 } else {
290 availableWidth = sizeWithSpacing(count: itemCount, size: maxWidth, spacing: m_horizontalSpacing);
291 }
292 }
293
294 // If none of the items have a maximum width set, default to filling all
295 // available space.
296 if (maxWidth <= 0.0 || maxWidth >= std::numeric_limits<qreal>::max()) {
297 maxWidth = availableWidth;
298 }
299
300 // Ensure we don't try to size things below their minimum size.
301 if (maxWidth < minWidth) {
302 maxWidth = minWidth;
303 }
304
305 if (preferredWidth != m_preferredWidth) {
306 m_preferredWidth = preferredWidth;
307 Q_EMIT preferredWidthChanged();
308 }
309
310 auto columns = 1;
311 auto rows = itemCount;
312 bool fit = true;
313
314 // Calculate the actual number of rows and columns by trying to fit items
315 // until we find the right number.
316 while (true) {
317 auto minTotalWidth = sizeWithSpacing(count: columns, size: minWidth, spacing: m_horizontalSpacing);
318 auto maxTotalWidth = sizeWithSpacing(count: columns, size: maxWidth, spacing: m_horizontalSpacing);
319
320 // If the minimum width is less than our width, but the maximum is
321 // larger, we found a correct solution since we can resize the items to
322 // fit within the provided bounds.
323 if (minTotalWidth <= availableWidth && maxTotalWidth >= availableWidth) {
324 break;
325 }
326
327 // As long as we have more space available than the items' max size,
328 // decrease the number of rows and that way increase the number of
329 // columns we use to place items - unless that results in no rows, as
330 // that means we've reached a state where we simply have more space than
331 // needed.
332 if (maxTotalWidth < availableWidth) {
333 rows--;
334 if (rows >= 1) {
335 columns = std::ceil(x: itemCount / float(rows));
336 } else {
337 fit = false;
338 break;
339 }
340 }
341
342 // In certain cases, we hit a corner case where decreasing the number of
343 // rows leads to things ending up outside of the item's bounds. If that
344 // happens, increase the number of rows by one and exit the loop.
345 if (minTotalWidth > availableWidth) {
346 rows += 1;
347 columns = std::ceil(x: itemCount / float(rows));
348 break;
349 }
350 }
351
352 // Calculate item width based on the calculated number of columns.
353 // If it turns out we have more space than needed, use maxWidth
354 // instead to avoid awkward gaps.
355 auto itemWidth = fit ? (availableWidth - m_horizontalSpacing * (columns - 1)) / columns : maxWidth;
356
357 // Recalculate the number of rows, otherwise we may end up with "ghost" rows
358 // since the items wrapped into a new column, but no all of them.
359 rows = std::ceil(x: itemCount / float(columns));
360
361 return std::make_tuple(args&: columns, args&: rows, args&: itemWidth, args&: maxHeight);
362}
363
364#include "moc_LegendLayout.cpp"
365

source code of kquickcharts/controls/LegendLayout.cpp